diff --git a/pkg/action/action.go b/pkg/action/action.go index 8105083f9..33db718a5 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -277,17 +277,24 @@ func splitAndDeannotate(postrendered, fallbackPrefix string) (map[string]string, // // This code has to do with writing files to disk. func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool, postRenderStrategy PostRenderStrategy) ([]*release.Hook, *bytes.Buffer, string, error) { + hs, b, notes, _, err := cfg.renderResourcesWithFiles(ctx, ch, values, releaseName, outputDir, subNotes, useReleaseName, includeCrds, pr, interactWithRemote, enableDNS, hideSecret, postRenderStrategy) + return hs, b, notes, err +} + +// renderResourcesWithFiles is the canonical render implementation, also returning the +// sorted manifest slice for sequencing-aware actions. +func (cfg *Configuration) renderResourcesWithFiles(ctx context.Context, ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool, postRenderStrategy PostRenderStrategy) ([]*release.Hook, *bytes.Buffer, string, []releaseutil.Manifest, error) { var hs []*release.Hook b := bytes.NewBuffer(nil) caps, err := cfg.getCapabilities() if err != nil { - return hs, b, "", err + return hs, b, "", nil, err } if ch.Metadata.KubeVersion != "" { if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) { - return hs, b, "", fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.Version) + return hs, b, "", nil, fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.Version) } } @@ -300,7 +307,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, if interactWithRemote && cfg.RESTClientGetter != nil { restConfig, err := cfg.RESTClientGetter.ToRESTConfig() if err != nil { - return hs, b, "", err + return hs, b, "", nil, err } e := engine.New(restConfig) e.EnableDNS = enableDNS @@ -316,7 +323,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, } if err2 != nil { - return hs, b, "", err2 + return hs, b, "", nil, err2 } // NOTES.txt gets rendered like all the other files, but because it's not a hook nor a resource, @@ -357,7 +364,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, } fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content) } - return hs, b, "", err + return hs, b, "", nil, err } // Build separate files maps for hooks and manifests. @@ -408,17 +415,17 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, merged, err := annotateAndMerge(group.files) if err != nil { - return hs, b, notes, fmt.Errorf("error merging %s: %w", group.name, err) + return hs, b, notes, nil, fmt.Errorf("error merging %s: %w", group.name, err) } postRendered, err := pr.Run(bytes.NewBufferString(merged)) if err != nil { - return hs, b, notes, fmt.Errorf("error while running post render on %s: %w", group.name, err) + return hs, b, notes, nil, fmt.Errorf("error while running post render on %s: %w", group.name, err) } rendered, err := splitAndDeannotate(postRendered.String(), group.name) if err != nil { - return hs, b, notes, fmt.Errorf("error while parsing post rendered output for %s: %w", group.name, err) + return hs, b, notes, nil, fmt.Errorf("error while parsing post rendered output for %s: %w", group.name, err) } for k, v := range rendered { @@ -440,22 +447,22 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, // Merge files as stream of documents for sending to post renderer merged, err := annotateAndMerge(files) if err != nil { - return hs, b, notes, fmt.Errorf("error merging manifests: %w", err) + return hs, b, notes, nil, fmt.Errorf("error merging manifests: %w", err) } // Run the post renderer postRendered, err := pr.Run(bytes.NewBufferString(merged)) if err != nil { - return hs, b, notes, fmt.Errorf("error while running post render on files: %w", err) + return hs, b, notes, nil, fmt.Errorf("error while running post render on files: %w", err) } // Use the file list and contents received from the post renderer files, err = splitAndDeannotate(postRendered.String(), "") if err != nil { - return hs, b, notes, fmt.Errorf("error while parsing post rendered output: %w", err) + return hs, b, notes, nil, fmt.Errorf("error while parsing post rendered output: %w", err) } default: - return hs, b, notes, fmt.Errorf("unknown post-render strategy: '%s'", postRenderStrategy) + return hs, b, notes, nil, fmt.Errorf("unknown post-render strategy: '%s'", postRenderStrategy) } } @@ -475,7 +482,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, } fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content) } - return hs, b, "", err + return hs, b, "", nil, err } // Aggregate all valid manifests into one big doc. @@ -488,7 +495,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, } else { err = writeToFile(outputDir, crd.Filename, string(crd.File.Data), fileWritten[crd.Filename]) if err != nil { - return hs, b, "", err + return hs, b, "", nil, err } fileWritten[crd.Filename] = true } @@ -513,13 +520,13 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, // used by install or upgrade err = writeToFile(newDir, m.Name, m.Content, fileWritten[m.Name]) if err != nil { - return hs, b, "", err + return hs, b, "", nil, err } fileWritten[m.Name] = true } } - return hs, b, notes, nil + return hs, b, notes, manifests, nil } // RESTClientGetter gets the rest client diff --git a/pkg/action/backward_compat_test.go b/pkg/action/backward_compat_test.go new file mode 100644 index 000000000..b4694c3e4 --- /dev/null +++ b/pkg/action/backward_compat_test.go @@ -0,0 +1,127 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/kube" + rcommon "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +func TestBackwardCompat_OrderedWaitWithoutAnnotations(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + buf := captureWarningOutput(t) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/first.yaml", "first", nil), + makeConfigMapTemplate("templates/second.yaml", "second", nil), + }, withName("backward-compat-no-annotations")) + + rel := mustRelease(t, mustRunInstall(t, install, ch)) + + require.Len(t, client.createCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/first", "ConfigMap/second"}, client.createCalls[0]) + require.Len(t, client.waitCalls, 1) + assert.ElementsMatch(t, client.createCalls[0], client.waitCalls[0]) + assert.Empty(t, buf.String()) + assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status) +} + +func TestBackwardCompat_RollbackWithoutSequencingInfo(t *testing.T) { + client := newRecordingKubeClient() + rollback := newRollbackAction(t, client) + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("backward-compat-rollback")) + seedRollbackRelease(t, rollback, "backward-compat-rollback", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("app", nil), + ), nil) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("backward-compat-rollback")) + seedRollbackRelease(t, rollback, "backward-compat-rollback", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + rollback.Version = 1 + + var runErr error + assert.NotPanics(t, func() { + runErr = rollback.Run("backward-compat-rollback") + }) + require.NoError(t, runErr) + + require.Len(t, client.updateCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target) + require.Len(t, client.waitCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0]) + assert.Nil(t, latestRollbackRelease(t, rollback, "backward-compat-rollback").SequencingInfo) +} + +func TestBackwardCompat_MixedAnnotationsUnsequencedLast(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + buf := captureWarningOutput(t) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeConfigMapTemplate("templates/plain-one.yaml", "plain-one", nil), + makeConfigMapTemplate("templates/plain-two.yaml", "plain-two", nil), + }, withName("backward-compat-mixed")) + + rel := mustRelease(t, mustRunInstall(t, install, ch)) + + require.Len(t, client.createCalls, 3) + assert.Equal(t, []string{"ConfigMap/database"}, client.createCalls[0]) + assert.Equal(t, []string{"ConfigMap/app"}, client.createCalls[1]) + assert.ElementsMatch(t, []string{"ConfigMap/plain-one", "ConfigMap/plain-two"}, client.createCalls[2]) + // Every batch, including terminal groups, is waited on. + require.Len(t, client.waitCalls, 3) + assert.Equal(t, []string{"ConfigMap/database"}, client.waitCalls[0]) + assert.Equal(t, []string{"ConfigMap/app"}, client.waitCalls[1]) + assert.ElementsMatch(t, client.createCalls[2], client.waitCalls[2]) + assert.Empty(t, buf.String()) + assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status) +} diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go index d7824d69f..459b1dcca 100644 --- a/pkg/action/hooks.go +++ b/pkg/action/hooks.go @@ -84,6 +84,15 @@ func (cfg *Configuration) execHookWithDelayedShutdown(rl *release.Release, hook return shutdownNoOp, fmt.Errorf("unable to build kubernetes object for %s hook %s: %w", hook, h.Path, err) } + // HIP-0025 sequencing annotations are ignored on hooks (spec: "Annotations + // added to resources in hooks will be ignored"). Strip them before apply so + // the multi-slash keys (e.g. helm.sh/depends-on/resource-groups), which are + // not valid Kubernetes annotation keys, do not cause the API server to reject + // the hook. + if err := stripSequencingAnnotations(resources); err != nil { + return shutdownNoOp, fmt.Errorf("unable to strip sequencing annotations from %s hook %s: %w", hook, h.Path, err) + } + // Record the time at which the hook was applied to the cluster h.LastRun = release.HookExecution{ StartedAt: time.Now(), diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go index b017b1f11..a966bc705 100644 --- a/pkg/action/hooks_test.go +++ b/pkg/action/hooks_test.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "io" + "maps" "reflect" "strings" "testing" @@ -29,7 +30,9 @@ import ( "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/cli-runtime/pkg/resource" @@ -445,6 +448,97 @@ func TestConfiguration_hookSetDeletePolicy(t *testing.T) { } } +// annotationRecordingKubeClient captures the annotations present on each +// resource at Create time, so a test can assert what actually reached the API. +type annotationRecordingKubeClient struct { + kubefake.PrintingKubeClient + createdAnnotations []map[string]string +} + +func (c *annotationRecordingKubeClient) Build(reader io.Reader, _ bool) (kube.ResourceList, error) { + u := &unstructured.Unstructured{} + if err := yaml.NewYAMLOrJSONDecoder(reader, 4096).Decode(u); err != nil { + return kube.ResourceList{}, err + } + return kube.ResourceList{{ + Name: u.GetName(), + Namespace: u.GetNamespace(), + Object: u, + }}, nil +} + +func (c *annotationRecordingKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) { + for _, info := range resources { + acc, err := meta.Accessor(info.Object) + if err != nil { + continue + } + anns := maps.Clone(acc.GetAnnotations()) + if anns == nil { + anns = map[string]string{} + } + c.createdAnnotations = append(c.createdAnnotations, anns) + } + return &kube.Result{Created: resources}, nil +} + +// TestExecHook_StripsSequencingAnnotations verifies HIP-0025 sequencing +// annotations are removed from hook resources before apply (spec: annotations +// on hooks are ignored). The multi-slash keys are not valid K8s annotation +// keys, so leaving them would make the API server reject the hook. +func TestExecHook_StripsSequencingAnnotations(t *testing.T) { + is := assert.New(t) + + client := &annotationRecordingKubeClient{ + PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, + } + configuration := &Configuration{ + Releases: storage.Init(driver.NewMemory()), + KubeClient: client, + Capabilities: common.DefaultCapabilities, + } + + rel := &release.Release{ + Name: "test-release", + Namespace: "test", + Hooks: []*release.Hook{ + { + Name: "test-hook", + Kind: "ConfigMap", + Path: "templates/hook.yaml", + Manifest: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-hook + namespace: test + annotations: + helm.sh/resource-group: "grp" + helm.sh/depends-on/resource-groups: '["other"]' + keep-me: "yes" +data: + foo: bar +`, + Weight: 0, + Events: []release.HookEvent{release.HookPreInstall}, + }, + }, + } + + err := configuration.execHook(rel, release.HookPreInstall, kube.StatusWatcherStrategy, nil, 600, false) + is.NoError(err) + + if is.Len(client.createdAnnotations, 1) { + anns := client.createdAnnotations[0] + // The multi-slash key is not a valid K8s annotation key and would be + // rejected at apply — it must be stripped from hooks (this is the bug). + is.NotContains(anns, "helm.sh/depends-on/resource-groups", "multi-slash sequencing annotation must be stripped from hooks") + // helm.sh/resource-group is a valid single-slash key; like the + // install/upgrade paths, it is left in place (harmlessly ignored on hooks). + is.Contains(anns, "helm.sh/resource-group", "valid single-slash annotation is retained, matching install/upgrade") + is.Equal("yes", anns["keep-me"], "non-sequencing annotations must survive") + } +} + func TestExecHook_WaitOptionsPassedDownstream(t *testing.T) { is := assert.New(t) diff --git a/pkg/action/install.go b/pkg/action/install.go index d36d2fc3e..466563670 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -56,6 +56,7 @@ import ( ri "helm.sh/helm/v4/pkg/release" rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/v1/sequence" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/repo/v1" "helm.sh/helm/v4/pkg/storage" @@ -100,6 +101,10 @@ type Install struct { Devel bool DependencyUpdate bool Timeout time.Duration + // ReadinessTimeout is the per-batch timeout when --wait=ordered is used. + // Each ordered batch waits at most this long for resources to become ready. + // Must not exceed Timeout. Defaults to 1 minute when zero. + ReadinessTimeout time.Duration Namespace string ReleaseName string GenerateName bool @@ -310,6 +315,10 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return nil, fmt.Errorf("release name check failed: %w", err) } + if i.ReadinessTimeout > 0 && i.Timeout > 0 && i.ReadinessTimeout > i.Timeout { + return nil, fmt.Errorf("--readiness-timeout (%s) must not exceed --timeout (%s)", i.ReadinessTimeout, i.Timeout) + } + if err := chartutil.ProcessDependencies(chrt, vals); err != nil { i.cfg.Logger().Error("chart dependencies processing failed", slog.Any("error", err)) return nil, fmt.Errorf("chart dependencies processing failed: %w", err) @@ -374,8 +383,13 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st rel := i.createRelease(chrt, vals, i.Labels) + var sortedManifests []releaseutil.Manifest var manifestDoc *bytes.Buffer - rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(ctx, chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret, i.PostRenderStrategy) + if i.WaitStrategy == kube.OrderedWaitStrategy { + rel.Hooks, manifestDoc, rel.Info.Notes, sortedManifests, err = i.cfg.renderResourcesWithFiles(ctx, chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret, i.PostRenderStrategy) + } else { + rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(ctx, chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret, i.PostRenderStrategy) + } // Even for errors, attach this if available if manifestDoc != nil { rel.Manifest = manifestDoc.String() @@ -425,6 +439,18 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return rel, nil } + var plan *sequence.Plan + if i.WaitStrategy == kube.OrderedWaitStrategy { + // Build the deployment plan before any cluster or storage side effect: + // DAG errors (cycles, duplicate group membership) now fail cleanly with + // nothing stored and nothing applied. + plan, err = sequence.Build(chrt, sortedManifests) + if err != nil { + rel.SetStatus(rcommon.StatusFailed, "failed to build sequencing plan: "+err.Error()) + return rel, err + } + } + if i.CreateNamespace { ns := &v1.Namespace{ TypeMeta: metav1.TypeMeta{ @@ -469,8 +495,15 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return rel, err } - rel, err = i.performInstallCtx(ctx, rel, toBeAdopted, resources) + if i.WaitStrategy == kube.OrderedWaitStrategy { + rel, err = i.performSequencedInstallCtx(ctx, rel, plan, toBeAdopted) + } else { + rel, err = i.performInstallCtx(ctx, rel, toBeAdopted, resources) + } if err != nil { + // failRelease cleans up a failed install via the context-free Uninstall.Run + // public API (upstream keeps Uninstall context-free); threading ctx through the + // whole uninstall subsystem is out of scope for this best-effort recovery path. rel, err = i.failRelease(rel, err) } return rel, err @@ -498,6 +531,89 @@ func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, t } } +// performSequencedInstallCtx runs performSequencedInstall in a goroutine and +// respects context cancellation, mirroring performInstallCtx. +func (i *Install) performSequencedInstallCtx(ctx context.Context, rel *release.Release, plan *sequence.Plan, toBeAdopted kube.ResourceList) (*release.Release, error) { + type Msg struct { + r *release.Release + e error + } + + resultChan := make(chan Msg, 1) + + go func() { + i.goroutineCount.Add(1) + rel, err := i.performSequencedInstall(ctx, rel, plan, toBeAdopted) + resultChan <- Msg{rel, err} + i.goroutineCount.Add(-1) + }() + + select { + case <-ctx.Done(): + return rel, ctx.Err() + case msg := <-resultChan: + return msg.r, msg.e + } +} + +// performSequencedInstall deploys chart resources in DAG-ordered batches when +// --wait=ordered is active. +func (i *Install) performSequencedInstall(ctx context.Context, rel *release.Release, plan *sequence.Plan, toBeAdopted kube.ResourceList) (*release.Release, error) { + if !i.DisableHooks { + if err := i.cfg.execHook(rel, release.HookPreInstall, i.WaitStrategy, i.WaitOptions, i.Timeout, i.ServerSideApply); err != nil { + return rel, fmt.Errorf("failed pre-install: %w", err) + } + } + + logPlanWarnings(i.cfg.Logger(), plan) + + readinessTimeout := i.ReadinessTimeout + if readinessTimeout <= 0 { + readinessTimeout = time.Minute + } + + sd := &sequencedDeployment{ + cfg: i.cfg, + releaseName: rel.Name, + releaseNamespace: rel.Namespace, + disableOpenAPI: i.DisableOpenAPIValidation, + serverSideApply: i.ServerSideApply, + forceConflicts: i.ForceConflicts, + forceReplace: i.ForceReplace, + waitStrategy: i.WaitStrategy, + waitOptions: i.WaitOptions, + waitForJobs: i.WaitForJobs, + timeout: i.Timeout, + readinessTimeout: readinessTimeout, + deadline: computeDeadline(i.Timeout), + currentResources: toBeAdopted, + upgradeCSAFieldManager: true, + threeWayMergeForUnstructured: i.TakeOwnership && !i.ServerSideApply, + } + + if err := sd.apply(ctx, plan); err != nil { + return rel, err + } + + if !i.DisableHooks { + if err := i.cfg.execHook(rel, release.HookPostInstall, i.WaitStrategy, i.WaitOptions, i.Timeout, i.ServerSideApply); err != nil { + return rel, fmt.Errorf("failed post-install: %w", err) + } + } + + if len(i.Description) > 0 { + rel.SetStatus(rcommon.StatusDeployed, i.Description) + } else { + rel.SetStatus(rcommon.StatusDeployed, "Install complete") + } + + if err := i.recordRelease(rel); err != nil { + i.cfg.Logger().Error("failed to record the release", slog.Any("error", err)) + } + + return rel, nil +} + // getGoroutineCount return the number of running routines func (i *Install) getGoroutineCount() int32 { return i.goroutineCount.Load() @@ -512,6 +628,10 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource } } + if err := stripSequencingAnnotations(resources); err != nil { + return rel, fmt.Errorf("stripping sequencing annotations: %w", err) + } + // At this point, we can do the install. Note that before we were detecting whether to // do an update, but it's not clear whether we WANT to do an update if the reuse is set // to true, since that is basically an upgrade operation. @@ -674,6 +794,10 @@ func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]any, label Version: 1, Labels: labels, ApplyMethod: string(determineReleaseSSApplyMethod(i.ServerSideApply)), + // Sequenced is set before the release record is first persisted so any + // failure-cleanup path (uninstall via RollbackOnFailure) sees the + // ordering intent even when the install dies mid-deployment (ll8/0vr). + Sequenced: i.WaitStrategy == kube.OrderedWaitStrategy, } return r diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 6fc449c30..21d0a0f1d 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -18,6 +18,7 @@ package action import ( "bytes" + "context" "errors" "fmt" "time" @@ -28,6 +29,7 @@ import ( "helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/v1/sequence" "helm.sh/helm/v4/pkg/storage/driver" ) @@ -37,12 +39,13 @@ import ( type Rollback struct { cfg *Configuration - Version int - Timeout time.Duration - WaitStrategy kube.WaitStrategy - WaitOptions []kube.WaitOption - WaitForJobs bool - DisableHooks bool + Version int + Timeout time.Duration + ReadinessTimeout time.Duration + WaitStrategy kube.WaitStrategy + WaitOptions []kube.WaitOption + WaitForJobs bool + DisableHooks bool // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster DryRunStrategy DryRunStrategy // ForceReplace will, if set to `true`, ignore certain warnings and perform the rollback anyway. @@ -72,10 +75,22 @@ func NewRollback(cfg *Configuration) *Rollback { // Run executes 'helm rollback' against the given release. func (r *Rollback) Run(name string) error { + return r.RunWithContext(context.Background(), name) +} + +// RunWithContext executes 'helm rollback' against the given release, propagating +// ctx to the sequenced-wait path. It mirrors the Run/RunWithContext split used by +// Install and Upgrade so callers that already hold a context (e.g. upgrade's +// rollback-on-failure) can thread it through instead of starting a fresh one. +func (r *Rollback) RunWithContext(ctx context.Context, name string) error { if err := r.cfg.KubeClient.IsReachable(); err != nil { return err } + if r.ReadinessTimeout > 0 && r.Timeout > 0 && r.ReadinessTimeout > r.Timeout { + return fmt.Errorf("--readiness-timeout (%s) must not exceed --timeout (%s)", r.ReadinessTimeout, r.Timeout) + } + r.cfg.Releases.MaxHistory = r.MaxHistory r.cfg.Logger().Debug("preparing rollback", "name", name) @@ -92,7 +107,7 @@ func (r *Rollback) Run(name string) error { } r.cfg.Logger().Debug("performing rollback", "name", name) - if _, err := r.performRollback(currentRelease, targetRelease, serverSideApply); err != nil { + if _, err := r.performRollback(ctx, currentRelease, targetRelease, serverSideApply); err != nil { return err } @@ -190,17 +205,22 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele Manifest: previousRelease.Manifest, Hooks: previousRelease.Hooks, ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)), + Sequenced: previousRelease.IsSequenced(), } return currentRelease, targetRelease, serverSideApply, nil } -func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) { +func (r *Rollback) performRollback(ctx context.Context, currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) { if isDryRun(r.DryRunStrategy) { r.cfg.Logger().Debug("dry run", "name", targetRelease.Name) return targetRelease, nil } + if targetRelease.IsSequenced() { + return r.performSequencedRollback(ctx, currentRelease, targetRelease, serverSideApply) + } + current, err := r.cfg.KubeClient.Build(bytes.NewBufferString(currentRelease.Manifest), false) if err != nil { return targetRelease, fmt.Errorf("unable to build kubernetes objects from current release manifest: %w", err) @@ -225,6 +245,19 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas if err != nil { return targetRelease, fmt.Errorf("unable to set metadata visitor from target release: %w", err) } + + // Strip Helm-internal sequencing annotations from both current and target + // before applying to K8s. The stored Manifest in a release secret retains + // the raw annotations even though they were stripped before the original + // apply; without this strip, SSA on rollback rejects the multi-slash + // annotation key (e.g. helm.sh/depends-on/resource-groups) as invalid. + if err := stripSequencingAnnotations(current); err != nil { + return targetRelease, fmt.Errorf("stripping sequencing annotations from current release: %w", err) + } + if err := stripSequencingAnnotations(target); err != nil { + return targetRelease, fmt.Errorf("stripping sequencing annotations from target release: %w", err) + } + results, err := r.cfg.KubeClient.Update( current, target, @@ -305,3 +338,151 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas return targetRelease, nil } + +func (r *Rollback) failRollback(currentRelease, targetRelease *release.Release, created kube.ResourceList, err error) (*release.Release, error) { + msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err) + r.cfg.Logger().Warn(msg) + + currentRelease.Info.Status = common.StatusSuperseded + targetRelease.Info.Status = common.StatusFailed + targetRelease.Info.Description = msg + r.cfg.recordRelease(currentRelease) + r.cfg.recordRelease(targetRelease) + + if r.CleanupOnFail && len(created) > 0 { + r.cfg.Logger().Debug("cleanup on fail set, cleaning up resources", "count", len(created)) + _, errs := r.cfg.KubeClient.Delete(created, metav1.DeletePropagationBackground) + if errs != nil { + return targetRelease, fmt.Errorf( + "an error occurred while cleaning up resources after rollback failure: %w: %w", + err, + joinErrors(errs, ", "), + ) + } + r.cfg.Logger().Debug("resource cleanup complete") + } + + return targetRelease, err +} + +// performSequencedRollback deploys the target revision's resources in DAG-ordered batches +// when the target revision was originally deployed with --wait=ordered. +func (r *Rollback) performSequencedRollback(ctx context.Context, currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) { + fail := func(created kube.ResourceList, err error) (*release.Release, error) { + return r.failRollback(currentRelease, targetRelease, created, err) + } + + current, err := r.cfg.KubeClient.Build(bytes.NewBufferString(currentRelease.Manifest), false) + if err != nil { + return fail(nil, fmt.Errorf("unable to build kubernetes objects from current release manifest: %w", err)) + } + if err := stripSequencingAnnotations(current); err != nil { + return fail(nil, fmt.Errorf("stripping sequencing annotations from current release: %w", err)) + } + + target, err := r.cfg.KubeClient.Build(bytes.NewBufferString(targetRelease.Manifest), false) + if err != nil { + return fail(nil, fmt.Errorf("unable to build kubernetes objects from target release manifest: %w", err)) + } + + sortedManifests, err := sequence.ParseStoredManifests(targetRelease.Manifest) + if err != nil { + return fail(nil, fmt.Errorf("parsing target release manifest for sequenced rollback: %w", err)) + } + + plan, err := sequence.Build(targetRelease.Chart, sortedManifests) + if err != nil { + return fail(nil, fmt.Errorf("building sequencing plan for rollback: %w", err)) + } + logPlanWarnings(r.cfg.Logger(), plan) + + if !r.DisableHooks { + if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.WaitStrategy, r.WaitOptions, r.Timeout, serverSideApply); err != nil { + return fail(nil, err) + } + } else { + r.cfg.Logger().Debug("rollback hooks disabled", "name", targetRelease.Name) + } + + readinessTimeout := r.ReadinessTimeout + if readinessTimeout <= 0 { + readinessTimeout = time.Minute + } + + gatingStrategy := r.WaitStrategy + if gatingStrategy == kube.HookOnlyStrategy { + r.cfg.Logger().Info("release was deployed with ordered sequencing; using the status watcher to gate rollback batches (hooks keep the hook-only strategy)", "release", targetRelease.Name) + gatingStrategy = kube.StatusWatcherStrategy + } + + sd := &sequencedDeployment{ + cfg: r.cfg, + releaseName: targetRelease.Name, + releaseNamespace: targetRelease.Namespace, + serverSideApply: serverSideApply, + forceConflicts: r.ForceConflicts, + forceReplace: r.ForceReplace, + waitStrategy: gatingStrategy, + waitOptions: r.WaitOptions, + waitForJobs: r.WaitForJobs, + timeout: r.Timeout, + readinessTimeout: readinessTimeout, + deadline: computeDeadline(r.Timeout), + upgradeMode: true, + currentResources: current, + upgradeCSAFieldManager: true, + } + + if err := sd.apply(ctx, plan); err != nil { + return fail(sd.createdResources, err) + } + + allTargetKeys := make(map[string]bool, len(target)) + for _, resource := range target { + allTargetKeys[objectKey(resource)] = true + } + + removedKeys := make(map[string]bool) + var toBeDeleted kube.ResourceList + for _, resource := range current { + if !allTargetKeys[objectKey(resource)] { + removedKeys[objectKey(resource)] = true + toBeDeleted = append(toBeDeleted, resource) + } + } + + if len(toBeDeleted) > 0 { + waiter, err := getWaiterFor(r.cfg.KubeClient, gatingStrategy, r.WaitOptions...) + if err != nil { + return fail(sd.createdResources, fmt.Errorf("getting waiter for removed-resource deletion: %w", err)) + } + if err := sd.deleteRemovedFromOldRelease(currentRelease, removedKeys, toBeDeleted, waiter); err != nil { + return fail(sd.createdResources, fmt.Errorf("deleting removed resources during rollback: %w", err)) + } + } + + if !r.DisableHooks { + if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.WaitStrategy, r.WaitOptions, r.Timeout, serverSideApply); err != nil { + return fail(sd.createdResources, err) + } + } + + deployed, err := r.cfg.Releases.DeployedAll(currentRelease.Name) + if err != nil && !errors.Is(err, driver.ErrNoDeployedReleases) { + return fail(sd.createdResources, err) + } + + for _, reli := range deployed { + rel, err := releaserToV1Release(reli) + if err != nil { + return fail(sd.createdResources, err) + } + r.cfg.Logger().Debug("superseding previous deployment", "version", rel.Version) + rel.Info.Status = common.StatusSuperseded + r.cfg.recordRelease(rel) + } + + targetRelease.Info.Status = common.StatusDeployed + + return targetRelease, nil +} diff --git a/pkg/action/rollback_sequenced_test.go b/pkg/action/rollback_sequenced_test.go new file mode 100644 index 000000000..494492d16 --- /dev/null +++ b/pkg/action/rollback_sequenced_test.go @@ -0,0 +1,538 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/kube" + rcommon "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +func newRollbackAction(t *testing.T, kubeClient kube.Interface) *Rollback { + t.Helper() + + cfg := actionConfigFixture(t) + cfg.KubeClient = kubeClient + + rollback := NewRollback(cfg) + rollback.Timeout = 5 * time.Minute + rollback.WaitStrategy = kube.OrderedWaitStrategy + rollback.ServerSideApply = "auto" + + return rollback +} + +func seedRollbackRelease(t *testing.T, rollback *Rollback, name string, version int, status rcommon.Status, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo) { + t.Helper() + + now := time.Now() + rel := &release.Release{ + Name: name, + Namespace: "spaced", + Chart: ch, + Config: map[string]any{}, + Manifest: manifest, + Info: &release.Info{ + FirstDeployed: now, + LastDeployed: now, + Status: status, + Description: "Seeded release", + }, + Version: version, + ApplyMethod: "csa", + SequencingInfo: sequencingInfo, + } + + require.NoError(t, rollback.cfg.Releases.Create(rel)) +} + +func storedRollbackRelease(t *testing.T, rollback *Rollback, name string, version int) *release.Release { + t.Helper() + + reli, err := rollback.cfg.Releases.Get(name, version) + require.NoError(t, err) + + rel, err := releaserToV1Release(reli) + require.NoError(t, err) + + return rel +} + +func latestRollbackRelease(t *testing.T, rollback *Rollback, name string) *release.Release { + t.Helper() + + reli, err := rollback.cfg.Releases.Last(name) + require.NoError(t, err) + + rel, err := releaserToV1Release(reli) + require.NoError(t, err) + + return rel +} + +func TestRollback_Sequenced_Basic(t *testing.T) { + client := newRecordingKubeClient() + rollback := newRollbackAction(t, client) + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("rollback-basic")) + + sequencingInfo := &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)} + seedRollbackRelease(t, rollback, "rollback-basic", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), sequencingInfo) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-basic")) + seedRollbackRelease(t, rollback, "rollback-basic", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("app", nil), + ), nil) + + rollback.Version = 1 + require.NoError(t, rollback.Run("rollback-basic")) + + require.Len(t, client.updateCalls, 2) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls)) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls) + assert.Equal(t, []string{"ConfigMap/database"}, client.updateCalls[0].current) + assert.Equal(t, []string{"ConfigMap/app"}, client.updateCalls[1].current) + + rel := latestRollbackRelease(t, rollback, "rollback-basic") + assert.Equal(t, 3, rel.Version) + assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status) + assert.True(t, rel.IsSequenced()) + assert.True(t, rel.Sequenced) + assert.Nil(t, rel.SequencingInfo) +} + +// TestRollback_Sequenced_RemovedGroupsDeletedInReverseOrder locks bead +// 7yi-rollback: resources present in the from-revision but absent from the +// rollback target must be deleted batch-by-batch in exact reverse deployment +// order of the FROM revision's plan, with a delete-wait gating each batch, +// not in one unordered bulk call. +func TestRollback_Sequenced_RemovedGroupsDeletedInReverseOrder(t *testing.T) { + client := newRecordingKubeClient() + rollback := newRollbackAction(t, client) + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-removed")) + seedRollbackRelease(t, rollback, "rollback-removed", 1, rcommon.StatusSuperseded, targetChart, configMapManifest("app", nil), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-removed")) + seedRollbackRelease(t, rollback, "rollback-removed", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + configMapManifest("app", nil), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + rollback.Version = 1 + require.NoError(t, rollback.Run("rollback-removed")) + + assert.Equal(t, [][]string{{"ConfigMap/queue"}, {"ConfigMap/database"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) +} + +// TestRollback_Sequenced_DefaultWaitStrategyGatesBatches locks bead 4sj for +// rollback: a rollback to a sequenced revision under the DEFAULT hookOnly +// strategy must gate each re-deploy batch on real readiness waits. +func TestRollback_Sequenced_DefaultWaitStrategyGatesBatches(t *testing.T) { + client := newRecordingKubeClient() + cfg := actionConfigFixture(t) + cfg.KubeClient = client + + rollback := NewRollback(cfg) + rollback.Timeout = 5 * time.Minute + rollback.WaitStrategy = kube.HookOnlyStrategy + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("rollback-default-wait")) + + now := time.Now() + require.NoError(t, rollback.cfg.Releases.Create(&release.Release{ + Name: "rollback-default-wait", + Namespace: "spaced", + Chart: targetChart, + Config: map[string]any{}, + Manifest: joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), + Info: &release.Info{ + FirstDeployed: now, + LastDeployed: now, + Status: rcommon.StatusSuperseded, + Description: "Seeded release", + }, + Version: 1, + ApplyMethod: "csa", + Sequenced: true, + })) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-default-wait")) + seedRollbackRelease(t, rollback, "rollback-default-wait", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("app", nil), + ), nil) + + rollback.Version = 1 + require.NoError(t, rollback.Run("rollback-default-wait")) + + require.Len(t, client.updateCalls, 2) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls)) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls) + assert.Contains(t, client.waiterStrategies, kube.StatusWatcherStrategy) +} + +func TestRollback_ToNonSequenced(t *testing.T) { + client := newRecordingKubeClient() + rollback := newRollbackAction(t, client) + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("rollback-to-standard")) + seedRollbackRelease(t, rollback, "rollback-to-standard", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), nil) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-to-standard")) + seedRollbackRelease(t, rollback, "rollback-to-standard", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("app", nil), + ), nil) + + rollback.Version = 1 + require.NoError(t, rollback.Run("rollback-to-standard")) + + require.Len(t, client.updateCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target) + require.Len(t, client.waitCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0]) + assert.False(t, latestRollbackRelease(t, rollback, "rollback-to-standard").IsSequenced()) +} + +func TestRollback_FromSequencedToNonSequenced(t *testing.T) { + client := newRecordingKubeClient() + rollback := newRollbackAction(t, client) + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-transition")) + seedRollbackRelease(t, rollback, "rollback-transition", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("app", nil), + ), nil) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("rollback-transition")) + seedRollbackRelease(t, rollback, "rollback-transition", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + rollback.Version = 1 + require.NoError(t, rollback.Run("rollback-transition")) + + require.Len(t, client.updateCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target) + assert.False(t, latestRollbackRelease(t, rollback, "rollback-transition").IsSequenced()) +} + +func TestRollback_SequencedFlagPreservedFromLegacyRelease(t *testing.T) { + client := newRecordingKubeClient() + rollback := newRollbackAction(t, client) + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + }, withName("rollback-sequencing-info")) + sequencingInfo := &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)} + seedRollbackRelease(t, rollback, "rollback-sequencing-info", 1, rcommon.StatusSuperseded, targetChart, configMapManifest("database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), sequencingInfo) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + }, withName("rollback-sequencing-info")) + seedRollbackRelease(t, rollback, "rollback-sequencing-info", 2, rcommon.StatusDeployed, currentChart, configMapManifest("database", nil), nil) + + rollback.Version = 1 + require.NoError(t, rollback.Run("rollback-sequencing-info")) + + rel := latestRollbackRelease(t, rollback, "rollback-sequencing-info") + assert.True(t, rel.IsSequenced()) + assert.True(t, rel.Sequenced) + assert.Nil(t, rel.SequencingInfo) +} + +func TestRollback_Sequenced_Failure(t *testing.T) { + client := newRecordingKubeClient() + client.waitErrorOnCall = 2 + client.waitError = errors.New("timed out waiting for batch") + + rollback := newRollbackAction(t, client) + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + }, withName("rollback-failure")) + seedRollbackRelease(t, rollback, "rollback-failure", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-failure")) + seedRollbackRelease(t, rollback, "rollback-failure", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("app", nil), + ), nil) + + rollback.Version = 1 + err := rollback.Run("rollback-failure") + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out waiting for batch") + + require.Len(t, client.updateCalls, 2) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}}, updateTargets(client.updateCalls)) + assert.Empty(t, client.deleteCalls) + + assert.Equal(t, rcommon.StatusFailed, latestRollbackRelease(t, rollback, "rollback-failure").Info.Status) + assert.Equal(t, rcommon.StatusSuperseded, storedRollbackRelease(t, rollback, "rollback-failure", 2).Info.Status) +} + +func TestRollback_Sequenced_CleanupOnFail(t *testing.T) { + client := newRecordingKubeClient() + client.waitErrorOnCall = 2 + client.waitError = errors.New("timed out waiting for batch") + + rollback := newRollbackAction(t, client) + rollback.CleanupOnFail = true + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + }, withName("rollback-cleanup")) + seedRollbackRelease(t, rollback, "rollback-cleanup", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-cleanup")) + seedRollbackRelease(t, rollback, "rollback-cleanup", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("app", nil), + ), nil) + + rollback.Version = 1 + err := rollback.Run("rollback-cleanup") + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out waiting for batch") + assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls) + assert.Equal(t, rcommon.StatusFailed, latestRollbackRelease(t, rollback, "rollback-cleanup").Info.Status) +} + +func TestRollback_Sequenced_DryRun(t *testing.T) { + client := newRecordingKubeClient() + rollback := newRollbackAction(t, client) + rollback.DryRunStrategy = DryRunClient + + targetChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("rollback-dry-run")) + seedRollbackRelease(t, rollback, "rollback-dry-run", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + makeConfigMapTemplate("templates/app.yaml", "app", nil), + }, withName("rollback-dry-run")) + seedRollbackRelease(t, rollback, "rollback-dry-run", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("app", nil), + ), nil) + + rollback.Version = 1 + require.NoError(t, rollback.Run("rollback-dry-run")) + + assert.Empty(t, client.updateCalls) + assert.Empty(t, client.waitCalls) + assert.Empty(t, client.deleteCalls) + assert.Equal(t, 2, latestRollbackRelease(t, rollback, "rollback-dry-run").Version) +} + +// TestRollback_Sequenced_StorageDecodedChart_Subcharts guards the rollback +// leg of bead xmn: the target revision's chart comes from release storage, +// which drops the loaded dependency tree, so the forward plan must be +// rebuilt from the stored metadata and manifest paths: subchart resources +// first, then the parent's own. +func TestRollback_Sequenced_StorageDecodedChart_Subcharts(t *testing.T) { + client := newRecordingKubeClient() + rollback := newRollbackAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + bar := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/bar.yaml", "bar", nil), + }, withName("bar")) + parent.AddDependency(bar) + parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar", Enabled: true}} + parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["bar"]`} + + seedRollbackRelease(t, rollback, "decoded-rollback", 1, rcommon.StatusSuperseded, storageDecodedChart(t, parent), joinManifestDocs( + sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)), + sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + seedRollbackRelease(t, rollback, "decoded-rollback", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs( + configMapManifest("parent", nil), + configMapManifest("bar", nil), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + rollback.Version = 1 + require.NoError(t, rollback.Run("decoded-rollback")) + + // Forward deployment order: the subchart's batch first, then the parent's. + assert.Equal(t, [][]string{{"ConfigMap/bar"}, {"ConfigMap/parent"}}, updateTargets(client.updateCalls)) + assert.Equal(t, [][]string{{"ConfigMap/bar"}, {"ConfigMap/parent"}}, client.waitCalls) +} diff --git a/pkg/action/rollback_test.go b/pkg/action/rollback_test.go index b34adda91..0dc55f765 100644 --- a/pkg/action/rollback_test.go +++ b/pkg/action/rollback_test.go @@ -27,6 +27,9 @@ import ( "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" + rcommon "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" ) func TestNewRollback(t *testing.T) { @@ -131,3 +134,85 @@ func TestRollbackRevisionZeroForNonRollback(t *testing.T) { assert.Equal(t, 0, r.Info.RollbackRevision) } + +// TestRollback_StripsSequencingAnnotationsOnPlainPath locks the fix for cap-21 +// (hip-0025-r5y). Scenario: a release was installed plain at rev-1, upgraded +// with sequencing at rev-2 (manifest stored in the secret retains the raw +// helm.sh/depends-on/resource-groups annotation), then rolled back to rev-1. +// Because targetRelease.SequencingInfo is unset, performRollback falls through +// to the plain (non-sequenced) UPDATE path. Without stripping, SSA on the +// rollback rejects the multi-slash annotation key as invalid. This test +// verifies that BOTH current (rev-2 manifest) and target (rev-1 manifest) are +// passed through stripSequencingAnnotations before KubeClient.Update is called. +func TestRollback_StripsSequencingAnnotationsOnPlainPath(t *testing.T) { + const annotation = releaseutil.AnnotationDependsOnResourceGroups + + // rev-2's manifest carries the helm-internal annotation because the + // rendered template kept it; the live K8s objects had it stripped by the + // sequenced upgrade path, but the secret-stored Manifest still has it. + sequencedManifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: cap21-cm + namespace: spaced + annotations: + "` + annotation + `": "[other-group]" +data: + k: v +` + + // rev-1's manifest is plain. + plainManifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: cap21-cm + namespace: spaced +data: + k: v +` + + rev1 := releaseStub() + rev1.Name = "cap21" + rev1.Version = 1 + rev1.Info.Status = rcommon.StatusSuperseded + rev1.Manifest = plainManifest + rev1.SequencingInfo = nil // plain install + + rev2 := releaseStub() + rev2.Name = "cap21" + rev2.Version = 2 + rev2.Info.Status = rcommon.StatusDeployed + rev2.Manifest = sequencedManifest + rev2.SequencingInfo = &release.SequencingInfo{Enabled: true, Strategy: "ordered"} + + cfg := actionConfigFixture(t) + require.NoError(t, cfg.Releases.Create(rev1)) + require.NoError(t, cfg.Releases.Create(rev2)) + + recorder := newRecordingKubeClient() + cfg.KubeClient = recorder + + client := NewRollback(cfg) + client.Version = 1 + client.DisableHooks = true + require.NoError(t, client.Run("cap21")) + + require.Len(t, recorder.updateCalls, 1, "exactly one KubeClient.Update call expected on plain rollback path") + call := recorder.updateCalls[0] + + assertNoSequencingAnnotation(t, "current", call.currentResources, annotation) + assertNoSequencingAnnotation(t, "target", call.targetResources, annotation) +} + +func assertNoSequencingAnnotation(t *testing.T, label string, resources kube.ResourceList, key string) { + t.Helper() + for _, info := range resources { + acc := info.Object.(interface { + GetAnnotations() map[string]string + }) + anns := acc.GetAnnotations() + if _, present := anns[key]; present { + t.Fatalf("%s resource %q still carries stripped annotation %q after rollback", label, info.Name, key) + } + } +} diff --git a/pkg/action/sequencing.go b/pkg/action/sequencing.go new file mode 100644 index 000000000..605f2844c --- /dev/null +++ b/pkg/action/sequencing.go @@ -0,0 +1,344 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "helm.sh/helm/v4/pkg/kube" + release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/v1/sequence" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/resource" +) + +// computeDeadline returns a deadline based on the given timeout duration. +// If timeout is zero or negative, it returns the zero time (no deadline). +func computeDeadline(timeout time.Duration) time.Time { + if timeout <= 0 { + return time.Time{} + } + return time.Now().Add(timeout) +} + +// buildManifestYAML concatenates the Content fields of the given manifests into a single +// YAML stream suitable for passing to KubeClient.Build(). +func buildManifestYAML(manifests []releaseutil.Manifest) string { + if len(manifests) == 0 { + return "" + } + var buf strings.Builder + for i, m := range manifests { + if i > 0 { + buf.WriteString("---\n") + } + buf.WriteString(m.Content) + buf.WriteString("\n") + } + return buf.String() +} + +// sequencedDeployment executes a sequence.Plan: ordered installation, upgrade, +// or rollback of chart resources, one batch at a time, gating each batch on +// readiness before the next starts. +type sequencedDeployment struct { + cfg *Configuration + releaseName string + releaseNamespace string + disableOpenAPI bool + serverSideApply bool + forceConflicts bool + forceReplace bool + waitStrategy kube.WaitStrategy + waitOptions []kube.WaitOption + waitForJobs bool + timeout time.Duration + readinessTimeout time.Duration + deadline time.Time // overall operation deadline + + // upgradeMode forces every batch through KubeClient.Update. When false + // (install), a batch is still applied via Update when it intersects + // currentResources (resources adopted with --take-ownership). + upgradeMode bool + // currentResources are the objects a batch may be an update of: the full + // old-release resource list on upgrade/rollback, the toBeAdopted set on + // install. Matched per batch by objectKey. + currentResources kube.ResourceList + upgradeCSAFieldManager bool // upgrade client-side apply field manager + // threeWayMergeForUnstructured mirrors performInstall's adoption Update + // (TakeOwnership && !ServerSideApply); always false on upgrade/rollback. + threeWayMergeForUnstructured bool + createdResources kube.ResourceList +} + +// logPlanWarnings surfaces non-fatal sequencing-plan warnings to the user. +func logPlanWarnings(logger *slog.Logger, plan *sequence.Plan) { + for _, w := range plan.Warnings { + logger.Warn("sequencing: "+w.Message, "chart", w.ChartPath) + } +} + +// getWaiterFor returns a waiter for the given strategy, preferring the +// options-aware interface when the client supports it. +func getWaiterFor(client kube.Interface, ws kube.WaitStrategy, opts ...kube.WaitOption) (kube.Waiter, error) { + if c, ok := client.(kube.InterfaceWaitOptions); ok { + return c.GetWaiterWithOptions(ws, opts...) + } + return client.GetWaiter(ws) +} + +// apply deploys the plan batch by batch, in order. +func (s *sequencedDeployment) apply(ctx context.Context, plan *sequence.Plan) error { + for _, batch := range plan.Batches { + if err := s.applyBatch(ctx, batch); err != nil { + return err + } + } + return nil +} + +// applyBatch builds one batch's manifests, sets release metadata, strips +// Helm-internal sequencing annotations, creates or updates the resources, and +// waits for readiness. Context cancellation is honored at each stage boundary: +// before Build, before Create/Update, and before Wait. +func (s *sequencedDeployment) applyBatch(ctx context.Context, batch sequence.Batch) error { + if err := ctx.Err(); err != nil { + return err + } + manifests := batch.Manifests() + if len(manifests) == 0 { + return nil + } + + yaml := buildManifestYAML(manifests) + target, err := s.cfg.KubeClient.Build(bytes.NewBufferString(yaml), !s.disableOpenAPI) + if err != nil { + return fmt.Errorf("building resource batch: %w", err) + } + if len(target) == 0 { + return nil + } + + if err := target.Visit(setMetadataVisitor(s.releaseName, s.releaseNamespace, true)); err != nil { + return fmt.Errorf("setting metadata for resource batch: %w", err) + } + if err := stripSequencingAnnotations(target); err != nil { + return fmt.Errorf("stripping sequencing annotations: %w", err) + } + + if err := ctx.Err(); err != nil { + return err + } + + targetKeys := make(map[string]bool, len(target)) + for _, r := range target { + targetKeys[objectKey(r)] = true + } + var matchingCurrent kube.ResourceList + for _, r := range s.currentResources { + if targetKeys[objectKey(r)] { + matchingCurrent = append(matchingCurrent, r) + } + } + + var result *kube.Result + if s.upgradeMode || len(matchingCurrent) > 0 { + result, err = s.cfg.KubeClient.Update( + matchingCurrent, + target, + kube.ClientUpdateOptionForceReplace(s.forceReplace), + kube.ClientUpdateOptionServerSideApply(s.serverSideApply, s.forceConflicts), + kube.ClientUpdateOptionThreeWayMergeForUnstructured(s.threeWayMergeForUnstructured), + kube.ClientUpdateOptionUpgradeClientSideFieldManager(s.upgradeCSAFieldManager), + ) + if err != nil { + return fmt.Errorf("updating resource batch: %w", err) + } + } else { + result, err = s.cfg.KubeClient.Create( + target, + kube.ClientCreateOptionServerSideApply(s.serverSideApply, false)) + if err != nil { + return fmt.Errorf("creating resource batch: %w", err) + } + } + s.createdResources = append(s.createdResources, result.Created...) + + if err := ctx.Err(); err != nil { + return err + } + + if !batch.Wait { + return nil + } + return s.waitForResources(target, batch.HasCustomReadiness) +} + +// stripSequencingAnnotations removes Helm-internal sequencing annotations from +// resources before they are applied to Kubernetes. This prevents K8s API +// validation errors for annotation keys that are not valid K8s label keys. +func stripSequencingAnnotations(resources kube.ResourceList) error { + sequencingKeys := releaseutil.HelmInternalSequencingAnnotations() + return resources.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + acc, accErr := meta.Accessor(info.Object) + if accErr != nil { + return nil // skip non-accessible objects + } + annotations := acc.GetAnnotations() + if len(annotations) == 0 { + return nil + } + changed := false + for _, key := range sequencingKeys { + if _, exists := annotations[key]; exists { + delete(annotations, key) + changed = true + } + } + if changed { + acc.SetAnnotations(annotations) + } + return nil + }) +} + +// deleteRemoved deletes resources of the previous release that are absent from +// the new one, walking an already-reversed plan of the OLD release so removal +// happens in exact reverse deployment order, gating each batch on WaitForDelete +// (HIP-0025 §Sequencing order, bead 7yi). removedKeys holds the objectKey()s +// of resources to delete; batch resources outside the set are skipped. +func (s *sequencedDeployment) deleteRemoved(reversedPlan *sequence.Plan, removedKeys map[string]bool, waiter kube.Waiter) error { + for _, batch := range reversedPlan.Batches { + manifests := batch.Manifests() + if len(manifests) == 0 { + continue + } + resources, err := s.cfg.KubeClient.Build(bytes.NewBufferString(buildManifestYAML(manifests)), false) + if err != nil { + return fmt.Errorf("building removed-resource batch: %w", err) + } + var toDelete kube.ResourceList + for _, r := range resources { + if removedKeys[objectKey(r)] { + toDelete = append(toDelete, r) + } + } + if len(toDelete) == 0 { + continue + } + if _, errs := s.cfg.KubeClient.Delete(toDelete, metav1.DeletePropagationBackground); errs != nil { + return fmt.Errorf("deleting removed resources: %w", joinErrors(errs, ", ")) + } + if !batch.Wait { + continue + } + waitTimeout, err := s.batchWaitTimeout() + if err != nil { + return err + } + if err := waiter.WaitForDelete(toDelete, waitTimeout); err != nil { + return fmt.Errorf("waiting for removed resources to be deleted: %w", err) + } + } + return nil +} + +// deleteRemovedFromOldRelease builds the OLD release's deployment plan from its +// stored manifest and deletes the removed resources batch-by-batch in exact +// reverse deployment order (HIP-0025 §Sequencing order, bead 7yi). When no plan +// can be built from the stored release (pre-sequencing record, unparsable +// manifest), it falls back to one unordered bulk delete with a warning. +func (s *sequencedDeployment) deleteRemovedFromOldRelease(oldRelease *release.Release, removedKeys map[string]bool, toBeDeleted kube.ResourceList, waiter kube.Waiter) error { + oldManifests, err := sequence.ParseStoredManifests(oldRelease.Manifest) + var oldPlan *sequence.Plan + if err == nil { + oldPlan, err = sequence.Build(oldRelease.Chart, oldManifests) + } + if err != nil { + s.cfg.Logger().Warn("unable to build sequencing plan for previous release; deleting removed resources unordered", slog.Any("error", err)) + if _, errs := s.cfg.KubeClient.Delete(toBeDeleted, metav1.DeletePropagationBackground); errs != nil { + return fmt.Errorf("deleting removed resources: %w", joinErrors(errs, ", ")) + } + return nil + } + return s.deleteRemoved(oldPlan.Reverse(), removedKeys, waiter) +} + +// batchWaitTimeout returns the effective per-batch wait timeout: +// min(readinessTimeout, time remaining until the overall deadline). +func (s *sequencedDeployment) batchWaitTimeout() (time.Duration, error) { + waitTimeout := s.readinessTimeout + if !s.deadline.IsZero() { + remaining := time.Until(s.deadline) + if remaining <= 0 { + return 0, errors.New("overall timeout exceeded before waiting for resource batch") + } + if waitTimeout <= 0 || remaining < waitTimeout { + waitTimeout = remaining + } + } + if waitTimeout <= 0 { + waitTimeout = time.Minute // safe default + } + return waitTimeout, nil +} + +// waitForResources waits for the given resources to become ready, applying the +// per-batch and overall deadline constraints. Every batch is waited on (D1): +// leaf-group suppression was removed because it keyed on bare resource names +// (beads xnf/p1m) and made --wait=ordered weaker than --wait for terminal +// groups; Batch.LeafGroups is retained by the builder for display only. +func (s *sequencedDeployment) waitForResources(resources kube.ResourceList, hasCustomReadiness bool) error { + if len(resources) == 0 { + return nil + } + waitTimeout, err := s.batchWaitTimeout() + if err != nil { + return err + } + + var waiter kube.Waiter + waitOptions := append([]kube.WaitOption(nil), s.waitOptions...) + if hasCustomReadiness { + waitOptions = append(waitOptions, kube.WithCustomReadinessStatusReader()) + } + if c, ok := s.cfg.KubeClient.(kube.InterfaceWaitOptions); ok { + waiter, err = c.GetWaiterWithOptions(s.waitStrategy, waitOptions...) + } else { + waiter, err = s.cfg.KubeClient.GetWaiter(s.waitStrategy) + } + if err != nil { + return fmt.Errorf("getting waiter for resource batch: %w", err) + } + + if s.waitForJobs { + return waiter.WaitWithJobs(resources, waitTimeout) + } + return waiter.Wait(resources, waitTimeout) +} diff --git a/pkg/action/sequencing_test.go b/pkg/action/sequencing_test.go new file mode 100644 index 000000000..f88db3d99 --- /dev/null +++ b/pkg/action/sequencing_test.go @@ -0,0 +1,1167 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest/fake" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/kube" + kubefake "helm.sh/helm/v4/pkg/kube/fake" + ri "helm.sh/helm/v4/pkg/release" + rcommon "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/v1/sequence" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +type recordingKubeClient struct { + kubefake.PrintingKubeClient + createCalls [][]string + updateCalls []updateCall + waitCalls [][]string + watchUntilReadyCalls [][]string + deleteWaitCalls [][]string + deleteCalls [][]string + waiterStrategies []kube.WaitStrategy + operations []string + waitCallCount int + waitErrorOnCall int + waitError error + onBuild func() + // buildRESTClient, when set, attaches a fake REST client to every + // resource.Info produced by Build — needed by tests whose action path + // performs live existence/ownership checks (a real Build always attaches + // a client; most recording tests never hit those paths and omit it). + buildRESTClient func(u *unstructured.Unstructured) resource.RESTClient + // createdObjects tracks "Kind/name" ids that Create has been called for, + // letting stateful fakes answer GETs like a live cluster would. + createdObjects map[string]bool + onCreate func() + onUpdate func() +} + +type updateCall struct { + current []string + target []string + created []string + currentResources kube.ResourceList + targetResources kube.ResourceList +} + +type recordingKubeWaiter struct { + client *recordingKubeClient +} + +func newRecordingKubeClient() *recordingKubeClient { + return &recordingKubeClient{ + PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: io.Discard}, + // A real Build always attaches a REST client to each Info; mirror + // that with a fake that reports 404 so existence/conflict checks in + // the install/upgrade prologue exercise the real "resource does not + // exist yet" path. Delete-path tests override this (see + // withoutBuildClients) because their ownership checks would read a + // 404 as "already deleted". + buildRESTClient: func(u *unstructured.Unstructured) resource.RESTClient { + return fakeClientWith(http.StatusNotFound, u.GroupVersionKind().GroupVersion(), "") + }, + createdObjects: make(map[string]bool), + } +} + +// withoutBuildClients makes Build produce Infos with no REST client, for +// delete-path tests that rely on the nil-client ownership-verification guard +// (verifyOwnershipBeforeDelete treats a fetch 404 as "already deleted", which +// would wrongly skip deletions for these fixtures). +func (c *recordingKubeClient) withoutBuildClients() *recordingKubeClient { + c.buildRESTClient = nil + return c +} + +// statefulOwnedRESTClient mimics a live cluster across an install-then-delete +// flow: GETs report 404 until Create has been called for the object, and +// afterwards return it labeled/annotated as owned by the given release, so +// post-failure rollback deletion passes ownership checks even though the +// delete path re-Builds fresh resource.Infos from the stored manifest. +func statefulOwnedRESTClient(c *recordingKubeClient, releaseName, releaseNamespace string) func(u *unstructured.Unstructured) resource.RESTClient { + return func(u *unstructured.Unstructured) resource.RESTClient { + gvk := u.GroupVersionKind() + id := fmt.Sprintf("%s/%s", gvk.Kind, u.GetName()) + ownedBody := fmt.Sprintf( + `{"apiVersion":%q,"kind":%q,"metadata":{"name":%q,"labels":{%q:%q},"annotations":{%q:%q,%q:%q}}}`, + gvk.GroupVersion().String(), gvk.Kind, u.GetName(), + appManagedByLabel, appManagedByHelm, + helmReleaseNameAnnotation, releaseName, + helmReleaseNamespaceAnnotation, releaseNamespace, + ) + return &fake.RESTClient{ + GroupVersion: gvk.GroupVersion(), + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) { + header := http.Header{} + header.Set("Content-Type", runtime.ContentTypeJSON) + code, body := http.StatusNotFound, "" + if c.createdObjects[id] { + code, body = http.StatusOK, ownedBody + } + return &http.Response{StatusCode: code, Header: header, Body: stringBody(body)}, nil + }), + } + } +} + +func (c *recordingKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) { + if c.onCreate != nil { + c.onCreate() + } + ids := resourceIDs(resources) + c.createCalls = append(c.createCalls, ids) + if c.createdObjects != nil { + for _, id := range ids { + c.createdObjects[id] = true + } + } + c.operations = append(c.operations, "create:"+strings.Join(ids, ",")) + return &kube.Result{Created: resources}, nil +} + +func (c *recordingKubeClient) Delete(resources kube.ResourceList, deletionPropagation metav1.DeletionPropagation) (*kube.Result, []error) { + ids := resourceIDs(resources) + c.deleteCalls = append(c.deleteCalls, ids) + c.operations = append(c.operations, "delete:"+strings.Join(ids, ",")) + return c.PrintingKubeClient.Delete(resources, deletionPropagation) +} + +func (c *recordingKubeClient) Update(current, target kube.ResourceList, _ ...kube.ClientUpdateOption) (*kube.Result, error) { + if c.onUpdate != nil { + c.onUpdate() + } + currentIDs := resourceIDs(current) + targetIDs := resourceIDs(target) + + currentSet := make(map[string]struct{}, len(currentIDs)) + for _, id := range currentIDs { + currentSet[id] = struct{}{} + } + + var created kube.ResourceList + var createdIDs []string + for _, info := range target { + id := fmt.Sprintf("%s/%s", info.Object.GetObjectKind().GroupVersionKind().Kind, info.Name) + if _, exists := currentSet[id]; !exists { + created = append(created, info) + createdIDs = append(createdIDs, id) + } + } + + c.updateCalls = append(c.updateCalls, updateCall{ + current: currentIDs, + target: targetIDs, + created: createdIDs, + currentResources: current, + targetResources: target, + }) + c.operations = append(c.operations, "update:"+strings.Join(targetIDs, ",")) + return &kube.Result{Updated: target, Created: created}, nil +} + +func (c *recordingKubeClient) Build(reader io.Reader, _ bool) (kube.ResourceList, error) { + if c.onBuild != nil { + c.onBuild() + } + decoder := yamlutil.NewYAMLOrJSONDecoder(reader, 4096) + var resources kube.ResourceList + + for { + var obj map[string]any + if err := decoder.Decode(&obj); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + if len(obj) == 0 { + continue + } + + u := &unstructured.Unstructured{Object: obj} + gvk := u.GroupVersionKind() + namespace := u.GetNamespace() + if namespace == "" { + namespace = "spaced" + u.SetNamespace(namespace) + } + info := &resource.Info{ + Name: u.GetName(), + Namespace: namespace, + Object: u, + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: gvk.Group, Version: gvk.Version, Resource: strings.ToLower(gvk.Kind) + "s"}, + GroupVersionKind: gvk, + Scope: meta.RESTScopeNamespace, + }, + } + if c.buildRESTClient != nil { + info.Client = c.buildRESTClient(u) + } + resources.Append(info) + } + + return resources, nil +} + +func (c *recordingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) { + return c.GetWaiterWithOptions(ws) +} + +func (c *recordingKubeClient) GetWaiterWithOptions(ws kube.WaitStrategy, _ ...kube.WaitOption) (kube.Waiter, error) { + c.waiterStrategies = append(c.waiterStrategies, ws) + if ws == kube.HookOnlyStrategy { + // Mirror the real hookOnlyWaiter: general waits are no-ops, only + // hook watches are real (pkg/kube/statuswait.go:297-315). + return &recordingHookOnlyWaiter{client: c}, nil + } + return &recordingKubeWaiter{client: c}, nil +} + +type recordingHookOnlyWaiter struct { + client *recordingKubeClient +} + +func (w *recordingHookOnlyWaiter) Wait(_ kube.ResourceList, _ time.Duration) error { + return nil +} + +func (w *recordingHookOnlyWaiter) WaitWithJobs(_ kube.ResourceList, _ time.Duration) error { + return nil +} + +func (w *recordingHookOnlyWaiter) WaitForDelete(_ kube.ResourceList, _ time.Duration) error { + return nil +} + +func (w *recordingHookOnlyWaiter) WatchUntilReady(resources kube.ResourceList, d time.Duration) error { + return (&recordingKubeWaiter{client: w.client}).WatchUntilReady(resources, d) +} + +func (w *recordingKubeWaiter) Wait(resources kube.ResourceList, _ time.Duration) error { + return w.client.recordWait(resources) +} + +func (w *recordingKubeWaiter) WaitWithJobs(resources kube.ResourceList, _ time.Duration) error { + return w.client.recordWait(resources) +} + +func (w *recordingKubeWaiter) WaitForDelete(resources kube.ResourceList, _ time.Duration) error { + ids := resourceIDs(resources) + w.client.deleteWaitCalls = append(w.client.deleteWaitCalls, ids) + w.client.operations = append(w.client.operations, "wait-delete:"+strings.Join(ids, ",")) + return nil +} + +func (w *recordingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ time.Duration) error { + ids := resourceIDs(resources) + w.client.watchUntilReadyCalls = append(w.client.watchUntilReadyCalls, ids) + w.client.operations = append(w.client.operations, "watch:"+strings.Join(ids, ",")) + return nil +} + +func (c *recordingKubeClient) recordWait(resources kube.ResourceList) error { + c.waitCallCount++ + ids := resourceIDs(resources) + c.waitCalls = append(c.waitCalls, ids) + c.operations = append(c.operations, "wait:"+strings.Join(ids, ",")) + if c.waitError != nil && c.waitCallCount == c.waitErrorOnCall { + return c.waitError + } + return nil +} + +func resourceIDs(resources kube.ResourceList) []string { + ids := make([]string, 0, len(resources)) + for _, info := range resources { + kind := info.Object.GetObjectKind().GroupVersionKind().Kind + if kind == "" { + kind = "Unknown" + } + ids = append(ids, fmt.Sprintf("%s/%s", kind, info.Name)) + } + return ids +} + +func newSequencedInstallAction(t *testing.T, kubeClient kube.Interface) *Install { + t.Helper() + cfg := actionConfigFixture(t) + cfg.KubeClient = kubeClient + + install := NewInstall(cfg) + install.Namespace = "spaced" + install.ReleaseName = "test-install-release" + install.Timeout = 5 * time.Minute + install.ReadinessTimeout = time.Minute + install.WaitStrategy = kube.OrderedWaitStrategy + return install +} + +func releaseFile(name, content string) *common.File { + return &common.File{Name: name, ModTime: time.Now(), Data: []byte(content)} +} + +func configMapManifest(name string, annotations map[string]string) string { + var b strings.Builder + b.WriteString("apiVersion: v1\nkind: ConfigMap\nmetadata:\n") + _, _ = fmt.Fprintf(&b, " name: %s\n", name) + if len(annotations) > 0 { + b.WriteString(" annotations:\n") + for key, value := range annotations { + _, _ = fmt.Fprintf(&b, " %s: %q\n", key, value) + } + } + b.WriteString("data:\n key: value\n") + return b.String() +} + +func makeConfigMapTemplate(fileName, name string, annotations map[string]string) *common.File { + return releaseFile(fileName, configMapManifest(name, annotations)) +} + +func makeHookTemplate(fileName, name, hook string, extraAnnotations map[string]string) *common.File { + annotations := map[string]string{release.HookAnnotation: hook} + maps.Copy(annotations, extraAnnotations) + return makeConfigMapTemplate(fileName, name, annotations) +} + +func mustRelease(t *testing.T, rel ri.Releaser) *release.Release { + t.Helper() + out, err := releaserToV1Release(rel) + require.NoError(t, err) + return out +} + +func TestSequencing_BuildManifestYAML(t *testing.T) { + yaml := buildManifestYAML([]releaseutil.Manifest{ + makeTestManifest("one", "chart/templates/one.yaml", nil), + makeTestManifest("two", "chart/templates/two.yaml", nil), + }) + + assert.Contains(t, yaml, "name: one") + assert.Contains(t, yaml, "name: two") +} + +func TestInstall_Sequenced_BasicResourceGroups(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }) + + rel := mustRelease(t, mustRunInstall(t, install, ch)) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.createCalls) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls) + assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status) + assert.True(t, rel.Sequenced) + assert.True(t, rel.IsSequenced()) + assert.Nil(t, rel.SequencingInfo) +} + +func TestInstall_Sequenced_SubchartOrdering(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + database := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + }, withName("database")) + api := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/api.yaml", "api", nil), + }, withName("api")) + + parent.AddDependency(database, api) + parent.Metadata.Dependencies = []*chart.Dependency{ + {Name: "database"}, + {Name: "api", DependsOn: []string{"database"}}, + } + + mustRunInstall(t, install, parent) + + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/api"}, {"ConfigMap/parent"}}, client.createCalls) + assert.Equal(t, client.createCalls, client.waitCalls) +} + +// TestInstall_Sequenced_UndeclaredVendoredSubchartDeployed is a regression for +// hip-0025: a subchart vendored into charts/ but absent from Chart.yaml +// dependencies is rendered, so under --wait=ordered its manifests must still be +// applied. Previously they were grouped by subchart yet never matched a DAG +// batch (the DAG is built from Chart.yaml dependencies only), so they were +// silently dropped. Undeclared subcharts are unsequenced: they deploy after the +// declared subchart batches and before the parent's own resources. +func TestInstall_Sequenced_UndeclaredVendoredSubchartDeployed(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + declared := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + }, withName("database")) + vendored := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/vendored.yaml", "vendored", nil), + }, withName("vendored")) + + parent.AddDependency(declared, vendored) + // Only "database" is declared in Chart.yaml dependencies; "vendored" sits in + // charts/ but is undeclared. + parent.Metadata.Dependencies = []*chart.Dependency{ + {Name: "database"}, + } + + mustRunInstall(t, install, parent) + + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/vendored"}, {"ConfigMap/parent"}}, client.createCalls) +} + +// TestInstall_Sequenced_LeafGroupsWaited locks D1: ordered is a --wait +// flavor, so terminal groups are gated too. This makes readiness-failure +// detection and --atomic work for them. +func TestInstall_Sequenced_LeafGroupsWaited(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/base.yaml", "base", map[string]string{ + releaseutil.AnnotationResourceGroup: "base", + }), + makeConfigMapTemplate("templates/leaf.yaml", "leaf", map[string]string{ + releaseutil.AnnotationResourceGroup: "leaf", + releaseutil.AnnotationDependsOnResourceGroups: `["base"]`, + kube.AnnotationReadinessSuccess: `["{.never} == match"]`, + kube.AnnotationReadinessFailure: `["{.never} == fail"]`, + }), + }) + + mustRunInstall(t, install, ch) + assert.Equal(t, [][]string{{"ConfigMap/base"}, {"ConfigMap/leaf"}}, client.createCalls) + assert.Equal(t, [][]string{{"ConfigMap/base"}, {"ConfigMap/leaf"}}, client.waitCalls) +} + +func TestInstall_Sequenced_TerminalGroupFailureFailsInstall(t *testing.T) { + client := newRecordingKubeClient() + client.waitErrorOnCall = 2 + client.waitError = assert.AnError + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/base.yaml", "base", map[string]string{ + releaseutil.AnnotationResourceGroup: "base", + }), + makeConfigMapTemplate("templates/leaf.yaml", "leaf", map[string]string{ + releaseutil.AnnotationResourceGroup: "leaf", + releaseutil.AnnotationDependsOnResourceGroups: `["base"]`, + }), + }) + + _, err := install.RunWithContext(context.Background(), ch, map[string]any{}) + require.Error(t, err, "a terminal group's readiness failure must fail the install") + assert.Equal(t, [][]string{{"ConfigMap/base"}, {"ConfigMap/leaf"}}, client.createCalls) + last, getErr := install.cfg.Releases.Last(install.ReleaseName) + require.NoError(t, getErr) + assert.Equal(t, rcommon.StatusFailed, mustRelease(t, last).Info.Status) +} + +// TestInstall_Sequenced_NonLeafReadinessStillWaitsOnFailure is the regression +// guard for cap-04/cap-05/cap-32: when a resource HAS a dependent, its +// readiness IS waited on. Simulates the worker.Wait returning an error +// (e.g. readiness-failure JSONPath fired) and confirms the install aborts. +// Without this guard, a future "ignore-readiness" change could silently +// regress all custom-readiness behavior. +func TestInstall_Sequenced_NonLeafReadinessStillWaitsOnFailure(t *testing.T) { + client := newRecordingKubeClient() + client.waitErrorOnCall = 1 + client.waitError = assert.AnError + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + // bootstrap has a dependent (app), so its readiness must be waited on. + makeConfigMapTemplate("templates/bootstrap.yaml", "bootstrap", map[string]string{ + releaseutil.AnnotationResourceGroup: "bootstrap", + kube.AnnotationReadinessSuccess: `["{.ready} == true"]`, + kube.AnnotationReadinessFailure: `["{.failed} == true"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["bootstrap"]`, + }), + }) + + _, err := install.RunWithContext(context.Background(), ch, map[string]any{}) + require.Error(t, err, "bootstrap readiness wait failure must abort install") + require.NotEmpty(t, client.waitCalls, "bootstrap wait call must have been recorded") + assert.Equal(t, []string{"ConfigMap/bootstrap"}, client.waitCalls[0]) + // app group's batch never gets applied because bootstrap wait failed first. + require.NotContains(t, client.operations, "create:ConfigMap/app", "app should not be created after bootstrap failed") +} + +func TestInstall_Sequenced_Combined(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + database := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/setup.yaml", "db-setup", map[string]string{ + releaseutil.AnnotationResourceGroup: "setup", + }), + makeConfigMapTemplate("templates/data.yaml", "db-data", map[string]string{ + releaseutil.AnnotationResourceGroup: "data", + releaseutil.AnnotationDependsOnResourceGroups: `["setup"]`, + }), + }, withName("database")) + api := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/api.yaml", "api", map[string]string{ + releaseutil.AnnotationResourceGroup: "api", + }), + }, withName("api")) + + parent.AddDependency(database, api) + parent.Metadata.Dependencies = []*chart.Dependency{ + {Name: "database"}, + {Name: "api", DependsOn: []string{"database"}}, + } + + mustRunInstall(t, install, parent) + + assert.Equal(t, [][]string{ + {"ConfigMap/db-setup"}, + {"ConfigMap/db-data"}, + {"ConfigMap/api"}, + {"ConfigMap/parent"}, + }, client.createCalls) +} + +func TestInstall_Sequenced_NoAnnotations(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/first.yaml", "first", nil), + makeConfigMapTemplate("templates/second.yaml", "second", nil), + }) + + rel := mustRelease(t, mustRunInstall(t, install, ch)) + + require.Len(t, client.createCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/first", "ConfigMap/second"}, client.createCalls[0]) + assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status) +} + +func TestInstall_Sequenced_UnsequencedLast(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/orphan.yaml", "orphan", map[string]string{ + releaseutil.AnnotationResourceGroup: "orphan", + releaseutil.AnnotationDependsOnResourceGroups: `["missing"]`, + }), + makeConfigMapTemplate("templates/plain.yaml", "plain", nil), + }) + + mustRunInstall(t, install, ch) + + require.Len(t, client.createCalls, 2) + assert.Equal(t, []string{"ConfigMap/database"}, client.createCalls[0]) + assert.ElementsMatch(t, []string{"ConfigMap/orphan", "ConfigMap/plain"}, client.createCalls[1]) +} + +func TestInstall_Sequenced_ReadinessTimeout(t *testing.T) { + client := newRecordingKubeClient() + client.waitErrorOnCall = 1 + client.waitError = errors.New("timed out waiting for batch") + + install := newSequencedInstallAction(t, client) + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }) + + rel, err := install.Run(ch, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out waiting for batch") + assert.Len(t, client.createCalls, 1) + + last, getErr := install.cfg.Releases.Last(install.ReleaseName) + require.NoError(t, getErr) + assert.Equal(t, rcommon.StatusFailed, mustRelease(t, last).Info.Status) + assert.Equal(t, rcommon.StatusFailed, mustRelease(t, rel).Info.Status) +} + +func TestInstall_Sequenced_AtomicRollback(t *testing.T) { + client := newRecordingKubeClient() + // Install-then-rollback needs cluster-like GET behavior: 404 before the + // resources are created (conflict check), owned afterwards (rollback + // deletion ownership verification). + client.buildRESTClient = statefulOwnedRESTClient(client, "test-install-release", "spaced") + client.waitErrorOnCall = 1 + client.waitError = errors.New("timed out waiting for batch") + + install := newSequencedInstallAction(t, client) + install.RollbackOnFailure = true + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }) + + _, err := install.Run(ch, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "rollback-on-failure") + assert.NotEmpty(t, client.deleteCalls) +} + +// TestInstall_Sequenced_FailureCleanupIsSequenced locks beads ll8/0vr: the +// Sequenced flag must reach the STORED release record before deployment +// starts, so the rollback-on-failure uninstall, which re-reads the release +// from storage, tears the partial install down in reverse batch order with a +// delete-wait per batch, not as one unordered bulk delete. +func TestInstall_Sequenced_FailureCleanupIsSequenced(t *testing.T) { + client := newRecordingKubeClient() + client.buildRESTClient = statefulOwnedRESTClient(client, "test-install-release", "spaced") + client.waitErrorOnCall = 2 + client.waitError = errors.New("batch two never became ready") + + install := newSequencedInstallAction(t, client) + install.RollbackOnFailure = true + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/a.yaml", "a", map[string]string{ + releaseutil.AnnotationResourceGroup: "a", + }), + makeConfigMapTemplate("templates/b.yaml", "b", map[string]string{ + releaseutil.AnnotationResourceGroup: "b", + releaseutil.AnnotationDependsOnResourceGroups: `["a"]`, + }), + makeConfigMapTemplate("templates/c.yaml", "c", map[string]string{ + releaseutil.AnnotationResourceGroup: "c", + releaseutil.AnnotationDependsOnResourceGroups: `["b"]`, + }), + }) + + rel, err := install.Run(ch, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "rollback-on-failure") + assert.True(t, mustRelease(t, rel).Sequenced, "failed release must persist sequencing intent via the Sequenced flag") + + // a and b were created (c's batch never started; its manifests read as + // already-deleted). Cleanup must delete in reverse group order, waiting + // for each batch's deletion before the next. + assert.Equal(t, [][]string{{"ConfigMap/b"}, {"ConfigMap/a"}}, client.deleteCalls) + assert.Equal(t, [][]string{{"ConfigMap/b"}, {"ConfigMap/a"}}, client.deleteWaitCalls) +} + +// TestInstall_Sequenced_TakeOwnershipAdoptsExisting locks bead 9ui (install): +// with --take-ownership, a resource that already exists in the cluster must be +// adopted via Update inside its plan batch, not blindly Created. +func TestInstall_Sequenced_TakeOwnershipAdoptsExisting(t *testing.T) { + client := newRecordingKubeClient() + client.buildRESTClient = statefulOwnedRESTClient(client, "test-install-release", "spaced") + // database pre-exists in the cluster before the install begins. + client.createdObjects["ConfigMap/database"] = true + + install := newSequencedInstallAction(t, client) + install.TakeOwnership = true + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }) + + mustRunInstall(t, install, ch) + + // database's batch adopts (Update with the pre-existing object as current); + // app's batch has nothing to adopt and is Created. + require.Len(t, client.updateCalls, 1) + assert.Equal(t, []string{"ConfigMap/database"}, client.updateCalls[0].current) + assert.Equal(t, []string{"ConfigMap/database"}, client.updateCalls[0].target) + assert.Equal(t, [][]string{{"ConfigMap/app"}}, client.createCalls) +} + +// TestInstall_Sequenced_SameNameDifferentKindWaited locks beads xnf/p1m: +// readiness gating must never key on bare metadata.name. A ConfigMap and a +// Secret share the name "shared"; previously the leaf-suppression map keyed by +// name suppressed the wait for BOTH, including the depended-on ConfigMap. +func TestInstall_Sequenced_SameNameDifferentKindWaited(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + secretManifest := "apiVersion: v1\nkind: Secret\nmetadata:\n name: shared\n annotations:\n" + + " " + releaseutil.AnnotationResourceGroup + ": \"app\"\n" + + " " + releaseutil.AnnotationDependsOnResourceGroups + ": '[\"base\"]'\n" + + "stringData:\n key: value\n" + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/base.yaml", "shared", map[string]string{ + releaseutil.AnnotationResourceGroup: "base", + }), + releaseFile("templates/app.yaml", secretManifest), + }) + + mustRunInstall(t, install, ch) + + assert.Equal(t, [][]string{{"ConfigMap/shared"}, {"Secret/shared"}}, client.createCalls) + assert.Equal(t, [][]string{{"ConfigMap/shared"}, {"Secret/shared"}}, client.waitCalls) +} + +func TestInstall_Sequenced_DryRun(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + install.DryRunStrategy = DryRunServer + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }) + + rel := mustRelease(t, mustRunInstall(t, install, ch)) + assert.Empty(t, client.createCalls) + assert.Empty(t, client.waitCalls) + assert.Equal(t, "Dry run complete", rel.Info.Description) +} + +func TestInstall_Sequenced_CyclicDependency(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/a.yaml", "a", map[string]string{ + releaseutil.AnnotationResourceGroup: "a", + releaseutil.AnnotationDependsOnResourceGroups: `["b"]`, + }), + makeConfigMapTemplate("templates/b.yaml", "b", map[string]string{ + releaseutil.AnnotationResourceGroup: "b", + releaseutil.AnnotationDependsOnResourceGroups: `["a"]`, + }), + }) + + _, err := install.Run(ch, nil) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "cycle") || strings.Contains(err.Error(), "circular")) + assert.Empty(t, client.createCalls) + _, lastErr := install.cfg.Releases.Last(install.ReleaseName) + assert.Error(t, lastErr, "cycle must be detected before any release record is stored") +} + +func TestInstall_Sequenced_SequencedFlagStored(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/config.yaml", "config", nil), + }) + + rel := mustRelease(t, mustRunInstall(t, install, ch)) + + assert.True(t, rel.Sequenced) + assert.Nil(t, rel.SequencingInfo) + stored, err := install.cfg.Releases.Get(rel.Name, rel.Version) + require.NoError(t, err) + storedRel := mustRelease(t, stored) + assert.True(t, storedRel.Sequenced) + assert.True(t, storedRel.IsSequenced()) + assert.Nil(t, storedRel.SequencingInfo) +} + +func TestInstall_NonSequenced_Unchanged(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + install.WaitStrategy = kube.StatusWatcherStrategy + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }) + + rel := mustRelease(t, mustRunInstall(t, install, ch)) + + require.Len(t, client.createCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.createCalls[0]) + assert.Nil(t, rel.SequencingInfo) +} + +func TestInstall_Sequenced_HookResourcesExcludedFromDAG(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeHookTemplate("templates/pre-hook.yaml", "pre-hook", release.HookPreInstall.String(), map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + }) + + mustRunInstall(t, install, ch) + + assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/database"}}, client.createCalls) + assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}}, client.watchUntilReadyCalls) +} + +func TestInstall_Sequenced_HooksAtStandardPositions(t *testing.T) { + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeHookTemplate("templates/pre-hook.yaml", "pre-hook", release.HookPreInstall.String(), map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeHookTemplate("templates/post-hook.yaml", "post-hook", release.HookPostInstall.String(), map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + }) + + mustRunInstall(t, install, ch) + + assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/database"}, {"ConfigMap/post-hook"}}, client.createCalls) + assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.watchUntilReadyCalls) +} + +func singleManifestBatch(m releaseutil.Manifest) sequence.Batch { + return sequence.Batch{ + Groups: []sequence.Group{{Manifests: []releaseutil.Manifest{m}}}, + Wait: true, + } +} + +func TestSequencedDeployment_ApplyBatch_Create_RespectsContextCancellation(t *testing.T) { + newSequencedDeploymentForTest := func(client kube.Interface) *sequencedDeployment { + t.Helper() + + cfg := actionConfigFixture(t) + cfg.KubeClient = client + + return &sequencedDeployment{ + cfg: cfg, + releaseName: "demo", + releaseNamespace: "spaced", + waitStrategy: kube.OrderedWaitStrategy, + readinessTimeout: time.Minute, + } + } + + t.Run("context canceled before build", func(t *testing.T) { + client := newRecordingKubeClient() + buildCalled := false + client.onBuild = func() { buildCalled = true } + + sd := newSequencedDeploymentForTest(client) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := sd.applyBatch(ctx, singleManifestBatch(makeTestManifest("cm", "chart/templates/cm.yaml", nil))) + require.ErrorIs(t, err, context.Canceled) + assert.False(t, buildCalled) + assert.Empty(t, client.createCalls) + assert.Empty(t, client.waitCalls) + }) + + t.Run("context canceled after build before create", func(t *testing.T) { + client := newRecordingKubeClient() + ctx, cancel := context.WithCancel(context.Background()) + client.onBuild = cancel + + sd := newSequencedDeploymentForTest(client) + + err := sd.applyBatch(ctx, singleManifestBatch(makeTestManifest("cm", "chart/templates/cm.yaml", nil))) + require.ErrorIs(t, err, context.Canceled) + assert.Empty(t, client.createCalls) + assert.Empty(t, client.waitCalls) + }) + + t.Run("context canceled after create before wait", func(t *testing.T) { + client := newRecordingKubeClient() + ctx, cancel := context.WithCancel(context.Background()) + client.onCreate = cancel + + sd := newSequencedDeploymentForTest(client) + + err := sd.applyBatch(ctx, singleManifestBatch(makeTestManifest("cm", "chart/templates/cm.yaml", nil))) + require.ErrorIs(t, err, context.Canceled) + require.Len(t, client.createCalls, 1) + assert.Empty(t, client.waitCalls) + }) +} + +func TestSequencedDeployment_ApplyBatch_Update_RespectsContextCancellation(t *testing.T) { + newSequencedDeploymentForTest := func(client kube.Interface) *sequencedDeployment { + t.Helper() + + cfg := actionConfigFixture(t) + cfg.KubeClient = client + + return &sequencedDeployment{ + cfg: cfg, + releaseName: "demo", + releaseNamespace: "spaced", + waitStrategy: kube.OrderedWaitStrategy, + readinessTimeout: time.Minute, + upgradeMode: true, + } + } + + t.Run("context canceled before build", func(t *testing.T) { + client := newRecordingKubeClient() + buildCalled := false + client.onBuild = func() { buildCalled = true } + + sd := newSequencedDeploymentForTest(client) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := sd.applyBatch(ctx, singleManifestBatch(makeTestManifest("cm", "chart/templates/cm.yaml", nil))) + require.ErrorIs(t, err, context.Canceled) + assert.False(t, buildCalled) + assert.Empty(t, client.updateCalls) + assert.Empty(t, client.createCalls) + assert.Empty(t, client.waitCalls) + }) + + t.Run("context canceled after build before update", func(t *testing.T) { + client := newRecordingKubeClient() + ctx, cancel := context.WithCancel(context.Background()) + client.onBuild = cancel + + sd := newSequencedDeploymentForTest(client) + + err := sd.applyBatch(ctx, singleManifestBatch(makeTestManifest("cm", "chart/templates/cm.yaml", nil))) + require.ErrorIs(t, err, context.Canceled) + assert.Empty(t, client.updateCalls) + assert.Empty(t, client.createCalls) + assert.Empty(t, client.waitCalls) + }) + + t.Run("context canceled after update before wait", func(t *testing.T) { + client := newRecordingKubeClient() + ctx, cancel := context.WithCancel(context.Background()) + client.onUpdate = cancel + + sd := newSequencedDeploymentForTest(client) + + err := sd.applyBatch(ctx, singleManifestBatch(makeTestManifest("cm", "chart/templates/cm.yaml", nil))) + require.ErrorIs(t, err, context.Canceled) + require.Len(t, client.updateCalls, 1) + assert.Empty(t, client.createCalls) + assert.Empty(t, client.waitCalls) + }) +} + +func TestStripSequencingAnnotations(t *testing.T) { + // makeInfo constructs a minimal *resource.Info backed by an unstructured + // ConfigMap. stripSequencingAnnotations only needs meta.Accessor to work, + // so Name + Object are sufficient — no Mapping/Client required. + makeInfo := func(name string, annotations map[string]string) *resource.Info { + u := &unstructured.Unstructured{} + u.SetAPIVersion("v1") + u.SetKind("ConfigMap") + u.SetName(name) + if annotations != nil { + u.SetAnnotations(annotations) + } + return &resource.Info{Name: name, Object: u} + } + + annotationsOf := func(info *resource.Info) map[string]string { + acc, err := meta.Accessor(info.Object) + require.NoError(t, err) + return acc.GetAnnotations() + } + + t.Run("strips depends-on annotation, preserves siblings", func(t *testing.T) { + info := makeInfo("cm", map[string]string{ + releaseutil.AnnotationDependsOnResourceGroups: `["foo"]`, + releaseutil.AnnotationResourceGroup: "app", + "other.example.com/keep": "yes", + }) + resources := kube.ResourceList{info} + + require.NoError(t, stripSequencingAnnotations(resources)) + + ann := annotationsOf(info) + assert.NotContains(t, ann, releaseutil.AnnotationDependsOnResourceGroups, + "helm-internal depends-on annotation must be removed") + assert.Equal(t, "app", ann[releaseutil.AnnotationResourceGroup], + "single-slash helm.sh/resource-group is a valid k8s key and must be preserved") + assert.Equal(t, "yes", ann["other.example.com/keep"], + "unrelated annotations must be preserved") + }) + + t.Run("no-op when resource has no annotations", func(t *testing.T) { + info := makeInfo("cm", nil) + resources := kube.ResourceList{info} + + require.NoError(t, stripSequencingAnnotations(resources)) + assert.Empty(t, annotationsOf(info)) + }) + + t.Run("no-op when resource has only non-sequencing annotations", func(t *testing.T) { + info := makeInfo("cm", map[string]string{ + "keep.example.com/one": "1", + "keep.example.com/two": "2", + }) + resources := kube.ResourceList{info} + + require.NoError(t, stripSequencingAnnotations(resources)) + + ann := annotationsOf(info) + assert.Equal(t, "1", ann["keep.example.com/one"]) + assert.Equal(t, "2", ann["keep.example.com/two"]) + }) + + t.Run("strips across a multi-resource batch", func(t *testing.T) { + a := makeInfo("a", map[string]string{ + releaseutil.AnnotationDependsOnResourceGroups: `["x"]`, + releaseutil.AnnotationResourceGroup: "g1", + }) + b := makeInfo("b", map[string]string{ + releaseutil.AnnotationDependsOnResourceGroups: `["y"]`, + releaseutil.AnnotationResourceGroup: "g2", + }) + c := makeInfo("c", map[string]string{ + "unrelated": "value", + }) + resources := kube.ResourceList{a, b, c} + + require.NoError(t, stripSequencingAnnotations(resources)) + + for _, info := range []*resource.Info{a, b} { + ann := annotationsOf(info) + assert.NotContains(t, ann, releaseutil.AnnotationDependsOnResourceGroups, + "resource %s should have depends-on stripped", info.Name) + assert.Contains(t, ann, releaseutil.AnnotationResourceGroup, + "resource %s should retain resource-group", info.Name) + } + assert.Equal(t, "value", annotationsOf(c)["unrelated"], + "resources without sequencing annotations are untouched") + }) + + t.Run("strips every key listed in HelmInternalSequencingAnnotations", func(t *testing.T) { + // Lock the invariant: the function must remove EVERY key in the + // HelmInternalSequencingAnnotations list, not just depends-on. + ann := map[string]string{"keep": "yes"} + for _, k := range releaseutil.HelmInternalSequencingAnnotations() { + ann[k] = "internal" + } + info := makeInfo("cm", ann) + + require.NoError(t, stripSequencingAnnotations(kube.ResourceList{info})) + + got := annotationsOf(info) + for _, k := range releaseutil.HelmInternalSequencingAnnotations() { + assert.NotContains(t, got, k, "internal key %q must be stripped", k) + } + assert.Equal(t, "yes", got["keep"]) + }) +} + +func makeTestManifest(name, sourcePath string, annotations map[string]string) releaseutil.Manifest { + content := configMapManifest(name, annotations) + head := &releaseutil.SimpleHead{ + Version: v1.SchemeGroupVersion.String(), + Kind: "ConfigMap", + } + head.Metadata = &struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Annotations map[string]string `json:"annotations"` + }{ + Name: name, + Annotations: annotations, + } + return releaseutil.Manifest{Name: sourcePath, Content: content, Head: head} +} + +func mustRunInstall(t *testing.T, install *Install, ch *chart.Chart) ri.Releaser { + t.Helper() + rel, err := install.RunWithContext(context.Background(), ch, map[string]any{}) + require.NoError(t, err) + return rel +} diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index b14305112..ca6ac079f 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -28,8 +28,9 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" releasei "helm.sh/helm/v4/pkg/release" - "helm.sh/helm/v4/pkg/release/common" + releasecommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/v1/sequence" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" ) @@ -183,7 +184,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) // TODO: Are there any cases where we want to force a delete even if it's // already marked deleted? - if rel.Info.Status == common.StatusUninstalled { + if rel.Info.Status == releasecommon.StatusUninstalled { if !u.KeepHistory { if err := u.purgeReleases(rels...); err != nil { return nil, fmt.Errorf("uninstall: Failed to purge the release: %w", err) @@ -193,8 +194,17 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) return nil, fmt.Errorf("the release named %q is already deleted", name) } + gatingWaiter := waiter + if rel.IsSequenced() && u.WaitStrategy == kube.HookOnlyStrategy { + u.cfg.Logger().Info("release was installed with ordered sequencing; using the status watcher to gate deletion batches (hooks keep the hook-only strategy)", "release", rel.Name) + gatingWaiter, err = getWaiterFor(u.cfg.KubeClient, kube.StatusWatcherStrategy, u.WaitOptions...) + if err != nil { + return nil, err + } + } + u.cfg.Logger().Debug("uninstall: deleting release", "name", name) - rel.Info.Status = common.StatusUninstalling + rel.Info.Status = releasecommon.StatusUninstalling rel.Info.Deleted = time.Now() rel.Info.Description = "Deletion in progress (or silently failed)" res := &releasei.UninstallReleaseResponse{Release: rel} @@ -214,7 +224,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) u.cfg.Logger().Debug("uninstall: Failed to store updated release", slog.Any("error", err)) } - deletedResources, kept, errs := u.deleteRelease(rel) + deletedResources, kept, errs := u.deleteRelease(rel, gatingWaiter) if errs != nil { u.cfg.Logger().Debug("uninstall: Failed to delete release", slog.Any("error", errs)) return nil, fmt.Errorf("failed to delete release: %s", name) @@ -222,8 +232,10 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) res.Info = kept - if err := waiter.WaitForDelete(deletedResources, u.Timeout); err != nil { - errs = append(errs, err) + if !rel.IsSequenced() { + if err := waiter.WaitForDelete(deletedResources, u.Timeout); err != nil { + errs = append(errs, err) + } } if !u.DisableHooks { @@ -233,7 +245,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) } } - rel.Info.Status = common.StatusUninstalled + rel.Info.Status = releasecommon.StatusUninstalled if len(u.Description) > 0 { rel.Info.Description = u.Description } else { @@ -272,7 +284,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) } u.cfg.Logger().Debug("superseding previous deployment", "version", rel.Version) - rel.Info.Status = common.StatusSuperseded + rel.Info.Status = releasecommon.StatusSuperseded if err := u.cfg.Releases.Update(rel); err != nil { u.cfg.Logger().Debug("uninstall: Failed to store updated release", slog.Any("error", err)) } @@ -317,8 +329,21 @@ func (e *joinedErrors) Unwrap() []error { return e.errs } -// deleteRelease deletes the release and returns list of delete resources and manifests that were kept in the deletion process -func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, string, []error) { +// deleteRelease deletes the release and returns list of delete resources and manifests that were kept in the deletion process. +func (u *Uninstall) deleteRelease(rel *release.Release, waiter kube.Waiter) (kube.ResourceList, string, []error) { + if rel.IsSequenced() { + resources, kept, errs, planned := u.deleteReleaseSequenced(rel, waiter) + if planned { + return resources, kept, errs + } + // The ordered plan could not be rebuilt from the stored release (e.g. + // a corrupted record). Never leave the release stuck in uninstalling + // over ordering: deletion completeness is what matters, so degrade to + // the standard unsequenced deletion below with a warning, exactly as + // upgrade does for removed-resource deletion (deleteRemovedFromOldRelease). + u.cfg.Logger().Warn("unable to rebuild the ordered uninstall plan from the stored release; deleting resources without sequencing", slog.Any("error", joinErrors(errs, "; "))) + } + var errs []error manifests := releaseutil.SplitManifests(rel.Manifest) @@ -340,75 +365,185 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri } } + resources, err := u.buildDeleteResources(filesToDelete) + if err != nil { + return nil, "", []error{err} + } + + ownedResources, skipped, err := u.verifyOwnedForDelete(resources, rel.Name, rel.Namespace) + if err != nil { + return nil, "", []error{err} + } + if skipped != "" { + if kept.Len() > 0 { + kept.WriteString("\n") + } + kept.WriteString(skipped) + } + + // Delete only owned resources + if len(ownedResources) > 0 { + _, errs = u.cfg.KubeClient.Delete(ownedResources, parseCascadingFlag(u.DeletionPropagation, u.cfg.Logger())) + } + return ownedResources, kept.String(), errs +} + +// deleteReleaseSequenced deletes a sequenced release's resources in exact +// reverse deployment order. The forward plan is rebuilt from the STORED +// manifest via its "# Source:" comments (no chart re-render), so it covers +// every stored resource, including vendored subcharts absent from Chart.yaml +// dependencies, which the DAG-walking deleter used to leak (bead i42). +// Keep-policy filtering applies to the parsed stream before the plan is built, +// so kept resources are never part of the plan. +// +// The final return value reports whether a plan was built and ordered deletion +// ran (regardless of deletion errors). false means planning failed before any +// deletion was attempted; the caller falls back to unsequenced deletion so a +// planning failure can never leave the release stuck in uninstalling. +func (u *Uninstall) deleteReleaseSequenced(rel *release.Release, waiter kube.Waiter) (kube.ResourceList, string, []error, bool) { + manifests, err := sequence.ParseStoredManifests(rel.Manifest) + if err != nil { + return nil, "", []error{fmt.Errorf("parsing stored release manifest: %w", err)}, false + } + + filesToKeep, filesToDelete := filterManifestsToKeep(manifests) + var kept strings.Builder + if len(filesToKeep) > 0 { + kept.WriteString("These resources were kept due to the resource policy:\n") + for _, f := range filesToKeep { + fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name) + } + } + + plan, err := sequence.Build(rel.Chart, filesToDelete) + if err != nil { + return nil, "", []error{fmt.Errorf("building sequencing plan for uninstall: %w", err)}, false + } + logPlanWarnings(u.cfg.Logger(), plan) + + deadline := computeDeadline(u.Timeout) + var allDeleted kube.ResourceList + for _, batch := range plan.Reverse().Batches { + deleted, errs := u.deleteManifestBatch(batch.Manifests(), waiter, deadline, rel.Name, rel.Namespace) + allDeleted = append(allDeleted, deleted...) + if len(errs) > 0 { + return allDeleted, kept.String(), errs, true + } + } + return allDeleted, kept.String(), nil, true +} + +// verifyOwnedForDelete verifies which of the built resources are owned by the +// release. It logs (and thereby skips) resources that are not owned by the +// release or whose ownership cannot be verified, and returns the owned subset +// together with a human-readable summary of what was skipped (empty when nothing +// was skipped). It performs no deletion; callers delete the returned owned +// resources. Both the sequenced and non-sequenced uninstall paths route through +// it so neither deletes resources that do not belong to the release. +func (u *Uninstall) verifyOwnedForDelete(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, string, error) { + if len(resources) == 0 { + return nil, "", nil + } + + ownedResources, unownedResources, unverifiableResources, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + if err != nil { + return nil, "", fmt.Errorf("unable to verify resource ownership: %w", err) + } + + var skipped strings.Builder + + // Log and report resources that are not owned by this release. + if len(unownedResources) > 0 { + for _, info := range unownedResources { + u.cfg.Logger().Warn("skipping delete of resource not owned by this release", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace, + "release", releaseName) + } + fmt.Fprintf(&skipped, "%d resource(s) were not deleted because they are not owned by this release:\n", len(unownedResources)) + for _, info := range unownedResources { + fmt.Fprintf(&skipped, "[%s] %s\n", info.Mapping.GroupVersionKind.Kind, info.Name) + } + } + + // Log and report resources whose ownership could not be verified. + if len(unverifiableResources) > 0 { + for _, ur := range unverifiableResources { + u.cfg.Logger().Warn("skipping delete of resource because ownership could not be verified", + "kind", ur.Info.Mapping.GroupVersionKind.Kind, + "name", ur.Info.Name, + "namespace", ur.Info.Namespace, + "release", releaseName, + "error", ur.Err) + } + if skipped.Len() > 0 { + skipped.WriteString("\n") + } + fmt.Fprintf(&skipped, "%d resource(s) were not deleted because their ownership could not be verified:\n", len(unverifiableResources)) + for _, ur := range unverifiableResources { + fmt.Fprintf(&skipped, "[%s] %s: %s\n", ur.Info.Mapping.GroupVersionKind.Kind, ur.Info.Name, ur.Err) + } + } + + for _, info := range ownedResources { + u.cfg.Logger().Debug("deleting resource owned by this release", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace, + "release", releaseName) + } + + return ownedResources, skipped.String(), nil +} + +func (u *Uninstall) buildDeleteResources(manifests []releaseutil.Manifest) (kube.ResourceList, error) { var builder strings.Builder - for _, file := range filesToDelete { + for _, file := range manifests { builder.WriteString("\n---\n" + file.Content) } resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false) if err != nil { - return nil, "", []error{fmt.Errorf("unable to build kubernetes objects for delete: %w", err)} + return nil, fmt.Errorf("unable to build kubernetes objects for delete: %w", err) } + return resources, nil +} - // Verify ownership before deleting resources - var ownedResources, unownedResources kube.ResourceList - var unverifiableResources []unverifiableResource - if len(resources) > 0 { - ownedResources, unownedResources, unverifiableResources, err = verifyOwnershipBeforeDelete(resources, rel.Name, rel.Namespace) +func (u *Uninstall) deleteManifestBatch(manifests []releaseutil.Manifest, waiter kube.Waiter, deadline time.Time, releaseName, releaseNamespace string) (kube.ResourceList, []error) { + resources, err := u.buildDeleteResources(manifests) + if err != nil || len(resources) == 0 { if err != nil { - return nil, "", []error{fmt.Errorf("unable to verify resource ownership: %w", err)} + return nil, []error{err} } + return nil, nil + } - // Log warnings for unowned resources - if len(unownedResources) > 0 { - for _, info := range unownedResources { - u.cfg.Logger().Warn("skipping delete of resource not owned by this release", - "kind", info.Mapping.GroupVersionKind.Kind, - "name", info.Name, - "namespace", info.Namespace, - "release", rel.Name) - } - if kept.Len() > 0 { - kept.WriteString("\n") - } - fmt.Fprintf(&kept, "%d resource(s) were not deleted because they are not owned by this release:\n", len(unownedResources)) - for _, info := range unownedResources { - fmt.Fprintf(&kept, "[%s] %s\n", info.Mapping.GroupVersionKind.Kind, info.Name) - } - } + // Verify ownership before deleting so a sequenced uninstall, like the + // non-sequenced path, never deletes resources it does not own. + ownedResources, _, err := u.verifyOwnedForDelete(resources, releaseName, releaseNamespace) + if err != nil { + return nil, []error{err} + } + if len(ownedResources) == 0 { + return nil, nil + } - // Log warnings for resources whose ownership could not be verified - if len(unverifiableResources) > 0 { - for _, ur := range unverifiableResources { - u.cfg.Logger().Warn("skipping delete of resource because ownership could not be verified", - "kind", ur.Info.Mapping.GroupVersionKind.Kind, - "name", ur.Info.Name, - "namespace", ur.Info.Namespace, - "release", rel.Name, - "error", ur.Err) - } - if kept.Len() > 0 { - kept.WriteString("\n") - } - fmt.Fprintf(&kept, "%d resource(s) were not deleted because their ownership could not be verified:\n", len(unverifiableResources)) - for _, ur := range unverifiableResources { - fmt.Fprintf(&kept, "[%s] %s: %s\n", ur.Info.Mapping.GroupVersionKind.Kind, ur.Info.Name, ur.Err) - } - } + _, errs := u.cfg.KubeClient.Delete(ownedResources, parseCascadingFlag(u.DeletionPropagation, u.cfg.Logger())) + if len(errs) > 0 { + return ownedResources, errs + } - // Delete only owned resources - if len(ownedResources) > 0 { - for _, info := range ownedResources { - u.cfg.Logger().Debug("deleting resource owned by this release", - "kind", info.Mapping.GroupVersionKind.Kind, - "name", info.Name, - "namespace", info.Namespace, - "release", rel.Name) - } - _, errs = u.cfg.KubeClient.Delete(ownedResources, parseCascadingFlag(u.DeletionPropagation, u.cfg.Logger())) + timeout := u.Timeout + if !deadline.IsZero() { + if remaining := time.Until(deadline); remaining < timeout { + timeout = remaining } } - return ownedResources, kept.String(), errs + if err := waiter.WaitForDelete(ownedResources, timeout); err != nil { + errs = append(errs, err) + } + return ownedResources, errs } func parseCascadingFlag(cascadingFlag string, logger *slog.Logger) v1.DeletionPropagation { diff --git a/pkg/action/uninstall_sequenced_test.go b/pkg/action/uninstall_sequenced_test.go new file mode 100644 index 000000000..de2c1f7da --- /dev/null +++ b/pkg/action/uninstall_sequenced_test.go @@ -0,0 +1,798 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/kube" + kubefake "helm.sh/helm/v4/pkg/kube/fake" + rcommon "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +func newSequencedUninstallAction(t *testing.T, kubeClient kube.Interface) *Uninstall { + t.Helper() + + cfg := actionConfigFixture(t) + cfg.KubeClient = kubeClient + + uninstall := NewUninstall(cfg) + uninstall.DisableHooks = true + uninstall.Timeout = 5 * time.Minute + uninstall.WaitStrategy = kube.OrderedWaitStrategy + + return uninstall +} + +func seedUninstallRelease(t *testing.T, uninstall *Uninstall, rel *release.Release) { + t.Helper() + require.NoError(t, uninstall.cfg.Releases.Create(rel)) +} + +func newUninstallRelease(name string, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo, hooks ...*release.Hook) *release.Release { + now := time.Now() + return &release.Release{ + Name: name, + Namespace: "spaced", + Chart: ch, + Config: map[string]any{}, + Manifest: manifest, + Info: &release.Info{ + FirstDeployed: now, + LastDeployed: now, + Status: rcommon.StatusDeployed, + Description: "Seeded release", + }, + Version: 1, + Hooks: hooks, + SequencingInfo: sequencingInfo, + } +} + +// sourcedManifest prefixes a manifest document with the "# Source:" comment the +// real render pipeline stores in rel.Manifest (action.go renderResourcesWithFiles). +func sourcedManifest(path, content string) string { + return "# Source: " + path + "\n" + content +} + +type commentStrippingPostRenderer struct{} + +func (commentStrippingPostRenderer) Run(in *bytes.Buffer) (*bytes.Buffer, error) { + var out bytes.Buffer + for line := range strings.SplitSeq(in.String(), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + out.WriteString(line + "\n") + } + return &out, nil +} + +func secretManifest(name string) string { + return fmt.Sprintf(`apiVersion: v1 +kind: Secret +metadata: + name: %s +type: Opaque +data: + password: cGFzc3dvcmQ= +`, name) +} + +func serviceAccountManifest(name string) string { + return fmt.Sprintf(`apiVersion: v1 +kind: ServiceAccount +metadata: + name: %s +`, name) +} + +func expectedUninstallIDs(t *testing.T, manifest string) []string { + t.Helper() + + _, files, err := releaseutil.SortManifests(releaseutil.SplitManifests(manifest), nil, releaseutil.UninstallOrder) + require.NoError(t, err) + + ids := make([]string, 0, len(files)) + for _, file := range files { + ids = append(ids, fmt.Sprintf("%s/%s", file.Head.Kind, file.Head.Metadata.Name)) + } + return ids +} + +func filterDeleteCalls(calls [][]string, wanted ...string) [][]string { + want := make(map[string]struct{}, len(wanted)) + for _, id := range wanted { + want[id] = struct{}{} + } + + var filtered [][]string + for _, call := range calls { + if len(call) != 1 { + continue + } + if _, ok := want[call[0]]; ok { + filtered = append(filtered, call) + } + } + return filtered +} + +func operationIndex(operations []string, exact string) int { + for i, operation := range operations { + if operation == exact { + return i + } + } + return -1 +} + +func newDeleteHook(name string, event release.HookEvent) *release.Hook { + return &release.Hook{ + Name: name, + Kind: "ConfigMap", + Path: name, + Manifest: configMapManifest(name, nil), + Events: []release.HookEvent{event}, + DeletePolicies: []release.HookDeletePolicy{release.HookSucceeded}, + } +} + +func TestUninstall_Sequenced_ReverseOrder(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("sequenced-uninstall")) + + rel := newUninstallRelease( + "sequenced-uninstall", + ch, + joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) +} + +func TestUninstall_NonSequenced_Unchanged(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + + manifest := joinManifestDocs( + configMapManifest("config", nil), + secretManifest("secret"), + serviceAccountManifest("service-account"), + ) + rel := newUninstallRelease("standard-uninstall", buildChartWithTemplates(nil, withName("standard-uninstall")), manifest, nil) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + require.Len(t, client.deleteCalls, 1) + assert.Equal(t, expectedUninstallIDs(t, manifest), client.deleteCalls[0]) + require.Len(t, client.deleteWaitCalls, 1) + assert.Equal(t, client.deleteCalls[0], client.deleteWaitCalls[0]) +} + +func TestUninstall_Sequenced_WithSubcharts(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + bar := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/bar.yaml", "bar", nil), + }, withName("bar")) + nginx := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/nginx.yaml", "nginx", nil), + }, withName("nginx")) + + bar.AddDependency(nginx) + bar.Metadata.Dependencies = []*chart.Dependency{{Name: "nginx"}} + parent.AddDependency(bar) + parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}} + + rel := newUninstallRelease( + "subchart-uninstall", + parent, + joinManifestDocs( + sourcedManifest("parent/charts/bar/charts/nginx/templates/nginx.yaml", configMapManifest("nginx", nil)), + sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)), + sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + // Reverse-DAG uninstall: parent first, then bar (its own resources before + // recursing into bar's subchart batch), then nginx (innermost). With the + // chart-path threading fix, nested subcharts route through their proper + // subtree level instead of flattening. + assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}, {"ConfigMap/nginx"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) +} + +// TestUninstall_Sequenced_DeletesVendoredSubchart locks bead i42: a subchart +// vendored into charts/ but absent from Chart.yaml dependencies deploys on +// install (TestInstall_Sequenced_UndeclaredVendoredSubchartDeployed) but the +// DAG-walking deleter never visited it. The plan's completeness invariant +// guarantees it is now deleted, after the parent's own resources and before +// declared subcharts (exact reverse of the forward plan). +func TestUninstall_Sequenced_DeletesVendoredSubchart(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + declared := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + }, withName("database")) + vendored := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/vendored.yaml", "vendored", nil), + }, withName("vendored")) + + parent.AddDependency(declared, vendored) + parent.Metadata.Dependencies = []*chart.Dependency{{Name: "database"}} + + rel := newUninstallRelease( + "vendored-uninstall", + parent, + joinManifestDocs( + sourcedManifest("parent/charts/database/templates/database.yaml", configMapManifest("database", nil)), + sourcedManifest("parent/charts/vendored/templates/vendored.yaml", configMapManifest("vendored", nil)), + sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/vendored"}, {"ConfigMap/database"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) +} + +// TestUninstall_Sequenced_DefaultWaitStrategyGatesBatches locks bead 4sj / +// master-plan D4: ordering intent is a property of the stored release, so a +// sequenced release uninstalled with the DEFAULT wait strategy (hookOnly, whose +// WaitForDelete is a no-op) must upgrade its batch-gating waiter to the status +// watcher. Hooks keep the user's strategy; this test disables hooks. +func TestUninstall_Sequenced_DefaultWaitStrategyGatesBatches(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + cfg := actionConfigFixture(t) + cfg.KubeClient = client + + uninstall := NewUninstall(cfg) + uninstall.DisableHooks = true + uninstall.Timeout = 5 * time.Minute + uninstall.WaitStrategy = kube.HookOnlyStrategy + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("default-wait-uninstall")) + + rel := newUninstallRelease( + "default-wait-uninstall", + ch, + joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), + nil, + ) + rel.Sequenced = true + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) + assert.Contains(t, client.waiterStrategies, kube.StatusWatcherStrategy) +} + +// TestUninstall_Sequenced_UsesStoredSourcePaths pins the D5 mechanism: the +// uninstall plan is built purely from the stored manifest's "# Source:" +// comments. The chart in this fixture would RE-RENDER to different resource +// names (values drift), which used to break the fuzzy content/identity +// matcher; the stored paths make re-rendering irrelevant. +func TestUninstall_Sequenced_UsesStoredSourcePaths(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent-renamed", nil), + }, withName("parent")) + bar := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/bar.yaml", "bar-renamed", nil), + }, withName("bar")) + + parent.AddDependency(bar) + parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}} + + rel := newUninstallRelease( + "stored-source-uninstall", + parent, + joinManifestDocs( + sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)), + sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) +} + +// TestUninstall_Sequenced_AfterPostRenderedInstall pins the epic's D5 premise +// end-to-end: "# Source:" comments are appended to the stored manifest AFTER +// post-rendering (from the filename annotation), so even a comment-stripping +// post-renderer (kustomize-style) leaves the stored record fully +// plan-recoverable. NOTE: this test also passes before the Stage-C rewrite +// (the old path recovered order by re-rendering); it exists so any future +// regression of the premise fails loudly here rather than in production. +func TestUninstall_Sequenced_AfterPostRenderedInstall(t *testing.T) { + client := newRecordingKubeClient() + client.buildRESTClient = statefulOwnedRESTClient(client, "test-install-release", "spaced") + + install := newSequencedInstallAction(t, client) + install.PostRenderer = commentStrippingPostRenderer{} + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + bar := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/bar.yaml", "bar", nil), + }, withName("bar")) + + parent.AddDependency(bar) + parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}} + + rel := mustRelease(t, mustRunInstall(t, install, parent)) + assert.Contains(t, rel.Manifest, "# Source: parent/charts/bar/templates/") + assert.Contains(t, rel.Manifest, "# Source: parent/templates/") + + uninstall := NewUninstall(install.cfg) + uninstall.DisableHooks = true + uninstall.Timeout = 5 * time.Minute + uninstall.WaitStrategy = kube.OrderedWaitStrategy + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) +} + +func TestUninstall_Sequenced_KeepPolicy(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database-live.yaml", "database-live", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/database-keep.yaml", "database-keep", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + kube.ResourcePolicyAnno: kube.KeepPolicy, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("keep-policy")) + + rel := newUninstallRelease( + "keep-policy", + ch, + joinManifestDocs( + configMapManifest("database-live", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("database-keep", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + kube.ResourcePolicyAnno: kube.KeepPolicy, + }), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + res, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database-live"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) + assert.NotContains(t, client.deleteCalls, []string{"ConfigMap/database-keep"}) + assert.Contains(t, res.Info, "[ConfigMap] database-keep") +} + +func TestUninstall_Sequenced_KeepHistory(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + uninstall.KeepHistory = true + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("keep-history")) + + rel := newUninstallRelease( + "keep-history", + ch, + joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + reli, err := uninstall.cfg.Releases.Get(rel.Name, rel.Version) + require.NoError(t, err) + stored, err := releaserToV1Release(reli) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls) + assert.Equal(t, rcommon.StatusUninstalled, stored.Info.Status) +} + +func TestUninstall_Sequenced_DryRun(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + uninstall.DryRun = true + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("dry-run-uninstall")) + + rel := newUninstallRelease( + "dry-run-uninstall", + ch, + joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + res, err := uninstall.Run(rel.Name) + require.NoError(t, err) + require.NotNil(t, res) + assert.Empty(t, client.deleteCalls) + assert.Empty(t, client.deleteWaitCalls) +} + +func TestUninstall_Sequenced_Hooks(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + uninstall.DisableHooks = false + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("hooked-uninstall")) + + rel := newUninstallRelease( + "hooked-uninstall", + ch, + joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + newDeleteHook("pre-hook", release.HookPreDelete), + newDeleteHook("post-hook", release.HookPostDelete), + ) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.createCalls) + assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.watchUntilReadyCalls) + assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, filterDeleteCalls(client.deleteCalls, "ConfigMap/app", "ConfigMap/database")) + + preHookCreate := operationIndex(client.operations, "create:ConfigMap/pre-hook") + appDelete := operationIndex(client.operations, "delete:ConfigMap/app") + databaseWaitDelete := operationIndex(client.operations, "wait-delete:ConfigMap/database") + postHookCreate := operationIndex(client.operations, "create:ConfigMap/post-hook") + + require.NotEqual(t, -1, preHookCreate) + require.NotEqual(t, -1, appDelete) + require.NotEqual(t, -1, databaseWaitDelete) + require.NotEqual(t, -1, postHookCreate) + assert.Less(t, preHookCreate, appDelete) + assert.Greater(t, postHookCreate, databaseWaitDelete) +} + +// TestUninstall_Sequenced_SkipsUnownedResources locks the fix for the sequenced +// uninstall ownership gap: the sequenced delete path must verify ownership and +// skip resources it does not own, matching the non-sequenced path, rather than +// deleting them. +func TestUninstall_Sequenced_SkipsUnownedResources(t *testing.T) { + is := assert.New(t) + + logBuffer := &bytes.Buffer{} + config := actionConfigFixture(t) + config.SetLogger(slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn})) + + uninstall := NewUninstall(config) + uninstall.DisableHooks = true + uninstall.KeepHistory = true + uninstall.WaitStrategy = kube.OrderedWaitStrategy + + ch := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + }), + }, withName("sequenced-ownership")) + + rel := newUninstallRelease( + "sequenced-ownership", + ch, + configMapManifest("app", map[string]string{releaseutil.AnnotationResourceGroup: "app"}), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + // Build resolves every manifest to a resource that is NOT owned by this + // release (no Helm ownership metadata). The sequenced path must skip it. + failer := config.KubeClient.(*kubefake.FailingKubeClient) + failer.DummyResources = kube.ResourceList{ + newDeploymentWithOwner("unowned-deploy", rel.Namespace, nil, nil), + } + + _, err := uninstall.Run(rel.Name) + is.NoError(err) + + logOutput := logBuffer.String() + is.Contains(logOutput, "skipping delete of resource not owned by this release") + is.Contains(logOutput, "unowned-deploy") +} + +// storageDecodedChart encodes and decodes a chart the way the release storage +// codec does (json.Marshal in pkg/storage/driver). The memory driver used by +// actionConfigFixture shares release pointers, so without this helper action +// tests never see the shape real (secrets/configmaps/sql) storage produces: +// a chart whose loaded dependency tree is gone. +func storageDecodedChart(t *testing.T, c *chart.Chart) *chart.Chart { + t.Helper() + + encoded, err := json.Marshal(c) + require.NoError(t, err) + decoded := &chart.Chart{} + require.NoError(t, json.Unmarshal(encoded, decoded)) + require.Empty(t, decoded.Dependencies(), "release codec is expected to drop the loaded dependency tree") + return decoded +} + +// TestUninstall_Sequenced_StorageDecodedChart_NestedSubcharts reproduces the +// live bead-xmn failure: `helm uninstall` of a sequenced nested-subchart +// release read from REAL storage (which drops the chart's loaded dependency +// tree) must still delete in exact reverse deployment order instead of +// failing to build the subchart DAG and stranding the release in +// status=uninstalling. +func TestUninstall_Sequenced_StorageDecodedChart_NestedSubcharts(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + bar := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/bar.yaml", "bar", nil), + }, withName("bar")) + nginx := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/nginx.yaml", "nginx", nil), + }, withName("nginx")) + + // Post-ProcessDependencies shape, as install stores it: enabled entries + // only, and the parent-resources annotation that made the live uninstall + // fail with `references unknown or disabled subchart "bar"`. + bar.AddDependency(nginx) + bar.Metadata.Dependencies = []*chart.Dependency{{Name: "nginx", Enabled: true}} + parent.AddDependency(bar) + parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar", Enabled: true}} + parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["bar"]`} + + rel := newUninstallRelease( + "decoded-nested-uninstall", + storageDecodedChart(t, parent), + joinManifestDocs( + sourcedManifest("parent/charts/bar/charts/nginx/templates/nginx.yaml", configMapManifest("nginx", nil)), + sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)), + sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}, {"ConfigMap/nginx"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) +} + +// TestUninstall_Sequenced_StorageDecodedChart_VendoredSubchart extends the +// bead-i42 guard across the storage round-trip: a vendored-but-undeclared +// subchart of a storage-decoded chart is still deleted, after the parent's +// own resources and before declared subcharts. +func TestUninstall_Sequenced_StorageDecodedChart_VendoredSubchart(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + uninstall := newSequencedUninstallAction(t, client) + + parent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + parent.Metadata.Dependencies = []*chart.Dependency{{Name: "database", Enabled: true}} + parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["database"]`} + + rel := newUninstallRelease( + "decoded-vendored-uninstall", + storageDecodedChart(t, parent), + joinManifestDocs( + sourcedManifest("parent/charts/database/templates/database.yaml", configMapManifest("database", nil)), + sourcedManifest("parent/charts/vendored/templates/vendored.yaml", configMapManifest("vendored", nil)), + sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)), + ), + &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}, + ) + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/vendored"}, {"ConfigMap/database"}}, client.deleteCalls) + assert.Equal(t, client.deleteCalls, client.deleteWaitCalls) +} + +// TestUninstall_Sequenced_PlanFailure_FallsBackUnsequenced locks the +// stuck-release fix: when the ordered plan cannot be rebuilt from the stored +// release (here: a resource-group cycle, which Build treats as fatal), the +// uninstall must warn and degrade to the standard unsequenced deletion so the +// release reaches status=uninstalled instead of stranding in uninstalling. +func TestUninstall_Sequenced_PlanFailure_FallsBackUnsequenced(t *testing.T) { + client := newRecordingKubeClient().withoutBuildClients() + logBuffer := &bytes.Buffer{} + + cfg := actionConfigFixture(t) + cfg.SetLogger(slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn})) + cfg.KubeClient = client + + uninstall := NewUninstall(cfg) + uninstall.DisableHooks = true + uninstall.KeepHistory = true + uninstall.Timeout = 5 * time.Minute + uninstall.WaitStrategy = kube.OrderedWaitStrategy + + manifest := joinManifestDocs( + configMapManifest("alpha", map[string]string{ + releaseutil.AnnotationResourceGroup: "a", + releaseutil.AnnotationDependsOnResourceGroups: `["b"]`, + }), + configMapManifest("beta", map[string]string{ + releaseutil.AnnotationResourceGroup: "b", + releaseutil.AnnotationDependsOnResourceGroups: `["a"]`, + }), + ) + rel := newUninstallRelease( + "cyclic-uninstall", + buildChartWithTemplates(nil, withName("cyclic-uninstall")), + manifest, + nil, + ) + rel.Sequenced = true + seedUninstallRelease(t, uninstall, rel) + + _, err := uninstall.Run(rel.Name) + require.NoError(t, err) + + assert.Contains(t, logBuffer.String(), "deleting resources without sequencing") + require.Len(t, client.deleteCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/alpha", "ConfigMap/beta"}, client.deleteCalls[0]) + + reli, err := uninstall.cfg.Releases.Get(rel.Name, rel.Version) + require.NoError(t, err) + stored, err := releaserToV1Release(reli) + require.NoError(t, err) + assert.Equal(t, rcommon.StatusUninstalled, stored.Info.Status) +} diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 81d51164f..1b85d87d1 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -40,6 +40,7 @@ import ( ri "helm.sh/helm/v4/pkg/release" rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/v1/sequence" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" ) @@ -70,6 +71,10 @@ type Upgrade struct { SkipCRDs bool // Timeout is the timeout for this operation Timeout time.Duration + // ReadinessTimeout is the per-batch timeout when --wait=ordered is used. + // Each ordered batch waits at most this long for resources to become ready. + // Must not exceed Timeout. Defaults to 1 minute when zero. + ReadinessTimeout time.Duration // WaitStrategy determines what type of waiting should be done WaitStrategy kube.WaitStrategy // WaitOptions are additional options for waiting on resources @@ -188,12 +193,16 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char u.WaitStrategy = kube.StatusWatcherStrategy } + if u.ReadinessTimeout > 0 && u.Timeout > 0 && u.ReadinessTimeout > u.Timeout { + return nil, fmt.Errorf("--readiness-timeout (%s) must not exceed --timeout (%s)", u.ReadinessTimeout, u.Timeout) + } + if err := chartutil.ValidateReleaseName(name); err != nil { return nil, fmt.Errorf("release name is invalid: %s", name) } u.cfg.Logger().Debug("preparing upgrade", "name", name) - currentRelease, upgradedRelease, serverSideApply, err := u.prepareUpgrade(ctx, name, chrt, vals) + currentRelease, upgradedRelease, serverSideApply, sortedManifests, err := u.prepareUpgrade(ctx, name, chrt, vals) if err != nil { return nil, err } @@ -201,7 +210,12 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char u.cfg.Releases.MaxHistory = u.MaxHistory u.cfg.Logger().Debug("performing update", "name", name) - res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease, serverSideApply) + var res *release.Release + if u.WaitStrategy == kube.OrderedWaitStrategy { + res, err = u.performSequencedUpgrade(ctx, chrt, currentRelease, upgradedRelease, sortedManifests, serverSideApply) + } else { + res, err = u.performUpgrade(ctx, currentRelease, upgradedRelease, serverSideApply) + } if err != nil { return res, err } @@ -218,14 +232,16 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char } // prepareUpgrade builds an upgraded release for an upgrade operation. -func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv2.Chart, vals map[string]any) (*release.Release, *release.Release, bool, error) { +// When WaitStrategy is OrderedWaitStrategy, sortedManifests contains the parsed +// manifests with annotation data needed for DAG sequencing; otherwise it is nil. +func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv2.Chart, vals map[string]any) (*release.Release, *release.Release, bool, []releaseutil.Manifest, error) { if chart == nil { - return nil, nil, false, errMissingChart + return nil, nil, false, nil, errMissingChart } // HideSecret must be used with dry run. Otherwise, return an error. if !isDryRun(u.DryRunStrategy) && u.HideSecret { - return nil, nil, false, errors.New("hiding Kubernetes secrets requires a dry-run mode") + return nil, nil, false, nil, errors.New("hiding Kubernetes secrets requires a dry-run mode") } // finds the last non-deleted release with the given name @@ -233,19 +249,19 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv if err != nil { // to keep existing behavior of returning the "%q has no deployed releases" error when an existing release does not exist if errors.Is(err, driver.ErrReleaseNotFound) { - return nil, nil, false, driver.NewErrNoDeployedReleases(name) + return nil, nil, false, nil, driver.NewErrNoDeployedReleases(name) } - return nil, nil, false, err + return nil, nil, false, nil, err } lastRelease, err := releaserToV1Release(lastReleasei) if err != nil { - return nil, nil, false, err + return nil, nil, false, nil, err } // Concurrent `helm upgrade`s will either fail here with `errPending` or when creating the release with "already exists". This should act as a pessimistic lock. if lastRelease.Info.Status.IsPending() { - return nil, nil, false, errPending + return nil, nil, false, nil, errPending } var currentRelease *release.Release @@ -258,14 +274,14 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv var cerr error currentRelease, cerr = releaserToV1Release(currentReleasei) if cerr != nil { - return nil, nil, false, cerr + return nil, nil, false, nil, cerr } if err != nil { if errors.Is(err, driver.ErrNoDeployedReleases) && (lastRelease.Info.Status == rcommon.StatusFailed || lastRelease.Info.Status == rcommon.StatusSuperseded) { currentRelease = lastRelease } else { - return nil, nil, false, err + return nil, nil, false, nil, err } } } @@ -273,11 +289,11 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv // determine if values will be reused vals, err = u.reuseValues(chart, currentRelease, vals) if err != nil { - return nil, nil, false, err + return nil, nil, false, nil, err } if err := chartutil.ProcessDependencies(chart, vals); err != nil { - return nil, nil, false, err + return nil, nil, false, nil, err } // Increment revision count. This is passed to templates, and also stored on @@ -293,25 +309,33 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv caps, err := u.cfg.getCapabilities() if err != nil { - return nil, nil, false, err + return nil, nil, false, nil, err } valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, vals, options, caps, u.SkipSchemaValidation) if err != nil { - return nil, nil, false, err + return nil, nil, false, nil, err } - hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(ctx, chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret, u.PostRenderStrategy) + var sortedManifests []releaseutil.Manifest + var hooks []*release.Hook + var manifestDoc *bytes.Buffer + var notesTxt string + if u.WaitStrategy == kube.OrderedWaitStrategy { + hooks, manifestDoc, notesTxt, sortedManifests, err = u.cfg.renderResourcesWithFiles(ctx, chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret, u.PostRenderStrategy) + } else { + hooks, manifestDoc, notesTxt, err = u.cfg.renderResources(ctx, chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret, u.PostRenderStrategy) + } if err != nil { - return nil, nil, false, err + return nil, nil, false, nil, err } if driver.ContainsSystemLabels(u.Labels) { - return nil, nil, false, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()) + return nil, nil, false, nil, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()) } serverSideApply, err := getUpgradeServerSideValue(u.ServerSideApply, lastRelease.ApplyMethod) if err != nil { - return nil, nil, false, err + return nil, nil, false, nil, err } u.cfg.Logger().Debug("determined release apply method", slog.Bool("server_side_apply", serverSideApply), slog.String("previous_release_apply_method", lastRelease.ApplyMethod)) @@ -333,13 +357,14 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv Hooks: hooks, Labels: mergeCustomLabels(lastRelease.Labels, u.Labels), ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)), + Sequenced: u.WaitStrategy == kube.OrderedWaitStrategy, } if len(notesTxt) > 0 { upgradedRelease.Info.Notes = notesTxt } err = validateManifest(u.cfg.KubeClient, manifestDoc.Bytes(), !u.DisableOpenAPIValidation) - return currentRelease, upgradedRelease, serverSideApply, err + return currentRelease, upgradedRelease, serverSideApply, sortedManifests, err } func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedRelease *release.Release, serverSideApply bool) (*release.Release, error) { @@ -414,7 +439,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR ctxChan := make(chan resultMessage) doneChan := make(chan any) defer close(doneChan) - go u.releasingUpgrade(rChan, upgradedRelease, current, target, originalRelease, serverSideApply) + go u.releasingUpgrade(ctx, rChan, upgradedRelease, current, target, originalRelease, serverSideApply) go u.handleContext(ctx, doneChan, ctxChan, upgradedRelease) select { @@ -428,10 +453,10 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR // Function used to lock the Mutex, this is important for the case when RollbackOnFailure is set. // In that case the upgrade will finish before the rollback is finished so it is necessary to wait for the rollback to finish. // The rollback will be trigger by the function failRelease -func (u *Upgrade) reportToPerformUpgrade(c chan<- resultMessage, rel *release.Release, created kube.ResourceList, err error) { +func (u *Upgrade) reportToPerformUpgrade(ctx context.Context, c chan<- resultMessage, rel *release.Release, created kube.ResourceList, err error) { u.Lock.Lock() if err != nil { - rel, err = u.failRelease(rel, created, err) + rel, err = u.failRelease(ctx, rel, created, err) } c <- resultMessage{r: rel, e: err} u.Lock.Unlock() @@ -444,7 +469,7 @@ func (u *Upgrade) handleContext(ctx context.Context, done chan any, c chan<- res err := ctx.Err() // when RollbackOnFailure is set, the ongoing release finish first and doesn't give time for the rollback happens. - u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, err) + u.reportToPerformUpgrade(ctx, c, upgradedRelease, kube.ResourceList{}, err) case <-done: return } @@ -454,18 +479,24 @@ func isReleaseApplyMethodClientSideApply(applyMethod string) bool { return applyMethod == "" || applyMethod == string(release.ApplyMethodClientSideApply) } -func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release, serverSideApply bool) { +func (u *Upgrade) releasingUpgrade(ctx context.Context, c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release, serverSideApply bool) { // pre-upgrade hooks if !u.DisableHooks { if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil { - u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %w", err)) + u.reportToPerformUpgrade(ctx, c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %w", err)) return } } else { u.cfg.Logger().Debug("upgrade hooks disabled", "name", upgradedRelease.Name) } + // Strip Helm-internal sequencing annotations before applying to K8s. + if err := stripSequencingAnnotations(target); err != nil { + u.reportToPerformUpgrade(ctx, c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("stripping sequencing annotations: %w", err)) + return + } + upgradeClientSideFieldManager := isReleaseApplyMethodClientSideApply(originalRelease.ApplyMethod) && serverSideApply // Update client-side field manager if transitioning from client-side to server-side apply results, err := u.cfg.KubeClient.Update( current, @@ -475,7 +506,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele kube.ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManager)) if err != nil { u.cfg.recordRelease(originalRelease) - u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) + u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, err) return } @@ -487,19 +518,19 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele } if err != nil { u.cfg.recordRelease(originalRelease) - u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) + u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, err) return } if u.WaitForJobs { if err := waiter.WaitWithJobs(target, u.Timeout); err != nil { u.cfg.recordRelease(originalRelease) - u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) + u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, err) return } } else { if err := waiter.Wait(target, u.Timeout); err != nil { u.cfg.recordRelease(originalRelease) - u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) + u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, err) return } } @@ -507,7 +538,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele // post-upgrade hooks if !u.DisableHooks { if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil { - u.reportToPerformUpgrade(c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %w", err)) + u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %w", err)) return } } @@ -521,10 +552,175 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele } else { upgradedRelease.Info.Description = "Upgrade complete" } - u.reportToPerformUpgrade(c, upgradedRelease, nil, nil) + u.reportToPerformUpgrade(ctx, c, upgradedRelease, nil, nil) +} + +// performSequencedUpgrade deploys chart resources in DAG-ordered batches when +// --wait=ordered is used. It mirrors releasingUpgrade but uses sequencedDeployment +// to apply each batch and wait for readiness before proceeding to the next batch. +func (u *Upgrade) performSequencedUpgrade(ctx context.Context, chrt *chartv2.Chart, currentRelease, upgradedRelease *release.Release, manifests []releaseutil.Manifest, serverSideApply bool) (*release.Release, error) { + // Build the full set of current (old) resources for diff matching. + current, err := u.cfg.KubeClient.Build(bytes.NewBufferString(currentRelease.Manifest), false) + if err != nil { + if strings.Contains(err.Error(), "unable to recognize \"\": no matches for kind") { + return upgradedRelease, fmt.Errorf("current release manifest contains removed kubernetes api(s) for this "+ + "kubernetes version and it is therefore unable to build the kubernetes "+ + "objects for performing the diff. error from kubernetes: %w", err) + } + return upgradedRelease, fmt.Errorf("unable to build kubernetes objects from current release manifest: %w", err) + } + + // Build target to compute the set of resources to delete (those removed in the new release). + target, err := u.cfg.KubeClient.Build(bytes.NewBufferString(upgradedRelease.Manifest), !u.DisableOpenAPIValidation) + if err != nil { + return upgradedRelease, fmt.Errorf("unable to build kubernetes objects from new release manifest: %w", err) + } + + // It is safe to use force only on target because these are resources currently rendered by the chart. + if err := target.Visit(setMetadataVisitor(upgradedRelease.Name, upgradedRelease.Namespace, true)); err != nil { + return upgradedRelease, err + } + + // Do a basic diff using gvk + name to figure out what new resources are being + // created so we can validate they don't already exist (or adopt them). + existingResources := make(map[string]bool, len(current)) + for _, r := range current { + existingResources[objectKey(r)] = true + } + var toBeCreated kube.ResourceList + for _, r := range target { + if !existingResources[objectKey(r)] { + toBeCreated = append(toBeCreated, r) + } + } + + var toBeUpdated kube.ResourceList + if u.TakeOwnership { + toBeUpdated, err = requireAdoption(toBeCreated) + } else { + toBeUpdated, err = existingResourceConflict(toBeCreated, upgradedRelease.Name, upgradedRelease.Namespace) + } + if err != nil { + return nil, fmt.Errorf("unable to continue with update: %w", err) + } + + // Adopted resources join current so their plan batches update them in place. + toBeUpdated.Visit(func(r *resource.Info, err error) error { + if err != nil { + return err + } + current.Append(r) + return nil + }) + + if isDryRun(u.DryRunStrategy) { + u.cfg.Logger().Debug("dry run for release", "name", upgradedRelease.Name) + if len(u.Description) > 0 { + upgradedRelease.Info.Description = u.Description + } else { + upgradedRelease.Info.Description = "Dry run complete" + } + return upgradedRelease, nil + } + + plan, err := sequence.Build(chrt, manifests) + if err != nil { + return upgradedRelease, fmt.Errorf("building sequencing plan: %w", err) + } + + u.cfg.Logger().Debug("creating upgraded release", "name", upgradedRelease.Name) + if err := u.cfg.Releases.Create(upgradedRelease); err != nil { + return nil, err + } + + // pre-upgrade hooks + if !u.DisableHooks { + if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil { + return u.failRelease(ctx, upgradedRelease, nil, fmt.Errorf("pre-upgrade hooks failed: %w", err)) + } + } else { + u.cfg.Logger().Debug("upgrade hooks disabled", "name", upgradedRelease.Name) + } + + upgradeCSAFieldManager := isReleaseApplyMethodClientSideApply(currentRelease.ApplyMethod) && serverSideApply + + readinessTimeout := u.ReadinessTimeout + if readinessTimeout <= 0 { + readinessTimeout = time.Minute + } + + sd := &sequencedDeployment{ + cfg: u.cfg, + releaseName: upgradedRelease.Name, + releaseNamespace: upgradedRelease.Namespace, + disableOpenAPI: u.DisableOpenAPIValidation, + serverSideApply: serverSideApply, + forceConflicts: u.ForceConflicts, + forceReplace: u.ForceReplace, + waitStrategy: u.WaitStrategy, + waitOptions: u.WaitOptions, + waitForJobs: u.WaitForJobs, + timeout: u.Timeout, + readinessTimeout: readinessTimeout, + deadline: computeDeadline(u.Timeout), + upgradeMode: true, + currentResources: current, + upgradeCSAFieldManager: upgradeCSAFieldManager, + } + + logPlanWarnings(u.cfg.Logger(), plan) + if err := sd.apply(ctx, plan); err != nil { + return u.failRelease(ctx, upgradedRelease, sd.createdResources, err) + } + + // Delete resources present in the old release but absent from the new one, + // in exact reverse deployment order of the old release's plan (7yi). + allNewKeys := make(map[string]bool, len(target)) + for _, r := range target { + allNewKeys[objectKey(r)] = true + } + removedKeys := make(map[string]bool) + var toBeDeleted kube.ResourceList + for _, r := range current { + if !allNewKeys[objectKey(r)] { + removedKeys[objectKey(r)] = true + toBeDeleted = append(toBeDeleted, r) + } + } + if len(toBeDeleted) > 0 { + if err := u.deleteRemovedSequenced(currentRelease, sd, removedKeys, toBeDeleted); err != nil { + return u.failRelease(ctx, upgradedRelease, sd.createdResources, err) + } + } + + // post-upgrade hooks + if !u.DisableHooks { + if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil { + return u.failRelease(ctx, upgradedRelease, sd.createdResources, fmt.Errorf("post-upgrade hooks failed: %w", err)) + } + } + + currentRelease.Info.Status = rcommon.StatusSuperseded + u.cfg.recordRelease(currentRelease) + upgradedRelease.Info.Status = rcommon.StatusDeployed + if len(u.Description) > 0 { + upgradedRelease.Info.Description = u.Description + } else { + upgradedRelease.Info.Description = "Upgrade complete" + } + u.cfg.recordRelease(upgradedRelease) + return upgradedRelease, nil +} + +func (u *Upgrade) deleteRemovedSequenced(currentRelease *release.Release, sd *sequencedDeployment, removedKeys map[string]bool, toBeDeleted kube.ResourceList) error { + waiter, err := getWaiterFor(u.cfg.KubeClient, u.WaitStrategy, u.WaitOptions...) + if err != nil { + return fmt.Errorf("getting waiter for removed-resource deletion: %w", err) + } + return sd.deleteRemovedFromOldRelease(currentRelease, removedKeys, toBeDeleted, waiter) } -func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, err error) (*release.Release, error) { +func (u *Upgrade) failRelease(ctx context.Context, rel *release.Release, created kube.ResourceList, err error) (*release.Release, error) { msg := fmt.Sprintf("Upgrade %q failed: %s", rel.Name, err) u.cfg.Logger().Warn( "upgrade failed", @@ -588,7 +784,7 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e rollin.ForceConflicts = u.ForceConflicts rollin.ServerSideApply = u.ServerSideApply rollin.Timeout = u.Timeout - if rollErr := rollin.Run(rel.Name); rollErr != nil { + if rollErr := rollin.RunWithContext(ctx, rel.Name); rollErr != nil { return rel, fmt.Errorf("an error occurred while rolling back the release. original upgrade error: %w: %w", err, rollErr) } return rel, fmt.Errorf("release %s failed, and has been rolled back due to rollback-on-failure being set: %w", rel.Name, err) diff --git a/pkg/action/upgrade_sequenced_test.go b/pkg/action/upgrade_sequenced_test.go new file mode 100644 index 000000000..ec493711c --- /dev/null +++ b/pkg/action/upgrade_sequenced_test.go @@ -0,0 +1,559 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/kube" + ri "helm.sh/helm/v4/pkg/release" + rcommon "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +func newSequencedUpgradeAction(t *testing.T, kubeClient kube.Interface) *Upgrade { + t.Helper() + cfg := actionConfigFixture(t) + cfg.KubeClient = kubeClient + + upgrade := NewUpgrade(cfg) + upgrade.Namespace = "spaced" + upgrade.Timeout = 5 * time.Minute + upgrade.ReadinessTimeout = time.Minute + upgrade.WaitStrategy = kube.OrderedWaitStrategy + return upgrade +} + +func seedDeployedRelease(t *testing.T, upgrade *Upgrade, name string, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo) { + t.Helper() + + now := time.Now() + rel := &release.Release{ + Name: name, + Namespace: "spaced", + Chart: ch, + Config: map[string]any{}, + Manifest: manifest, + Info: &release.Info{ + FirstDeployed: now, + LastDeployed: now, + Status: rcommon.StatusDeployed, + Description: "Seeded release", + }, + Version: 1, + SequencingInfo: sequencingInfo, + } + + require.NoError(t, upgrade.cfg.Releases.Create(rel)) +} + +func joinManifestDocs(docs ...string) string { + return strings.Join(docs, "\n---\n") +} + +func updateTargets(calls []updateCall) [][]string { + targets := make([][]string, 0, len(calls)) + for _, call := range calls { + targets = append(targets, call.target) + } + return targets +} + +func TestUpgrade_Sequenced_Basic(t *testing.T) { + client := newRecordingKubeClient() + upgrade := newSequencedUpgradeAction(t, client) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("sequenced-upgrade")) + seedDeployedRelease(t, upgrade, "sequenced-upgrade", currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), nil) + + upgradedChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("sequenced-upgrade")) + + rel := mustRelease(t, mustRunUpgrade(t, upgrade, "sequenced-upgrade", upgradedChart)) + + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls)) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls) + require.Len(t, client.updateCalls, 2) + assert.Equal(t, []string{"ConfigMap/database"}, client.updateCalls[0].current) + assert.Equal(t, []string{"ConfigMap/app"}, client.updateCalls[1].current) + assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status) + assert.True(t, rel.Sequenced) + assert.True(t, rel.IsSequenced()) + assert.Nil(t, rel.SequencingInfo) +} + +func TestUpgrade_Sequenced_NewResourceGroup(t *testing.T) { + client := newRecordingKubeClient() + upgrade := newSequencedUpgradeAction(t, client) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("new-group")) + seedDeployedRelease(t, upgrade, "new-group", currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), nil) + + upgradedChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + }, withName("new-group")) + + mustRunUpgrade(t, upgrade, "new-group", upgradedChart) + + require.Len(t, client.updateCalls, 3) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls)) + assert.Empty(t, client.updateCalls[1].current) + assert.Equal(t, []string{"ConfigMap/queue"}, client.updateCalls[1].created) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}, {"ConfigMap/app"}}, client.waitCalls) +} + +func TestUpgrade_Sequenced_RemovedResourceGroup(t *testing.T) { + client := newRecordingKubeClient() + upgrade := newSequencedUpgradeAction(t, client) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + }, withName("removed-group")) + seedDeployedRelease(t, upgrade, "removed-group", currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + ), nil) + + upgradedChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("removed-group")) + + mustRunUpgrade(t, upgrade, "removed-group", upgradedChart) + + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls)) + assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls) + assert.Equal(t, []string{ + "update:ConfigMap/database", + "wait:ConfigMap/database", + "update:ConfigMap/app", + "wait:ConfigMap/app", + "delete:ConfigMap/queue", + "wait-delete:ConfigMap/queue", + }, client.operations) +} + +// TestUpgrade_Sequenced_NewResourceConflictAndAdoption locks bead 9ui +// (upgrade): a sequenced upgrade must run the same existing-resource +// conflict / adoption step over newly added resources as the default path +// (performUpgrade) does. +func TestUpgrade_Sequenced_NewResourceConflictAndAdoption(t *testing.T) { + currentChartFiles := []*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + } + currentManifest := configMapManifest("database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }) + upgradedChartFiles := []*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + } + + t.Run("unowned existing resource conflicts before anything is applied", func(t *testing.T) { + client := newRecordingKubeClient() + // queue already exists in the cluster, owned by another release. + client.buildRESTClient = statefulOwnedRESTClient(client, "some-other-release", "spaced") + client.createdObjects["ConfigMap/queue"] = true + + upgrade := newSequencedUpgradeAction(t, client) + seedDeployedRelease(t, upgrade, "adopt-upgrade", buildChartWithTemplates(currentChartFiles, withName("adopt-upgrade")), currentManifest, nil) + + _, err := upgrade.Run("adopt-upgrade", buildChartWithTemplates(upgradedChartFiles, withName("adopt-upgrade")), map[string]any{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to continue with update") + assert.Empty(t, client.updateCalls, "no batch may be applied after a conflict") + }) + + t.Run("take-ownership adopts the existing resource in its batch", func(t *testing.T) { + client := newRecordingKubeClient() + client.buildRESTClient = statefulOwnedRESTClient(client, "some-other-release", "spaced") + client.createdObjects["ConfigMap/queue"] = true + + upgrade := newSequencedUpgradeAction(t, client) + upgrade.TakeOwnership = true + seedDeployedRelease(t, upgrade, "adopt-upgrade-to", buildChartWithTemplates(currentChartFiles, withName("adopt-upgrade-to")), currentManifest, nil) + + mustRunUpgrade(t, upgrade, "adopt-upgrade-to", buildChartWithTemplates(upgradedChartFiles, withName("adopt-upgrade-to"))) + + require.Len(t, client.updateCalls, 2) + assert.Equal(t, []string{"ConfigMap/queue"}, client.updateCalls[1].current, + "pre-existing queue must be adopted (updated against a current object), not treated as new") + assert.Empty(t, client.updateCalls[1].created) + }) +} + +// TestUpgrade_Sequenced_RemovedGroupsDeletedInReverseOrder locks bead 7yi +// (upgrade): resources removed by an upgrade are deleted in exact reverse +// deployment order of the OLD release's plan, with a delete-wait gating each +// batch, not as one unordered bulk delete. +func TestUpgrade_Sequenced_RemovedGroupsDeletedInReverseOrder(t *testing.T) { + client := newRecordingKubeClient() + upgrade := newSequencedUpgradeAction(t, client) + + group := func(name string, deps string) map[string]string { + ann := map[string]string{releaseutil.AnnotationResourceGroup: name} + if deps != "" { + ann[releaseutil.AnnotationDependsOnResourceGroups] = deps + } + return ann + } + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", group("database", "")), + makeConfigMapTemplate("templates/queue.yaml", "queue", group("queue", `["database"]`)), + makeConfigMapTemplate("templates/worker.yaml", "worker", group("worker", `["queue"]`)), + makeConfigMapTemplate("templates/app.yaml", "app", group("app", `["worker"]`)), + }, withName("reverse-removed")) + seedDeployedRelease(t, upgrade, "reverse-removed", currentChart, joinManifestDocs( + configMapManifest("database", group("database", "")), + configMapManifest("queue", group("queue", `["database"]`)), + configMapManifest("worker", group("worker", `["queue"]`)), + configMapManifest("app", group("app", `["worker"]`)), + ), nil) + + upgradedChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", group("database", "")), + makeConfigMapTemplate("templates/app.yaml", "app", group("app", `["database"]`)), + }, withName("reverse-removed")) + + mustRunUpgrade(t, upgrade, "reverse-removed", upgradedChart) + + // Old plan order: database, queue, worker, app. Reverse: app, worker, + // queue, database. Removed set: {queue, worker} -> worker deleted (and + // delete-waited) strictly before queue. + assert.Equal(t, [][]string{{"ConfigMap/worker"}, {"ConfigMap/queue"}}, client.deleteCalls) + assert.Equal(t, [][]string{{"ConfigMap/worker"}, {"ConfigMap/queue"}}, client.deleteWaitCalls) +} + +func TestUpgrade_NonSequencedToSequenced(t *testing.T) { + client := newRecordingKubeClient() + upgrade := newSequencedUpgradeAction(t, client) + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("transition-to-sequenced")) + seedDeployedRelease(t, upgrade, "transition-to-sequenced", currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), nil) + + rel := mustRelease(t, mustRunUpgrade(t, upgrade, "transition-to-sequenced", currentChart)) + + require.Len(t, client.updateCalls, 2) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls)) + assert.True(t, rel.Sequenced) + assert.True(t, rel.IsSequenced()) +} + +func TestUpgrade_SequencedToNonSequenced(t *testing.T) { + client := newRecordingKubeClient() + upgrade := newSequencedUpgradeAction(t, client) + upgrade.WaitStrategy = kube.StatusWatcherStrategy + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("transition-to-standard")) + seedDeployedRelease(t, upgrade, "transition-to-standard", currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}) + + rel := mustRelease(t, mustRunUpgrade(t, upgrade, "transition-to-standard", currentChart)) + + require.Len(t, client.updateCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target) + require.Len(t, client.waitCalls, 1) + assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0]) + assert.Nil(t, rel.SequencingInfo) +} + +func TestUpgrade_Sequenced_FailureRollback(t *testing.T) { + client := newRecordingKubeClient() + client.waitErrorOnCall = 2 + client.waitError = assert.AnError + + upgrade := newSequencedUpgradeAction(t, client) + upgrade.RollbackOnFailure = true + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("failure-rollback")) + seedDeployedRelease(t, upgrade, "failure-rollback", currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), nil) + + upgradedChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + }, withName("failure-rollback")) + + rel, err := upgrade.Run("failure-rollback", upgradedChart, map[string]any{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "rolled back due to rollback-on-failure") + + failed := mustRelease(t, rel) + assert.Equal(t, rcommon.StatusFailed, failed.Info.Status) + + last, getErr := upgrade.cfg.Releases.Last("failure-rollback") + require.NoError(t, getErr) + assert.Equal(t, rcommon.StatusDeployed, mustRelease(t, last).Info.Status) +} + +func TestUpgrade_Sequenced_SubchartAdded(t *testing.T) { + client := newRecordingKubeClient() + upgrade := newSequencedUpgradeAction(t, client) + + currentParent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + currentDatabase := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + }, withName("database")) + currentParent.AddDependency(currentDatabase) + currentParent.Metadata.Dependencies = []*chart.Dependency{ + {Name: "database"}, + } + seedDeployedRelease(t, upgrade, "subchart-added", currentParent, joinManifestDocs( + configMapManifest("database", nil), + configMapManifest("parent", nil), + ), nil) + + upgradedParent := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/parent.yaml", "parent", nil), + }, withName("parent")) + upgradedDatabase := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", nil), + }, withName("database")) + upgradedAPI := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/api.yaml", "api", nil), + }, withName("api")) + upgradedParent.AddDependency(upgradedDatabase, upgradedAPI) + upgradedParent.Metadata.Dependencies = []*chart.Dependency{ + {Name: "database"}, + {Name: "api", DependsOn: []string{"database"}}, + } + + mustRunUpgrade(t, upgrade, "subchart-added", upgradedParent) + + require.Len(t, client.updateCalls, 3) + assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/api"}, {"ConfigMap/parent"}}, updateTargets(client.updateCalls)) + assert.Empty(t, client.updateCalls[1].current) + assert.Equal(t, []string{"ConfigMap/api"}, client.updateCalls[1].created) +} + +func TestUpgrade_Sequenced_DryRun(t *testing.T) { + client := newRecordingKubeClient() + upgrade := newSequencedUpgradeAction(t, client) + upgrade.DryRunStrategy = DryRunClient + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("dry-run")) + seedDeployedRelease(t, upgrade, "dry-run", currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), nil) + + rel := mustRelease(t, mustRunUpgrade(t, upgrade, "dry-run", currentChart)) + + assert.Empty(t, client.updateCalls) + assert.Empty(t, client.waitCalls) + assert.Empty(t, client.deleteCalls) + assert.Equal(t, "Dry run complete", rel.Info.Description) +} + +func TestUpgrade_Sequenced_CleanupOnFail(t *testing.T) { + client := newRecordingKubeClient() + client.waitErrorOnCall = 2 + client.waitError = errors.New("timed out waiting for batch") + + upgrade := newSequencedUpgradeAction(t, client) + upgrade.CleanupOnFail = true + + currentChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + }, withName("cleanup-on-fail")) + seedDeployedRelease(t, upgrade, "cleanup-on-fail", currentChart, joinManifestDocs( + configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}), + configMapManifest("app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ), nil) + + upgradedChart := buildChartWithTemplates([]*common.File{ + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{ + releaseutil.AnnotationResourceGroup: "queue", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`, + }), + }, withName("cleanup-on-fail")) + + rel, err := upgrade.Run("cleanup-on-fail", upgradedChart, map[string]any{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out waiting for batch") + assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls) + assert.Equal(t, rcommon.StatusFailed, mustRelease(t, rel).Info.Status) +} + +func mustRunUpgrade(t *testing.T, upgrade *Upgrade, name string, ch *chart.Chart) ri.Releaser { + t.Helper() + rel, err := upgrade.Run(name, ch, map[string]any{}) + require.NoError(t, err) + return rel +} diff --git a/pkg/action/validate.go b/pkg/action/validate.go index 948005521..0d790c274 100644 --- a/pkg/action/validate.go +++ b/pkg/action/validate.go @@ -82,6 +82,13 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN return err } + if info.Client == nil { + // Fail fast: without a REST client we cannot verify whether the + // resource already exists or is owned by another release, and + // assuming "no conflict" would skip adoption/ownership checks. + return fmt.Errorf("unable to verify whether resource %s already exists: no REST client available", resourceString(info)) + } + helper := resource.NewHelper(info.Client, info.Mapping) existing, err := helper.Get(info.Namespace, info.Name) if err != nil { @@ -130,7 +137,8 @@ func verifyOwnershipBeforeDelete(resources kube.ResourceList, releaseName, relea return err } - // If client is not available, skip verification (test scenario or build failure) + // If client is not available, skip verification (test scenario or build failure). + // Deliberately unchanged by HIP-0025 Stage C: shared with the non-sequenced path; tightening it is tracked separately. if info.Client == nil { infoCopy := *info owned.Append(&infoCopy) diff --git a/pkg/action/validate_test.go b/pkg/action/validate_test.go index 3624d3676..b1a4c9036 100644 --- a/pkg/action/validate_test.go +++ b/pkg/action/validate_test.go @@ -166,6 +166,18 @@ func TestExistingResourceConflict(t *testing.T) { assert.Error(t, err) } +// Regression for PR #32038 review (validate.go nil-client thread): a missing +// REST client must fail fast rather than silently skipping the conflict and +// adoption checks. +func TestExistingResourceConflictNilClientFailsFast(t *testing.T) { + info := newMissingDeployment("clientless", "ns-a") + info.Client = nil + + _, err := existingResourceConflict(kube.ResourceList{info}, "rel-name", "rel-namespace") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no REST client available") +} + func TestCheckOwnership(t *testing.T) { deployFoo := newDeploymentResource("foo", "ns-a", "") diff --git a/pkg/action/warning_system_test.go b/pkg/action/warning_system_test.go new file mode 100644 index 000000000..918524d3d --- /dev/null +++ b/pkg/action/warning_system_test.go @@ -0,0 +1,109 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "bytes" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/kube" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +func captureWarningOutput(t *testing.T) *bytes.Buffer { + t.Helper() + + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}) + oldLogger := slog.Default() + slog.SetDefault(slog.New(handler)) + t.Cleanup(func() { slog.SetDefault(oldLogger) }) + + return &buf +} + +func runSequencedInstallWithWarnings(t *testing.T, templates ...*common.File) string { + t.Helper() + + client := newRecordingKubeClient() + install := newSequencedInstallAction(t, client) + + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}) + install.cfg.SetLogger(handler) + + mustRunInstall(t, install, buildChartWithTemplates(templates)) + + return buf.String() +} + +func TestWarning_PartialReadinessAnnotations(t *testing.T) { + output := runSequencedInstallWithWarnings(t, + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + kube.AnnotationReadinessSuccess: `["{.ready} == true"]`, + }), + ) + + assert.Contains(t, output, "sequencing: resource \\\"app\\\" has only one of") + assert.Contains(t, output, releaseutil.AnnotationReadinessSuccess) + assert.Contains(t, output, releaseutil.AnnotationReadinessFailure) +} + +func TestWarning_IsolatedResourceGroups(t *testing.T) { + output := runSequencedInstallWithWarnings(t, + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + }), + makeConfigMapTemplate("templates/cache.yaml", "cache", map[string]string{ + releaseutil.AnnotationResourceGroup: "cache", + }), + ) + + assert.Contains(t, output, "resource-group \\\"database\\\" is isolated") + assert.Contains(t, output, "resource-group \\\"cache\\\" is isolated") +} + +func TestWarning_NonExistentGroupReference(t *testing.T) { + output := runSequencedInstallWithWarnings(t, + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["missing"]`, + }), + ) + + assert.Contains(t, output, "sequencing: group \\\"app\\\" depends-on non-existent group \\\"missing\\\"") +} + +func TestWarning_WellFormedSequencing(t *testing.T) { + output := runSequencedInstallWithWarnings(t, + makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{ + releaseutil.AnnotationResourceGroup: "database", + kube.AnnotationReadinessSuccess: `["{.ready} == true"]`, + kube.AnnotationReadinessFailure: `["{.failed} == true"]`, + }), + makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `["database"]`, + }), + ) + + assert.Empty(t, output) +} diff --git a/pkg/chart/v2/dependency.go b/pkg/chart/v2/dependency.go index 5a92ef305..459baa879 100644 --- a/pkg/chart/v2/dependency.go +++ b/pkg/chart/v2/dependency.go @@ -47,6 +47,9 @@ type Dependency struct { ImportValues []any `json:"import-values,omitempty" yaml:"import-values,omitempty"` // Alias usable alias to be used for the chart Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` + // DependsOn is a list of subchart names or aliases that must be deployed + // before this subchart. + DependsOn []string `json:"depends-on,omitempty" yaml:"depends-on,omitempty"` } // Validate checks for common problems with the dependency datastructure in @@ -63,6 +66,9 @@ func (d *Dependency) Validate() error { for i := range d.Tags { d.Tags[i] = sanitizeString(d.Tags[i]) } + for i := range d.DependsOn { + d.DependsOn[i] = sanitizeString(d.DependsOn[i]) + } if d.Alias != "" && !aliasNameFormat.MatchString(d.Alias) { return ValidationErrorf("dependency %q has disallowed characters in the alias", d.Name) } diff --git a/pkg/chart/v2/dependency_json_test.go b/pkg/chart/v2/dependency_json_test.go new file mode 100644 index 000000000..8a2777739 --- /dev/null +++ b/pkg/chart/v2/dependency_json_test.go @@ -0,0 +1,64 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v2 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +func TestDependencyDependsOnJSONOmitEmpty(t *testing.T) { + dependency := Dependency{ + Name: "app", + Repository: "https://example.com/charts", + } + + data, err := json.Marshal(dependency) + require.NoError(t, err) + + assert.NotContains(t, string(data), "depends-on") +} + +func TestDependencyDependsOnJSONTag(t *testing.T) { + dependency := Dependency{ + Name: "app", + Repository: "https://example.com/charts", + DependsOn: []string{"database"}, + } + + data, err := json.Marshal(dependency) + require.NoError(t, err) + + assert.Contains(t, string(data), `"depends-on":["database"]`) +} + +func TestDependencyDependsOnYAMLBackwardCompatibility(t *testing.T) { + input := []byte(` +name: app +repository: https://example.com/charts +`) + + var dependency Dependency + err := yaml.Unmarshal(input, &dependency) + require.NoError(t, err) + + assert.Nil(t, dependency.DependsOn) +} diff --git a/pkg/chart/v2/dependency_test.go b/pkg/chart/v2/dependency_test.go index 35919bd7a..968512c68 100644 --- a/pkg/chart/v2/dependency_test.go +++ b/pkg/chart/v2/dependency_test.go @@ -42,3 +42,22 @@ func TestValidateDependency(t *testing.T) { } } } + +func TestValidateDependency_SanitizesDependsOn(t *testing.T) { + dep := &Dependency{ + Name: "example", + DependsOn: []string{"data\abase", "cache\rqueue"}, + } + if err := dep.Validate(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + // Sanitized like Name/Tags/Condition: non-printable runes are dropped and + // whitespace runes are normalized to spaces, so stray characters don't break + // subchart depends-on lookups. + if dep.DependsOn[0] != "database" { + t.Errorf("DependsOn[0] not sanitized: got %q, want %q", dep.DependsOn[0], "database") + } + if dep.DependsOn[1] != "cache queue" { + t.Errorf("DependsOn[1] not sanitized: got %q, want %q", dep.DependsOn[1], "cache queue") + } +} diff --git a/pkg/chart/v2/lint/lint.go b/pkg/chart/v2/lint/lint.go index 23a93c551..4ba4505c3 100644 --- a/pkg/chart/v2/lint/lint.go +++ b/pkg/chart/v2/lint/lint.go @@ -65,6 +65,7 @@ func RunAll(baseDir string, values map[string]any, namespace string, options ... rules.TemplateLinterSkipSchemaValidation(lo.SkipSchemaValidation)) rules.Dependencies(&result) rules.Crds(&result) + rules.Sequencing(&result, namespace, values) return result } diff --git a/pkg/chart/v2/lint/lint_test.go b/pkg/chart/v2/lint/lint_test.go index 4256281e0..765c9312b 100644 --- a/pkg/chart/v2/lint/lint_test.go +++ b/pkg/chart/v2/lint/lint_test.go @@ -17,12 +17,15 @@ limitations under the License. package lint import ( + "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/lint/support" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" ) @@ -245,3 +248,42 @@ func TestMalformedTemplate(t *testing.T) { } } } + +func TestRunAll_RegistersSequencingRules(t *testing.T) { + dir := t.TempDir() + + testChart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sequencinglint", + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + }, + Templates: []*common.File{ + { + Name: "templates/configmap.yaml", + Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: partial-readiness + annotations: + helm.sh/readiness-success: '["{.status.phase} == \"Ready\""]' +data: + key: value +`), + }, + }, + } + + assert.NoError(t, chartutil.SaveDir(testChart, dir)) + + messages := RunAll(filepath.Join(dir, testChart.Name()), nil, namespace).Messages + assert.Condition(t, func() bool { + for _, msg := range messages { + if msg.Severity == support.ErrorSev && strings.Contains(msg.Err.Error(), "both must be present or absent together") { + return true + } + } + + return false + }, "expected sequencing lint error in %#v", messages) +} diff --git a/pkg/chart/v2/lint/rules/sequencing.go b/pkg/chart/v2/lint/rules/sequencing.go new file mode 100644 index 000000000..a5f4c09bd --- /dev/null +++ b/pkg/chart/v2/lint/rules/sequencing.go @@ -0,0 +1,219 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules // import "helm.sh/helm/v4/pkg/chart/v2/lint/rules" + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "strings" + + "sigs.k8s.io/yaml" + + "helm.sh/helm/v4/pkg/chart/common" + commonutil "helm.sh/helm/v4/pkg/chart/common/util" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" + "helm.sh/helm/v4/pkg/chart/v2/loader" + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/engine" + "helm.sh/helm/v4/pkg/kube" + release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/v1/sequence" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +// Sequencing runs lint rules for HIP-0025 sequencing annotations. +func Sequencing(linter *support.Linter, namespace string, values map[string]any) { + c, err := loader.LoadDir(linter.ChartDir) + if err != nil { + return // chart load errors are reported by other lint rules + } + if err := chartutil.ProcessDependencies(c, values); err != nil { + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, err) + return + } + + // Render failures are reported by the Templates rule; manifests stays nil + // so Build still validates the top-level subchart DAG (preserving the old + // pre-render validateSubchartSequencing coverage for broken-template charts). + manifests := collectRenderedManifests(linter, c, namespace, values) + + plan, err := sequence.Build(c, manifests) + if err != nil { + // Build's fatal classes are exactly what fails at install time: + // subchart/resource-group cycles, unknown depends-on refs, malformed + // helm.sh/depends-on/subcharts, multi-group assignment. + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, err) + return + } + + for _, w := range plan.Warnings { + path := w.ChartPath + if path == "" { + path = linter.ChartDir + } + switch w.Kind { + case sequence.WarningKindResourceGroupDemotion: + // Runtime recovers by demoting; the chart author must fix these. + linter.RunLinterRule(support.ErrorSev, path, errors.New(w.Message)) + case sequence.WarningKindPartialReadiness: + // Already reported per-template (with better context) by + // validateReadinessAnnotations during collection. + default: // isolated group, undeclared/unresolved subchart + linter.RunLinterRule(support.WarningSev, path, errors.New(w.Message)) + } + } +} + +func collectRenderedManifests(linter *support.Linter, c *chart.Chart, namespace string, values map[string]any) []releaseutil.Manifest { + options := common.ReleaseOptions{ + Name: "test-release", + Namespace: namespace, + } + caps := common.DefaultCapabilities.Copy() + + coalescedValues, err := commonutil.CoalesceValues(c, values) + if err != nil { + return nil + } + + valuesToRender, err := commonutil.ToRenderValues(c, coalescedValues, options, caps) + if err != nil { + return nil + } + + var renderEngine engine.Engine + renderEngine.LintMode = true + + renderedContentMap, err := renderEngine.RenderWithContext(context.Background(), c, valuesToRender) + if err != nil { + // Template rendering errors are already reported by the Templates lint rule. + return nil + } + + var manifests []releaseutil.Manifest + for _, templatePath := range slices.Sorted(maps.Keys(renderedContentMap)) { + content := renderedContentMap[templatePath] + if strings.TrimSpace(content) == "" { + continue + } + + for _, manifest := range parseRenderedManifests(templatePath, content) { + // HIP-0025 explicitly excludes hooks from sequencing: at install + // time SortManifests routes hook resources out before resource-group + // parsing runs, so their sequencing annotations are ignored. Mirror + // that here — otherwise lint reads a hook's leftover + // resource-group/depends-on annotations into the DAG and can report + // spurious cycles or orphan errors that never occur at runtime. + if isHookManifest(manifest) { + continue + } + validateReadinessAnnotations(linter, templatePath, manifest) + manifests = append(manifests, manifest) + } + } + + return manifests +} + +// isHookManifest reports whether a rendered manifest is a Helm hook. Hooks +// carry the helm.sh/hook annotation and are excluded from HIP-0025 sequencing +// (the install path separates them via SortManifests), so the sequencing lint +// rules must skip them too. +func isHookManifest(manifest releaseutil.Manifest) bool { + if manifest.Head == nil || manifest.Head.Metadata == nil { + return false + } + return strings.TrimSpace(manifest.Head.Metadata.Annotations[release.HookAnnotation]) != "" +} + +func parseRenderedManifests(templatePath, content string) []releaseutil.Manifest { + rawManifests := releaseutil.SplitManifests(content) + manifests := make([]releaseutil.Manifest, 0, len(rawManifests)) + + for _, manifestName := range slices.Sorted(maps.Keys(rawManifests)) { + raw := rawManifests[manifestName] + if strings.TrimSpace(raw) == "" { + continue + } + + var head releaseutil.SimpleHead + if err := yaml.Unmarshal([]byte(raw), &head); err != nil { + continue + } + + manifests = append(manifests, releaseutil.Manifest{ + Name: templatePath, + Content: raw, + Head: &head, + }) + } + + return manifests +} + +func validateReadinessAnnotations(linter *support.Linter, templatePath string, manifest releaseutil.Manifest) { + if manifest.Head == nil || manifest.Head.Metadata == nil { + return + } + + annotations := manifest.Head.Metadata.Annotations + successRaw := strings.TrimSpace(annotations[kube.AnnotationReadinessSuccess]) + failureRaw := strings.TrimSpace(annotations[kube.AnnotationReadinessFailure]) + hasSuccess := successRaw != "" + hasFailure := failureRaw != "" + + if hasSuccess != hasFailure { + linter.RunLinterRule(support.ErrorSev, templatePath, fmt.Errorf( + "resource %q has only one of %q / %q annotations; both must be present or absent together", + resourceDisplayName(manifest), + kube.AnnotationReadinessSuccess, + kube.AnnotationReadinessFailure, + )) + return + } + + // Both present (or both absent). When present, the JSONPath expressions + // must still be well-formed — the presence-symmetry check alone let + // malformed expressions through at lint time. + for key, raw := range map[string]string{ + kube.AnnotationReadinessSuccess: successRaw, + kube.AnnotationReadinessFailure: failureRaw, + } { + if err := kube.ValidateReadinessExpressions(raw); err != nil { + linter.RunLinterRule(support.ErrorSev, templatePath, fmt.Errorf( + "resource %q has malformed %q annotation: %w", + resourceDisplayName(manifest), key, err, + )) + } + } +} + +func resourceDisplayName(manifest releaseutil.Manifest) string { + if manifest.Head == nil || manifest.Head.Metadata == nil { + return manifest.Name + } + + if manifest.Head.Kind == "" { + return manifest.Head.Metadata.Name + } + + return fmt.Sprintf("%s/%s", manifest.Head.Kind, manifest.Head.Metadata.Name) +} diff --git a/pkg/chart/v2/lint/rules/sequencing_test.go b/pkg/chart/v2/lint/rules/sequencing_test.go new file mode 100644 index 000000000..62c9f5f03 --- /dev/null +++ b/pkg/chart/v2/lint/rules/sequencing_test.go @@ -0,0 +1,449 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +func TestSequencing_SubchartCircularDependency(t *testing.T) { + t.Parallel() + + root := newChart("testchart", nil) + root.Metadata.Dependencies = []*chart.Dependency{ + {Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", DependsOn: []string{"subchart-b"}}, + {Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}}, + } + root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil)) + + messages := runSequencingLint(t, root) + requireMessage(t, messages, support.ErrorSev, "subchart circular dependency detected") +} + +func TestSequencing_AliasedSubchartDependsOnOriginalName(t *testing.T) { + t.Parallel() + + // Full loader + ProcessDependencies pipeline: an aliased subchart may be + // referenced by its original chart name (HIP-0025: "names or aliases"). + // Before the fix this reported a spurious "unknown or disabled subchart". + root := newChart("testchart", nil) + root.Metadata.Dependencies = []*chart.Dependency{ + {Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "aliased-a"}, + {Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}}, + } + root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil)) + + messages := runSequencingLint(t, root) + assert.Empty(t, messages) +} + +func TestSequencing_AmbiguousDependsOnReported(t *testing.T) { + t.Parallel() + + // Ambiguity is now rejected by ProcessDependencies; the lint rule must + // surface that instead of silently skipping sequencing validation. + root := newChart("testchart", nil) + root.Metadata.Dependencies = []*chart.Dependency{ + {Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "first"}, + {Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "second"}, + {Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}}, + } + root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil)) + + messages := runSequencingLint(t, root) + requireMessage(t, messages, support.ErrorSev, `ambiguous subchart reference "subchart-a"`) +} + +func TestSequencing_SubchartAnnotationRequiresHIPListSyntax(t *testing.T) { + t.Parallel() + + root := newChart("testchart", nil) + root.Metadata.Dependencies = []*chart.Dependency{ + {Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a"}, + {Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b"}, + } + root.Metadata.Annotations = map[string]string{ + chartutil.AnnotationDependsOnSubcharts: `{"subchart-a":["subchart-b"],"subchart-b":["subchart-a"]}`, + } + root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil)) + + messages := runSequencingLint(t, root) + requireMessage(t, messages, support.ErrorSev, "JSON string array") +} + +func TestSequencing_SubchartAnnotationOrphanWithNoDependencies(t *testing.T) { + t.Parallel() + + // A chart with ZERO dependencies can still carry a + // helm.sh/depends-on/subcharts annotation referencing a subchart that + // doesn't exist. The lint rule previously early-returned on + // len(Dependencies)==0 and missed this orphan reference entirely. + root := newChart("testchart", nil) + root.Metadata.Annotations = map[string]string{ + chartutil.AnnotationDependsOnSubcharts: `["does-not-exist"]`, + } + + messages := runSequencingLint(t, root) + requireMessage(t, messages, support.ErrorSev, "unknown or disabled subchart") +} + +func TestSequencing_NestedSubchartCircularDependency(t *testing.T) { + t.Parallel() + + // The CHILD's Chart.yaml declares a depends-on cycle between its two + // grandchildren. HEAD's lint only validates the ROOT's subchart DAG, so + // this chart lints clean but fails fatally at install (bead lkx). + child := newChart("child", map[string]string{ + "templates/cm.yaml": manifestYAML("ConfigMap", "child-cm", nil), + }) + child.Metadata.Dependencies = []*chart.Dependency{ + {Name: "grandchild-a", Version: "0.1.0", Repository: "file://charts/grandchild-a", DependsOn: []string{"grandchild-b"}}, + {Name: "grandchild-b", Version: "0.1.0", Repository: "file://charts/grandchild-b", DependsOn: []string{"grandchild-a"}}, + } + child.SetDependencies(newChart("grandchild-a", nil), newChart("grandchild-b", nil)) + + root := newChart("testchart", nil) + root.Metadata.Dependencies = []*chart.Dependency{ + {Name: "child", Version: "0.1.0", Repository: "file://charts/child"}, + } + root.SetDependencies(child) + + messages := runSequencingLint(t, root) + requireMessage(t, messages, support.ErrorSev, "subchart circular dependency detected") + requireMessage(t, messages, support.ErrorSev, "testchart/charts/child") +} + +func TestSequencing_NestedSubchartUnknownDependsOnRef(t *testing.T) { + t.Parallel() + + child := newChart("child", map[string]string{ + "templates/cm.yaml": manifestYAML("ConfigMap", "child-cm", nil), + }) + child.Metadata.Dependencies = []*chart.Dependency{ + {Name: "grandchild-a", Version: "0.1.0", Repository: "file://charts/grandchild-a", DependsOn: []string{"missing"}}, + } + child.SetDependencies(newChart("grandchild-a", nil)) + + root := newChart("testchart", nil) + root.Metadata.Dependencies = []*chart.Dependency{ + {Name: "child", Version: "0.1.0", Repository: "file://charts/child"}, + } + root.SetDependencies(child) + + messages := runSequencingLint(t, root) + requireMessage(t, messages, support.ErrorSev, `depends-on unknown or disabled subchart "missing"`) + requireMessage(t, messages, support.ErrorSev, "testchart/charts/child") +} + +func TestSequencing_NestedResourceGroupCircularDependency(t *testing.T) { + t.Parallel() + + child := newChart("child", map[string]string{ + "templates/a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{ + releaseutil.AnnotationResourceGroup: "a", + releaseutil.AnnotationDependsOnResourceGroups: `'["b"]'`, + }), + "templates/b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{ + releaseutil.AnnotationResourceGroup: "b", + releaseutil.AnnotationDependsOnResourceGroups: `'["a"]'`, + }), + }) + root := newChart("testchart", nil) + root.Metadata.Dependencies = []*chart.Dependency{ + {Name: "child", Version: "0.1.0", Repository: "file://charts/child"}, + } + root.SetDependencies(child) + + messages := runSequencingLint(t, root) + requireMessage(t, messages, support.ErrorSev, "resource-group circular dependency detected") + requireMessage(t, messages, support.ErrorSev, "testchart/charts/child") +} + +func TestSequencing_IsolatedGroupWarns(t *testing.T) { + t.Parallel() + + // Two groups, no depends-on edges between them: runtime demotes both to + // the unsequenced batch with a warning. Lint surfaces that demotion as a + // WARNING (not an error — the chart still deploys). + messages := runSequencingLint(t, newChart("testchart", map[string]string{ + "templates/a.yaml": manifestYAML("ConfigMap", "cm-a", map[string]string{releaseutil.AnnotationResourceGroup: "a"}), + "templates/b.yaml": manifestYAML("ConfigMap", "cm-b", map[string]string{releaseutil.AnnotationResourceGroup: "b"}), + })) + requireMessage(t, messages, support.WarningSev, "isolated") +} + +func TestSequencing_UndeclaredSubchartWarns(t *testing.T) { + t.Parallel() + + // Vendored subchart present in charts/ but absent from Chart.yaml + // dependencies: runtime deploys it after declared subcharts with a + // warning. Lint mirrors that as a WARNING. + sub := newChart("vendored", map[string]string{ + "templates/cm.yaml": manifestYAML("ConfigMap", "vendored-cm", nil), + }) + root := newChart("testchart", nil) + root.SetDependencies(sub) // deliberately NOT in root.Metadata.Dependencies + + messages := runSequencingLint(t, root) + requireMessage(t, messages, support.WarningSev, "not declared in Chart.yaml") +} + +func TestSequencing_RenderedAnnotationRules(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + templates map[string]string + wantSeverity int + wantSubstring string + }{ + { + name: "partial readiness annotations", + templates: map[string]string{ + "templates/configmap.yaml": manifestYAML("ConfigMap", "partial-readiness", map[string]string{ + releaseutil.AnnotationResourceGroup: "bootstrap", + "helm.sh/readiness-success": `'["{.phase} == \"Ready\""]'`, + }), + }, + wantSeverity: support.ErrorSev, + wantSubstring: "both must be present or absent together", + }, + { + name: "duplicate group assignment", + templates: map[string]string{ + "templates/first.yaml": manifestYAML("ConfigMap", "shared-resource", map[string]string{releaseutil.AnnotationResourceGroup: "bootstrap"}), + "templates/second.yaml": manifestYAML("ConfigMap", "shared-resource", map[string]string{releaseutil.AnnotationResourceGroup: "app"}), + }, + wantSeverity: support.ErrorSev, + wantSubstring: "assigned to multiple resource groups", + }, + { + name: "circular resource-group dependencies", + templates: map[string]string{ + "templates/group-a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{ + releaseutil.AnnotationResourceGroup: "a", + releaseutil.AnnotationDependsOnResourceGroups: `'["c"]'`, + }), + "templates/group-b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{ + releaseutil.AnnotationResourceGroup: "b", + releaseutil.AnnotationDependsOnResourceGroups: `'["a"]'`, + }), + "templates/group-c.yaml": manifestYAML("ConfigMap", "group-c", map[string]string{ + releaseutil.AnnotationResourceGroup: "c", + releaseutil.AnnotationDependsOnResourceGroups: `'["b"]'`, + }), + }, + wantSeverity: support.ErrorSev, + wantSubstring: "resource-group circular dependency detected", + }, + { + name: "orphan resource-group reference", + templates: map[string]string{ + "templates/configmap.yaml": manifestYAML("ConfigMap", "orphaned-group", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `'["database"]'`, + }), + }, + wantSeverity: support.ErrorSev, + wantSubstring: `depends-on non-existent group "database"`, + }, + { + name: "malformed depends-on-resource-groups annotation", + templates: map[string]string{ + "templates/configmap.yaml": manifestYAML("ConfigMap", "bad-json", map[string]string{ + releaseutil.AnnotationResourceGroup: "app", + releaseutil.AnnotationDependsOnResourceGroups: `not-a-json-array`, + }), + }, + wantSeverity: support.ErrorSev, + wantSubstring: "depends-on", + }, + { + // Both readiness annotations present (so the presence-symmetry + // check passes), but the JSONPath in readiness-success is + // malformed. Presence symmetry alone let this through; the rule + // must validate the expressions compile. + name: "malformed readiness JSONPath with both annotations present", + templates: map[string]string{ + "templates/job.yaml": manifestYAML("Job", "bad-jsonpath", map[string]string{ + "helm.sh/readiness-success": `'["{.status.succeeded >= 1"]'`, + "helm.sh/readiness-failure": `'["{.status.failed} >= 1"]'`, + }), + }, + wantSeverity: support.ErrorSev, + wantSubstring: "malformed", + }, + { + // The comparison value's type IS statically known: an ordering + // operator with a non-numeric literal can never evaluate at + // runtime, so lint flags it as a definite authoring error. + name: "ordering operator with non-numeric readiness value", + templates: map[string]string{ + "templates/job.yaml": manifestYAML("Job", "string-ordering", map[string]string{ + "helm.sh/readiness-success": `'["{.status.phase} > \"Running\""]'`, + "helm.sh/readiness-failure": `'["{.status.failed} >= 1"]'`, + }), + }, + wantSeverity: support.ErrorSev, + wantSubstring: "requires a numeric comparison value", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + messages := runSequencingLint(t, newChart("testchart", tc.templates)) + requireMessage(t, messages, tc.wantSeverity, tc.wantSubstring) + }) + } +} + +func TestSequencing_HookResourcesExcludedFromResourceGroupDAG(t *testing.T) { + t.Parallel() + + // A hook resource carrying sequencing annotations that WOULD form a cycle + // with the non-hook resources must be ignored by the sequencing lint rules, + // exactly as the install path ignores it (SortManifests routes hooks out + // before resource-group parsing). group-b depends on group-a; the hook + // claims group-a depends-on group-b — a cycle only if the hook is counted. + templates := map[string]string{ + "templates/group-a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{ + releaseutil.AnnotationResourceGroup: "group-a", + }), + "templates/group-b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{ + releaseutil.AnnotationResourceGroup: "group-b", + releaseutil.AnnotationDependsOnResourceGroups: `'["group-a"]'`, + }), + "templates/hook-job.yaml": manifestYAML("Job", "hook-job", map[string]string{ + "helm.sh/hook": "pre-install", + releaseutil.AnnotationResourceGroup: "group-a", + releaseutil.AnnotationDependsOnResourceGroups: `'["group-b"]'`, + }), + } + + messages := runSequencingLint(t, newChart("hook-chart", templates)) + + for _, m := range messages { + assert.NotContains(t, m.Err.Error(), "circular", + "hook resource must be excluded from the resource-group DAG; got: %v", m.Err) + } +} + +func TestSequencing_NoMessagesWithoutSequencingAnnotations(t *testing.T) { + t.Parallel() + + messages := runSequencingLint(t, newChart("plain-chart", map[string]string{ + "templates/configmap.yaml": manifestYAML("ConfigMap", "plain", nil), + })) + + assert.Empty(t, messages) +} + +func runSequencingLint(t *testing.T, c *chart.Chart) []support.Message { + t.Helper() + + tmpDir := t.TempDir() + require.NoError(t, chartutil.SaveDir(c, tmpDir)) + + linter := support.Linter{ChartDir: filepath.Join(tmpDir, c.Name())} + Sequencing(&linter, "test-namespace", nil) + + return linter.Messages +} + +func requireMessage(t *testing.T, messages []support.Message, severity int, substring string) { + t.Helper() + + for _, message := range messages { + if message.Severity == severity && strings.Contains(message.Err.Error(), substring) { + return + } + } + + t.Fatalf("expected severity %d message containing %q, got %#v", severity, substring, messages) +} + +func newChart(name string, templates map[string]string) *chart.Chart { + files := make([]*common.File, 0, len(templates)) + names := make([]string, 0, len(templates)) + for templateName := range templates { + names = append(names, templateName) + } + sort.Strings(names) + + for _, templateName := range names { + files = append(files, &common.File{ + Name: templateName, + Data: []byte(templates[templateName]), + }) + } + + return &chart.Chart{ + Metadata: &chart.Metadata{ + Name: name, + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + }, + Templates: files, + } +} + +func manifestYAML(kind, name string, annotations map[string]string) string { + if len(annotations) == 0 { + return fmt.Sprintf(`apiVersion: v1 +kind: %s +metadata: + name: %s +data: + key: value +`, kind, name) + } + + keys := make([]string, 0, len(annotations)) + for key := range annotations { + keys = append(keys, key) + } + sort.Strings(keys) + + var annotationBlock strings.Builder + for _, key := range keys { + fmt.Fprintf(&annotationBlock, "\n %s: %s", key, annotations[key]) + } + + return fmt.Sprintf(`apiVersion: v1 +kind: %s +metadata: + name: %s + annotations:%s +data: + key: value +`, kind, name, annotationBlock.String()) +} diff --git a/pkg/chart/v2/util/dag.go b/pkg/chart/v2/util/dag.go new file mode 100644 index 000000000..7bfe5d07d --- /dev/null +++ b/pkg/chart/v2/util/dag.go @@ -0,0 +1,155 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "maps" + "sort" + "strings" +) + +// DAG is a directed acyclic graph of string-keyed nodes, used for resource-group +// and subchart dependency ordering. +// +// Edges are directed: AddEdge("a", "b") means "b depends on a" (a must come before b). +type DAG struct { + nodes map[string]struct{} + edges map[string][]string // from -> []to (dependents) + edgeSet map[string]struct{} // "from\x00to" -> exists (dedup guard) + inDegree map[string]int // node -> number of prerequisites +} + +// NewDAG creates an empty DAG. +func NewDAG() *DAG { + return &DAG{ + nodes: make(map[string]struct{}), + edges: make(map[string][]string), + edgeSet: make(map[string]struct{}), + inDegree: make(map[string]int), + } +} + +// AddNode registers a node in the DAG. Duplicate adds are idempotent. +func (d *DAG) AddNode(name string) { + if _, ok := d.nodes[name]; ok { + return + } + + d.nodes[name] = struct{}{} + d.edges[name] = nil + d.inDegree[name] = 0 +} + +// AddEdge adds a directed edge: "to" depends on "from" (from is deployed before to). +// Returns an error if either node is unknown or if a self-loop is requested. +func (d *DAG) AddEdge(from, to string) error { + if from == to { + return fmt.Errorf("self-loop not allowed: %q", from) + } + if _, ok := d.nodes[from]; !ok { + return fmt.Errorf("unknown node %q", from) + } + if _, ok := d.nodes[to]; !ok { + return fmt.Errorf("unknown node %q", to) + } + + key := from + "\x00" + to + if _, exists := d.edgeSet[key]; exists { + return nil + } + + d.edgeSet[key] = struct{}{} + d.edges[from] = append(d.edges[from], to) + d.inDegree[to]++ + + return nil +} + +// GetBatches performs a topological sort using Kahn's algorithm and returns +// the nodes grouped into deployment batches. Each batch contains nodes that +// can be deployed in parallel. Batches are ordered: batch 0 has no prerequisites, +// batch 1 depends only on batch 0, etc. +// +// Returns an error if a cycle is detected, including the names of the nodes +// involved in the cycle. +func (d *DAG) GetBatches() ([][]string, error) { + if len(d.nodes) == 0 { + return nil, nil + } + + inDegree := make(map[string]int, len(d.inDegree)) + maps.Copy(inDegree, d.inDegree) + + var batches [][]string + processed := 0 + + for { + var batch []string + for node := range d.nodes { + if inDegree[node] == 0 { + batch = append(batch, node) + inDegree[node] = -1 + } + } + + if len(batch) == 0 { + break + } + + sort.Strings(batch) + batches = append(batches, batch) + processed += len(batch) + + for _, node := range batch { + for _, dependent := range d.edges[node] { + inDegree[dependent]-- + } + } + } + + if processed == len(d.nodes) { + return batches, nil + } + + cycleNodes := make([]string, 0, len(d.nodes)-processed) + for node := range d.nodes { + if inDegree[node] > 0 { + cycleNodes = append(cycleNodes, node) + } + } + sort.Strings(cycleNodes) + + return nil, fmt.Errorf("cycle detected among nodes: %s", strings.Join(cycleNodes, ", ")) +} + +// Nodes returns a sorted slice of all node names in the DAG. +func (d *DAG) Nodes() []string { + nodes := make([]string, 0, len(d.nodes)) + for node := range d.nodes { + nodes = append(nodes, node) + } + sort.Strings(nodes) + + return nodes +} + +// HasNode reports whether the DAG contains a node with the given name. +func (d *DAG) HasNode(name string) bool { + _, ok := d.nodes[name] + return ok +} diff --git a/pkg/chart/v2/util/dag_test.go b/pkg/chart/v2/util/dag_test.go new file mode 100644 index 000000000..a029abea2 --- /dev/null +++ b/pkg/chart/v2/util/dag_test.go @@ -0,0 +1,232 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDAG_BatchOrdering(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + nodes []string + edges [][2]string + expected [][]string + }{ + { + name: "empty DAG", + expected: nil, + }, + { + name: "single node", + nodes: []string{"a"}, + expected: [][]string{{"a"}}, + }, + { + name: "linear chain", + nodes: []string{"a", "b", "c"}, + edges: [][2]string{{"a", "b"}, {"b", "c"}}, + expected: [][]string{ + {"a"}, + {"b"}, + {"c"}, + }, + }, + { + name: "diamond", + nodes: []string{"a", "b", "c", "d"}, + edges: [][2]string{{"a", "b"}, {"a", "c"}, {"b", "d"}, {"c", "d"}}, + expected: [][]string{ + {"a"}, + {"b", "c"}, + {"d"}, + }, + }, + { + name: "multiple roots", + nodes: []string{"a", "b", "c"}, + edges: [][2]string{{"a", "c"}, {"b", "c"}}, + expected: [][]string{ + {"a", "b"}, + {"c"}, + }, + }, + { + name: "duplicate edge is idempotent", + nodes: []string{"a", "b"}, + edges: [][2]string{{"a", "b"}, {"a", "b"}}, + expected: [][]string{ + {"a"}, + {"b"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dag := buildDAG(t, tt.nodes, tt.edges) + + batches, err := dag.GetBatches() + require.NoError(t, err) + assert.Equal(t, tt.expected, batches) + }) + } +} + +func TestDAG_CycleDetection(t *testing.T) { + t.Parallel() + + dag := buildDAG(t, []string{"a", "b", "c"}, [][2]string{{"a", "b"}, {"b", "c"}, {"c", "a"}}) + + batches, err := dag.GetBatches() + require.Error(t, err) + assert.Nil(t, batches) + assert.ErrorContains(t, err, "cycle") + assert.ErrorContains(t, err, "a") + assert.ErrorContains(t, err, "b") + assert.ErrorContains(t, err, "c") +} + +func TestDAG_SelfLoop(t *testing.T) { + t.Parallel() + + dag := NewDAG() + dag.AddNode("a") + + err := dag.AddEdge("a", "a") + require.Error(t, err) + assert.ErrorContains(t, err, "self-loop") +} + +func TestDAG_UnknownNode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + from string + to string + match string + }{ + { + name: "missing source node", + from: "missing", + to: "a", + match: "unknown node", + }, + { + name: "missing destination node", + from: "a", + to: "missing", + match: "unknown node", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dag := NewDAG() + dag.AddNode("a") + + err := dag.AddEdge(tt.from, tt.to) + require.Error(t, err) + assert.ErrorContains(t, err, tt.match) + }) + } +} + +func TestDAG_DisconnectedComponents(t *testing.T) { + t.Parallel() + + dag := buildDAG(t, + []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta"}, + [][2]string{{"alpha", "gamma"}, {"beta", "delta"}, {"delta", "epsilon"}}, + ) + + batches, err := dag.GetBatches() + require.NoError(t, err) + assert.Equal(t, [][]string{ + {"alpha", "beta", "zeta"}, + {"delta", "gamma"}, + {"epsilon"}, + }, batches) +} + +func TestDAG_LargeGraph(t *testing.T) { + t.Parallel() + + const nodeCount = 128 + + nodes := make([]string, 0, nodeCount) + edges := make([][2]string, 0, nodeCount-1) + for i := range nodeCount { + nodes = append(nodes, fmt.Sprintf("node-%03d", i)) + if i == 0 { + continue + } + edges = append(edges, [2]string{ + fmt.Sprintf("node-%03d", i-1), + fmt.Sprintf("node-%03d", i), + }) + } + + dag := buildDAG(t, nodes, edges) + + batches, err := dag.GetBatches() + require.NoError(t, err) + require.Len(t, batches, nodeCount) + + for i, batch := range batches { + assert.Equal(t, []string{fmt.Sprintf("node-%03d", i)}, batch) + } +} + +func TestDAG_NodeHelpers(t *testing.T) { + t.Parallel() + + dag := NewDAG() + dag.AddNode("charlie") + dag.AddNode("alpha") + dag.AddNode("bravo") + + assert.True(t, dag.HasNode("alpha")) + assert.False(t, dag.HasNode("delta")) + assert.Equal(t, []string{"alpha", "bravo", "charlie"}, dag.Nodes()) +} + +func buildDAG(t *testing.T, nodes []string, edges [][2]string) *DAG { + t.Helper() + + dag := NewDAG() + for _, node := range nodes { + dag.AddNode(node) + } + + for _, edge := range edges { + require.NoError(t, dag.AddEdge(edge[0], edge[1])) + } + + return dag +} diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index f28a4f4b1..5921a2dbd 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -148,6 +148,13 @@ func processDependencyEnabled(c *chart.Chart, v map[string]any, path string) err return nil } + // Resolve depends-on references while dependency entries still carry + // their original chart names; the alias rewrite below (req.Name = + // req.Alias) makes those names unrecoverable afterwards. + if err := resolveDependsOnReferences(c); err != nil { + return err + } + var chartDependencies []*chart.Chart // If any dependency is not a part of Chart.yaml // then this should be added to chartDependencies. diff --git a/pkg/chart/v2/util/subchart_dag.go b/pkg/chart/v2/util/subchart_dag.go new file mode 100644 index 000000000..697e6ca33 --- /dev/null +++ b/pkg/chart/v2/util/subchart_dag.go @@ -0,0 +1,264 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "encoding/json" + "fmt" + "strings" + + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +const ( + // AnnotationDependsOnSubcharts is the Chart.yaml annotation key for declaring + // parent-chart dependencies on subcharts. The value is a JSON string array of + // subchart names (or aliases) that must be ready before parent resources are + // installed. + AnnotationDependsOnSubcharts = "helm.sh/depends-on/subcharts" +) + +// BuildSubchartDAG constructs a DAG from a chart's subchart dependency declarations. +// +// Subchart-to-subchart ordering is read from the depends-on field on Chart.yaml +// dependency entries. The helm.sh/depends-on/subcharts metadata annotation is +// validated as the parent chart's dependencies on subcharts; parent resources +// are deployed after subchart batches by the action layer. +// +// Subcharts are keyed by their effective name (alias if set, otherwise name). +// ProcessDependencies must have been called on the chart first: it prunes +// disabled subcharts, applies alias renames, and rewrites depends-on +// references — dependency depends-on entries and the +// helm.sh/depends-on/subcharts annotation — to effective names (see +// resolveDependsOnReferences). BuildSubchartDAG therefore resolves references +// against effective names only. This holds for charts decoded from release +// storage too: they were processed before being stored, so their persisted +// depends-on references already use effective names. +func BuildSubchartDAG(c *chart.Chart) (*DAG, error) { + dag := NewDAG() + + if c == nil || c.Metadata == nil { + return dag, nil + } + + // Build the set of subcharts that survived ProcessDependencies. + loaded := make(map[string]bool, len(c.Dependencies())) + for _, sub := range c.Dependencies() { + loaded[sub.Name()] = true + } + + // Charts decoded from release storage have no loaded dependency tree: + // Chart.dependencies is unexported, so the release codec (json.Marshal in + // pkg/storage/driver) drops it. Metadata.Dependencies survives, and per + // the ProcessDependencies precondition above it was already pruned to the + // enabled set with aliases and depends-on references rewritten. Enabled + // is only ever set true by ProcessDependencies, so it marks entries that + // were deployable at install time; freshly parsed Chart.yaml entries + // default to Enabled=false and stay excluded here. + trustMetadata := len(c.Dependencies()) == 0 + + // Each loaded subchart becomes a DAG node keyed by its effective name. + nodes := make(map[string]bool, len(c.Metadata.Dependencies)) + for _, dep := range c.Metadata.Dependencies { + if dep == nil { + continue + } + eff := effectiveDependencyName(dep) + trustedFromMetadata := trustMetadata && dep.Enabled + if (!loaded[eff] && !trustedFromMetadata) || nodes[eff] { + continue + } + nodes[eff] = true + dag.AddNode(eff) + } + + for _, dep := range c.Metadata.Dependencies { + if dep == nil { + continue + } + eff := effectiveDependencyName(dep) + if !nodes[eff] { + continue + } + + for _, prerequisite := range dep.DependsOn { + if !nodes[prerequisite] { + return nil, fmt.Errorf("subchart %q depends-on unknown or disabled subchart %q", eff, prerequisite) + } + if err := dag.AddEdge(prerequisite, eff); err != nil { + return nil, fmt.Errorf("adding sequencing edge %s→%s: %w", prerequisite, eff, err) + } + } + } + + if err := validateParentSubchartDependencies(c.Metadata.Annotations[AnnotationDependsOnSubcharts], nodes); err != nil { + return nil, err + } + + return dag, nil +} + +// resolveDependsOnReferences rewrites depends-on references — Chart.yaml +// dependency depends-on entries and the helm.sh/depends-on/subcharts +// annotation — from a subchart's original chart name to its effective name +// (alias if set, otherwise name). It must run while original names are still +// present in c.Metadata.Dependencies, i.e. before processDependencyEnabled +// applies its Name = Alias rewrite; that rewrite makes original names +// unrecoverable. The rewritten references are what release storage persists +// and what BuildSubchartDAG consumes, including at uninstall time when the +// chart is decoded from the release record. +// +// A reference matching more than one subchart (e.g. the original name of a +// chart pulled in under two aliases) is rejected as ambiguous. References +// matching no dependency are left unchanged so BuildSubchartDAG can report +// them as unknown or disabled, and a malformed annotation is left for +// BuildSubchartDAG's JSON parse error. Resolution is idempotent: effective +// names resolve to themselves, so reprocessing an already-processed chart is +// a no-op. +func resolveDependsOnReferences(c *chart.Chart) error { + refs := newSubchartRefs() + for _, dep := range c.Metadata.Dependencies { + if dep == nil { + continue + } + eff := effectiveDependencyName(dep) + refs.register(eff, eff) + refs.register(dep.Name, eff) + } + + for _, dep := range c.Metadata.Dependencies { + if dep == nil { + continue + } + for i, ref := range dep.DependsOn { + eff, found, isAmbiguous := refs.resolve(ref) + if isAmbiguous { + return fmt.Errorf("subchart %q depends-on ambiguous subchart reference %q; reference it by alias to disambiguate", effectiveDependencyName(dep), ref) + } + if found { + dep.DependsOn[i] = eff + } + } + } + + return resolveAnnotationDependsOn(c, refs) +} + +// resolveAnnotationDependsOn rewrites the parent chart's +// helm.sh/depends-on/subcharts annotation entries to effective subchart names. +func resolveAnnotationDependsOn(c *chart.Chart, refs *subchartRefs) error { + annotation := strings.TrimSpace(c.Metadata.Annotations[AnnotationDependsOnSubcharts]) + if annotation == "" { + return nil + } + + var prerequisites []string + if err := json.Unmarshal([]byte(annotation), &prerequisites); err != nil { + // Malformed JSON is reported by BuildSubchartDAG with full context. + return nil + } + + changed := false + for i, ref := range prerequisites { + eff, found, isAmbiguous := refs.resolve(ref) + if isAmbiguous { + return fmt.Errorf("annotation %s references ambiguous subchart %q; reference it by alias to disambiguate", AnnotationDependsOnSubcharts, ref) + } + if found && eff != ref { + prerequisites[i] = eff + changed = true + } + } + if !changed { + return nil + } + + encoded, err := json.Marshal(prerequisites) + if err != nil { + return fmt.Errorf("re-encoding %s annotation: %w", AnnotationDependsOnSubcharts, err) + } + c.Metadata.Annotations[AnnotationDependsOnSubcharts] = string(encoded) + return nil +} + +// subchartRefs resolves depends-on references to effective subchart names +// while processing dependencies, i.e. while original chart names are still +// present. A reference may be a subchart's effective name (alias if set, +// otherwise name) or its original chart name. A reference that maps to more +// than one subchart is recorded as ambiguous and rejected on use, rather than +// being silently resolved to one of them. +type subchartRefs struct { + byRef map[string]string + ambiguous map[string]bool +} + +func newSubchartRefs() *subchartRefs { + return &subchartRefs{ + byRef: make(map[string]string), + ambiguous: make(map[string]bool), + } +} + +// register maps ref to the effective subchart name eff. If ref already resolves +// to a different subchart, it is flagged ambiguous. +func (s *subchartRefs) register(ref, eff string) { + if ref == "" { + return + } + if existing, ok := s.byRef[ref]; ok && existing != eff { + s.ambiguous[ref] = true + return + } + s.byRef[ref] = eff +} + +// resolve returns the effective subchart name for ref. found is false for an +// unknown reference; isAmbiguous is true when ref maps to multiple subcharts. +func (s *subchartRefs) resolve(ref string) (eff string, found, isAmbiguous bool) { + if s.ambiguous[ref] { + return "", false, true + } + eff, found = s.byRef[ref] + return eff, found, false +} + +func validateParentSubchartDependencies(annotation string, nodes map[string]bool) error { + annotation = strings.TrimSpace(annotation) + if annotation == "" { + return nil + } + + var prerequisites []string + if err := json.Unmarshal([]byte(annotation), &prerequisites); err != nil { + return fmt.Errorf("parsing %s annotation as JSON string array: %w", AnnotationDependsOnSubcharts, err) + } + + for _, prerequisite := range prerequisites { + if !nodes[prerequisite] { + return fmt.Errorf("annotation %s references unknown or disabled subchart %q", AnnotationDependsOnSubcharts, prerequisite) + } + } + + return nil +} + +func effectiveDependencyName(dep *chart.Dependency) string { + if dep.Alias != "" { + return dep.Alias + } + return dep.Name +} diff --git a/pkg/chart/v2/util/subchart_dag_test.go b/pkg/chart/v2/util/subchart_dag_test.go new file mode 100644 index 000000000..a3f85739a --- /dev/null +++ b/pkg/chart/v2/util/subchart_dag_test.go @@ -0,0 +1,554 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +func TestBuildSubchartDAG_Empty(t *testing.T) { + t.Parallel() + + batches := batchesForChart(t, newChart("parent")) + assert.Empty(t, batches) +} + +func TestBuildSubchartDAG_NoDependencies(t *testing.T) { + t.Parallel() + + c := newChart("parent", + enabledDependency("nginx"), + enabledDependency("rabbitmq"), + enabledDependency("postgres"), + ) + + assertBatches(t, c, [][]string{{"nginx", "postgres", "rabbitmq"}}) +} + +func TestBuildSubchartDAG_LinearOrder(t *testing.T) { + t.Parallel() + + c := newChart("parent", + enabledDependency("postgres"), + enabledDependency("rabbitmq", "postgres"), + enabledDependency("app", "rabbitmq"), + ) + + assertBatches(t, c, [][]string{{"postgres"}, {"rabbitmq"}, {"app"}}) +} + +func TestBuildSubchartDAG_AliasResolution(t *testing.T) { + t.Parallel() + + // Simulates post-ProcessDependencies state: the aliased entry has had + // Name rewritten to its alias, and depends-on entries were rewritten to + // effective names. (The real-pipeline path is covered by the + // TestProcessDependencies_* tests below.) + c := newChart("parent", + &chart.Dependency{Name: "primary-db", Alias: "primary-db", Enabled: true}, + enabledDependency("app", "primary-db"), + ) + + assertBatches(t, c, [][]string{{"primary-db"}, {"app"}}) +} + +func TestBuildSubchartDAG_DisabledSubchart(t *testing.T) { + t.Parallel() + + c := newChart("parent", + &chart.Dependency{Name: "cache", Enabled: false}, + enabledDependency("app", "cache"), + ) + + _, err := BuildSubchartDAG(c) + require.Error(t, err) + assert.ErrorContains(t, err, `depends-on unknown or disabled subchart "cache"`) +} + +func TestBuildSubchartDAG_DisabledSubchartNotReferenced(t *testing.T) { + t.Parallel() + + c := newChart("parent", + &chart.Dependency{Name: "cache", Enabled: false}, + enabledDependency("app"), + ) + + assertBatches(t, c, [][]string{{"app"}}) +} + +func TestBuildSubchartDAG_CycleDetection(t *testing.T) { + t.Parallel() + + c := newChart("parent", + enabledDependency("a", "b"), + enabledDependency("b", "c"), + enabledDependency("c", "a"), + ) + + dag, err := BuildSubchartDAG(c) + require.NoError(t, err) + + batches, err := dag.GetBatches() + require.Error(t, err) + assert.Nil(t, batches) + assert.ErrorContains(t, err, "cycle") +} + +func TestBuildSubchartDAG_AnnotationBasedParentDependencies(t *testing.T) { + t.Parallel() + + c := newChart("parent", + enabledDependency("postgres"), + enabledDependency("nginx"), + ) + c.Metadata.Annotations = map[string]string{ + AnnotationDependsOnSubcharts: `["nginx"]`, + } + + assertBatches(t, c, [][]string{{"nginx", "postgres"}}) +} + +func TestBuildSubchartDAG_HIPExample(t *testing.T) { + t.Parallel() + + c := newChart("foo", + enabledDependency("nginx"), + enabledDependency("rabbitmq"), + enabledDependency("bar", "nginx", "rabbitmq"), + ) + c.Metadata.Annotations = map[string]string{ + AnnotationDependsOnSubcharts: `["bar", "rabbitmq"]`, + } + + assertBatches(t, c, [][]string{{"nginx", "rabbitmq"}, {"bar"}}) +} + +func TestBuildSubchartDAG_MixedDeclarations(t *testing.T) { + t.Parallel() + + c := newChart("parent", + enabledDependency("database"), + enabledDependency("api", "database"), + enabledDependency("worker"), + ) + c.Metadata.Annotations = map[string]string{ + AnnotationDependsOnSubcharts: `["worker"]`, + } + + assertBatches(t, c, [][]string{{"database", "worker"}, {"api"}}) +} + +func TestBuildSubchartDAG_InvalidAnnotationJSON(t *testing.T) { + t.Parallel() + + c := newChart("parent", enabledDependency("api")) + c.Metadata.Annotations = map[string]string{ + AnnotationDependsOnSubcharts: `["api",`, + } + + _, err := BuildSubchartDAG(c) + require.Error(t, err) + assert.ErrorContains(t, err, "parsing "+AnnotationDependsOnSubcharts+" annotation") +} + +func TestBuildSubchartDAG_ObjectAnnotationRejected(t *testing.T) { + t.Parallel() + + c := newChart("parent", + enabledDependency("postgres"), + enabledDependency("nginx"), + ) + c.Metadata.Annotations = map[string]string{ + AnnotationDependsOnSubcharts: `{"nginx":["postgres"]}`, + } + + _, err := BuildSubchartDAG(c) + require.Error(t, err) + assert.ErrorContains(t, err, "JSON string array") +} + +func TestBuildSubchartDAG_NonExistentReference(t *testing.T) { + t.Parallel() + + c := newChart("parent", enabledDependency("app", "missing")) + + _, err := BuildSubchartDAG(c) + require.Error(t, err) + assert.ErrorContains(t, err, `depends-on unknown or disabled subchart "missing"`) +} + +func TestBuildSubchartDAG_AnnotationUnknownSubchart(t *testing.T) { + t.Parallel() + + c := newChart("parent", enabledDependency("postgres")) + c.Metadata.Annotations = map[string]string{ + AnnotationDependsOnSubcharts: `["app"]`, + } + + _, err := BuildSubchartDAG(c) + require.Error(t, err) + assert.ErrorContains(t, err, "unknown or disabled subchart") +} + +func TestBuildSubchartDAG_NestedSubcharts(t *testing.T) { + t.Parallel() + + root := newChart("parent", + enabledDependency("database"), + enabledDependency("application", "database"), + ) + nested := newChart("application", + enabledDependency("cache"), + enabledDependency("worker", "cache"), + ) + // Replace auto-stubs with real chart objects so nested DAG validation works. + root.SetDependencies( + &chart.Chart{Metadata: &chart.Metadata{Name: "database"}}, + nested, + ) + + assertBatches(t, root, [][]string{{"database"}, {"application"}}) + assertBatches(t, nested, [][]string{{"cache"}, {"worker"}}) +} + +// TestBuildSubchartDAG_StorageDecodedMetadataTrusted: a chart decoded from +// release storage has Metadata.Dependencies (pruned to the enabled set and +// alias-rewritten by ProcessDependencies before it was stored, Enabled=true) +// but an EMPTY loaded-dependency tree — the release codec drops the +// unexported Chart.dependencies field. Those metadata entries must be +// trusted, or uninstall/rollback of any sequenced release fails (bead xmn). +func TestBuildSubchartDAG_StorageDecodedMetadataTrusted(t *testing.T) { + t.Parallel() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Dependencies: []*chart.Dependency{ + {Name: "db", Enabled: true}, + {Name: "app", Enabled: true, DependsOn: []string{"db"}}, + }, + Annotations: map[string]string{ + AnnotationDependsOnSubcharts: `["app"]`, + }, + }, + } + // Note: no AddDependency call — c.Dependencies() is empty, as after a + // storage round-trip. + + assertBatches(t, c, [][]string{{"db"}, {"app"}}) +} + +// TestBuildSubchartDAG_MetadataOnlyNotEnabled_Ignored: freshly parsed +// Chart.yaml entries default to Enabled=false — only ProcessDependencies sets +// it true. Without loaded dependencies (e.g. lint before `helm dependency +// build`) such entries stay excluded, preserving the pre-existing contract +// for unprocessed charts. +func TestBuildSubchartDAG_MetadataOnlyNotEnabled_Ignored(t *testing.T) { + t.Parallel() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Dependencies: []*chart.Dependency{ + {Name: "ghost"}, + }, + }, + } + + batches := batchesForChart(t, c) + assert.Empty(t, batches, "unprocessed metadata-only deps should yield empty DAG") +} + +// TestBuildSubchartDAG_AnnotationReferencesUnloadedDep verifies that an +// annotation referencing a subchart present in metadata but pruned from +// c.Dependencies() (e.g., disabled by ProcessDependencies) produces an error. +func TestBuildSubchartDAG_AnnotationReferencesUnloadedDep(t *testing.T) { + t.Parallel() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Dependencies: []*chart.Dependency{ + {Name: "loaded-dep", Enabled: true}, + {Name: "pruned-dep", Enabled: true}, + }, + Annotations: map[string]string{ + AnnotationDependsOnSubcharts: `["pruned-dep"]`, + }, + }, + } + // Only loaded-dep is in c.Dependencies(); pruned-dep is not. + c.AddDependency(&chart.Chart{Metadata: &chart.Metadata{Name: "loaded-dep"}}) + + _, err := BuildSubchartDAG(c) + require.Error(t, err) + assert.ErrorContains(t, err, `unknown or disabled subchart "pruned-dep"`) +} + +func assertBatches(t *testing.T, c *chart.Chart, expected [][]string) { + t.Helper() + assert.Equal(t, expected, batchesForChart(t, c)) +} + +func batchesForChart(t *testing.T, c *chart.Chart) [][]string { + t.Helper() + + dag, err := BuildSubchartDAG(c) + require.NoError(t, err) + + batches, err := dag.GetBatches() + require.NoError(t, err) + + return batches +} + +func newChart(name string, deps ...*chart.Dependency) *chart.Chart { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: name, + Dependencies: deps, + }, + } + // Simulate post-ProcessDependencies state: enabled deps appear in + // c.Dependencies() under their effective name (alias if set). + for _, dep := range deps { + if dep == nil || !dep.Enabled { + continue + } + subName := dep.Alias + if subName == "" { + subName = dep.Name + } + c.AddDependency(&chart.Chart{ + Metadata: &chart.Metadata{Name: subName}, + }) + } + return c +} + +func enabledDependency(name string, dependsOn ...string) *chart.Dependency { + return &chart.Dependency{ + Name: name, + Enabled: true, + DependsOn: dependsOn, + } +} + +// pipelineChart builds a parent chart whose loaded dependencies are named +// after the entries' ORIGINAL chart names, exactly as loader.LoadDir produces +// them, so tests exercise the real ProcessDependencies pipeline (alias rename +// + depends-on resolution) instead of hand-constructing post-processed state. +func pipelineChart(deps ...*chart.Dependency) *chart.Chart { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + Dependencies: deps, + }, + } + added := make(map[string]bool) + for _, dep := range deps { + if added[dep.Name] { + continue + } + added[dep.Name] = true + c.AddDependency(&chart.Chart{ + Metadata: &chart.Metadata{ + Name: dep.Name, + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + }, + }) + } + return c +} + +func pipelineDependency(name, alias string, dependsOn ...string) *chart.Dependency { + return &chart.Dependency{ + Name: name, + Version: "0.1.0", + Alias: alias, + DependsOn: dependsOn, + } +} + +// TestProcessDependencies_ResolvesDependsOnByOriginalName is the regression +// test for hip-0025-92r. Through the real pipeline, processDependencyEnabled +// rewrites an aliased entry's Name to its alias; before the fix that made the +// original chart name unrecoverable and depends-on: ["postgres"] failed to +// resolve at DAG build time. +func TestProcessDependencies_ResolvesDependsOnByOriginalName(t *testing.T) { + t.Parallel() + + c := pipelineChart( + pipelineDependency("postgres", "primary-db"), + pipelineDependency("app", "", "postgres"), + ) + + require.NoError(t, ProcessDependencies(c, map[string]any{})) + + // The alias rename ran, and the depends-on reference was rewritten to the + // effective name — which is also what release storage persists, keeping + // the sequenced uninstall path working. + assert.Equal(t, "primary-db", c.Metadata.Dependencies[0].Name) + assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn) + + assertBatches(t, c, [][]string{{"primary-db"}, {"app"}}) +} + +func TestProcessDependencies_ResolvesDependsOnByAlias(t *testing.T) { + t.Parallel() + + c := pipelineChart( + pipelineDependency("postgres", "primary-db"), + pipelineDependency("app", "", "primary-db"), + ) + + require.NoError(t, ProcessDependencies(c, map[string]any{})) + + assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn) + assertBatches(t, c, [][]string{{"primary-db"}, {"app"}}) +} + +func TestProcessDependencies_AmbiguousDependsOnRejected(t *testing.T) { + t.Parallel() + + // The same chart aliased twice makes its original name ambiguous; + // referencing it by that name must be rejected, not silently resolved. + c := pipelineChart( + pipelineDependency("postgres", "db1"), + pipelineDependency("postgres", "db2"), + pipelineDependency("app", "", "postgres"), + ) + + err := ProcessDependencies(c, map[string]any{}) + require.Error(t, err) + assert.ErrorContains(t, err, `ambiguous subchart reference "postgres"`) +} + +func TestProcessDependencies_RewritesSubchartAnnotation(t *testing.T) { + t.Parallel() + + c := pipelineChart( + pipelineDependency("postgres", "primary-db"), + pipelineDependency("app", ""), + ) + c.Metadata.Annotations = map[string]string{ + AnnotationDependsOnSubcharts: `["postgres", "app"]`, + } + + require.NoError(t, ProcessDependencies(c, map[string]any{})) + + assert.Equal(t, `["primary-db","app"]`, c.Metadata.Annotations[AnnotationDependsOnSubcharts]) + assertBatches(t, c, [][]string{{"app", "primary-db"}}) +} + +func TestProcessDependencies_AmbiguousAnnotationRejected(t *testing.T) { + t.Parallel() + + c := pipelineChart( + pipelineDependency("postgres", "db1"), + pipelineDependency("postgres", "db2"), + ) + c.Metadata.Annotations = map[string]string{ + AnnotationDependsOnSubcharts: `["postgres"]`, + } + + err := ProcessDependencies(c, map[string]any{}) + require.Error(t, err) + assert.ErrorContains(t, err, `references ambiguous subchart "postgres"`) +} + +func TestProcessDependencies_UnknownDependsOnReportedByDAG(t *testing.T) { + t.Parallel() + + // Unknown references are left alone by the resolver so BuildSubchartDAG + // keeps reporting them with its established error message. + c := pipelineChart( + pipelineDependency("app", "", "missing"), + ) + + require.NoError(t, ProcessDependencies(c, map[string]any{})) + + _, err := BuildSubchartDAG(c) + require.Error(t, err) + assert.ErrorContains(t, err, `unknown or disabled subchart "missing"`) +} + +func TestProcessDependencies_DependsOnRewriteIdempotent(t *testing.T) { + t.Parallel() + + c := pipelineChart( + pipelineDependency("postgres", "primary-db"), + pipelineDependency("app", "", "postgres"), + ) + + require.NoError(t, ProcessDependencies(c, map[string]any{})) + // Lint and the sequenced-uninstall re-render run ProcessDependencies again + // on the already-processed chart; the second pass must neither error nor + // corrupt the rewritten references. + require.NoError(t, ProcessDependencies(c, map[string]any{})) + + assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn) + assertBatches(t, c, [][]string{{"primary-db"}, {"app"}}) +} + +func TestProcessDependencies_PlainAndAliasedSameChart(t *testing.T) { + t.Parallel() + + // "svc" is deployed both under its own name and under an alias: the bare + // name matches two subcharts and is ambiguous, while the alias resolves. + ambiguous := pipelineChart( + pipelineDependency("svc", ""), + pipelineDependency("svc", "svc2"), + pipelineDependency("app", "", "svc"), + ) + err := ProcessDependencies(ambiguous, map[string]any{}) + require.Error(t, err) + assert.ErrorContains(t, err, `ambiguous subchart reference "svc"`) + + byAlias := pipelineChart( + pipelineDependency("svc", ""), + pipelineDependency("svc", "svc2"), + pipelineDependency("app", "", "svc2"), + ) + require.NoError(t, ProcessDependencies(byAlias, map[string]any{})) + assertBatches(t, byAlias, [][]string{{"svc", "svc2"}, {"app"}}) +} + +func TestProcessDependencies_SwappedAliasesAmbiguous(t *testing.T) { + t.Parallel() + + // x is aliased to y while y is aliased to x: each bare name now refers to + // two different subcharts, so references to either are rejected as + // ambiguous rather than silently resolved to one of them. + c := pipelineChart( + pipelineDependency("x", "y"), + pipelineDependency("y", "x"), + pipelineDependency("app", "", "x"), + ) + + err := ProcessDependencies(c, map[string]any{}) + require.Error(t, err) + assert.ErrorContains(t, err, `ambiguous subchart reference "x"`) +} diff --git a/pkg/cmd/dag.go b/pkg/cmd/dag.go new file mode 100644 index 000000000..597d409ed --- /dev/null +++ b/pkg/cmd/dag.go @@ -0,0 +1,229 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "errors" + "fmt" + "io" + "slices" + "sort" + "strings" + + "github.com/spf13/cobra" + + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/cli/values" + "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/release/v1/sequence" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +const dagDesc = ` +Print the resource sequencing DAG (directed acyclic graph) for a chart. + +This is a development and troubleshooting command for HIP-0025 sequencing. It +loads the chart, evaluates conditional dependencies against the provided values, +renders templates locally, and prints the deployment order that +'helm install --wait=ordered' would use: + + - Subchart deployment batches, derived from Chart.yaml dependency 'depends-on' + fields and the 'helm.sh/depends-on/subcharts' annotation. + - Resource-group batches per chart level, derived from the + 'helm.sh/resource-group' and 'helm.sh/depends-on/resource-groups' + annotations on rendered manifests. + +Cycles in either DAG are reported as errors. Manifests that lack a +'helm.sh/resource-group' annotation, or whose group was demoted because of a +missing dependency, are listed as "Unsequenced" and would be deployed after the +sequenced batches. + +Hooks are not part of any sequencing DAG (HIP-0025) and are omitted from the +output. No cluster connection is required; the chart is rendered with +client-side dry-run. +` + +func newDagCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewInstall(cfg) + valueOpts := &values.Options{} + var kubeVersion string + var extraAPIs []string + + cmd := &cobra.Command{ + Use: "dag CHART", + Short: "print the resource sequencing DAG for a chart", + Long: dagDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return compInstall(args, toComplete, client) + }, + RunE: func(_ *cobra.Command, args []string) error { + if kubeVersion != "" { + parsed, err := common.ParseKubeVersion(kubeVersion) + if err != nil { + return fmt.Errorf("invalid kube version %q: %w", kubeVersion, err) + } + client.KubeVersion = parsed + } + + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + client.SetRegistryClient(registryClient) + + // Render the chart locally without touching the cluster. Hooks are not + // sequenced per HIP-0025, so we suppress them from the rendered output + // to keep the DAG view focused on install-phase resources. + client.DryRunStrategy = action.DryRunClient + client.ReleaseName = "release-name" + client.Replace = true + client.APIVersions = common.VersionSet(extraAPIs) + client.DisableHooks = true + + rel, err := runInstall(args, client, valueOpts, out) + if err != nil { + return err + } + if rel == nil || rel.Chart == nil { + return errors.New("no chart rendered") + } + + return printSequencingDAG(rel.Chart, strings.TrimSpace(rel.Manifest), out) + }, + } + + f := cmd.Flags() + addValueOptionsFlags(f, valueOpts) + addChartPathOptionsFlags(f, &client.ChartPathOptions) + f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") + f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)") + f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before printing the DAG") + + return cmd +} + +// printSequencingDAG walks a processed chart and its rendered manifest stream, +// printing the subchart deployment batches and per-chart resource-group batches +// in the same order 'helm install --wait=ordered' would deploy them. +func printSequencingDAG(chrt *chart.Chart, manifest string, out io.Writer) error { + var manifests []releaseutil.Manifest + if manifest != "" { + parsed, err := sequence.ParseStoredManifests(manifest) + if err != nil { + return fmt.Errorf("parsing rendered manifests: %w", err) + } + manifests = parsed + } + plan, err := sequence.Build(chrt, manifests) + if err != nil { + return err + } + logSequencePlanWarnings(plan) + + levelByPath := make(map[string]*sequence.ChartLevel, len(plan.Levels)) + for i := range plan.Levels { + levelByPath[plan.Levels[i].Path] = &plan.Levels[i] + } + + groupBatchesByPath := make(map[string][]sequence.Batch) + unsequencedByPath := make(map[string]sequence.Batch) + for _, batch := range plan.Batches { + switch batch.Kind { + case sequence.BatchKindGroups: + groupBatchesByPath[batch.ChartPath] = append(groupBatchesByPath[batch.ChartPath], batch) + case sequence.BatchKindUnsequenced: + unsequencedByPath[batch.ChartPath] = batch + } + } + + var printLevel func(level *sequence.ChartLevel) + printLevel = func(level *sequence.ChartLevel) { + indent := strings.Repeat(" ", level.Depth) + fmt.Fprintf(out, "%sChart: %s\n", indent, level.Path) + + if len(level.SubchartBatches) == 0 { + fmt.Fprintf(out, "%s Subchart batches: (none)\n", indent) + } else { + fmt.Fprintf(out, "%s Subchart batches:\n", indent) + for i, batch := range level.SubchartBatches { + fmt.Fprintf(out, "%s Batch %d: %s\n", indent, i+1, strings.Join(batch, ", ")) + } + } + + resourceIndent := indent + " " + groupBatches := groupBatchesByPath[level.Path] + if len(groupBatches) == 0 { + fmt.Fprintf(out, "%sResource-group batches: (none)\n", resourceIndent) + } else { + fmt.Fprintf(out, "%sResource-group batches:\n", resourceIndent) + for i, batch := range groupBatches { + names := make([]string, 0, len(batch.Groups)) + for _, group := range batch.Groups { + names = append(names, group.Name) + } + fmt.Fprintf(out, "%s Batch %d: %s\n", resourceIndent, i+1, strings.Join(names, ", ")) + } + } + + if batch, ok := unsequencedByPath[level.Path]; ok { + names := make([]string, 0, len(batch.Manifests())) + for _, manifest := range batch.Manifests() { + names = append(names, unsequencedResourceLabel(manifest)) + } + sort.Strings(names) + fmt.Fprintf(out, "%sUnsequenced (deployed last): %s\n", resourceIndent, strings.Join(names, ", ")) + } + + printChild := func(name string) { + if slices.Contains(level.Unresolved, name) { + fmt.Fprintf(out, "%s (subchart %q metadata unavailable; sequenced structurally from manifests)\n", indent, name) + } + if child := levelByPath[level.Path+"/charts/"+name]; child != nil { + printLevel(child) + } + } + + for _, batch := range level.SubchartBatches { + for _, name := range batch { + printChild(name) + } + } + for _, name := range level.Undeclared { + fmt.Fprintf(out, "%s Undeclared subchart %q (deployed unsequenced):\n", indent, name) + printChild(name) + } + } + + if len(plan.Levels) > 0 { + printLevel(&plan.Levels[0]) + } + return nil +} + +func unsequencedResourceLabel(m releaseutil.Manifest) string { + if m.Head != nil && m.Head.Metadata != nil && m.Head.Metadata.Name != "" { + if m.Head.Kind != "" { + return fmt.Sprintf("%s/%s", m.Head.Kind, m.Head.Metadata.Name) + } + return m.Head.Metadata.Name + } + return m.Name +} diff --git a/pkg/cmd/dag_test.go b/pkg/cmd/dag_test.go new file mode 100644 index 000000000..28a392f6a --- /dev/null +++ b/pkg/cmd/dag_test.go @@ -0,0 +1,60 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "strings" + "testing" +) + +func TestDagCmd(t *testing.T) { + chartPath := "testdata/testcharts/sequenced-chart" + + tests := []cmdTestCase{ + { + name: "sequenced chart prints subchart and resource-group batches", + cmd: fmt.Sprintf("dag '%s'", chartPath), + golden: "output/dag-sequenced-chart.txt", + }, + { + name: "sequenced chart demotes isolated groups", + cmd: "dag 'testdata/testcharts/sequenced-isolated-chart'", + golden: "output/dag-sequenced-isolated.txt", + }, + } + runTestCmd(t, tests) +} + +func TestDagCmd_RequiresChartArg(t *testing.T) { + _, _, err := executeActionCommandC(storageFixture(), "dag") + if err == nil { + t.Fatal("expected error when chart argument is missing") + } +} + +func TestDagCmd_NonexistentChart(t *testing.T) { + _, _, err := executeActionCommandC(storageFixture(), "dag testdata/testcharts/does-not-exist") + if err == nil { + t.Fatal("expected error for nonexistent chart path") + } + if !strings.Contains(err.Error(), "does-not-exist") && + !strings.Contains(strings.ToLower(err.Error()), "no such file") && + !strings.Contains(strings.ToLower(err.Error()), "not found") { + t.Fatalf("expected error to mention the missing chart, got: %v", err) + } +} diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index e7db3dde2..87d619a72 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -25,6 +25,7 @@ import ( "path/filepath" "sort" "strings" + "time" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -65,42 +66,99 @@ func AddWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) { cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy) } -type waitValue kube.WaitStrategy +func AddOrderedWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) { + cmd.Flags().Var( + newOrderedWaitValue(kube.HookOnlyStrategy, wait), + "wait", + "wait until resources are ready (up to --timeout). Use '--wait' alone for 'watcher' strategy, or specify one of: 'watcher', 'hookOnly', 'legacy', 'ordered'. Default when flag is omitted: 'hookOnly'.", + ) + cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy) +} + +type waitValue struct { + wait *kube.WaitStrategy + allowOrdered bool +} func newWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy) *waitValue { + return newConfiguredWaitValue(defaultValue, ws, false) +} + +func newOrderedWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy) *waitValue { + return newConfiguredWaitValue(defaultValue, ws, true) +} + +func newConfiguredWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy, allowOrdered bool) *waitValue { *ws = defaultValue - return (*waitValue)(ws) + return &waitValue{wait: ws, allowOrdered: allowOrdered} } func (ws *waitValue) String() string { - if ws == nil { + if ws == nil || ws.wait == nil { return "" } - return string(*ws) + return string(*ws.wait) } func (ws *waitValue) Set(s string) error { switch s { case string(kube.StatusWatcherStrategy), string(kube.LegacyStrategy), string(kube.HookOnlyStrategy): - *ws = waitValue(s) + *ws.wait = kube.WaitStrategy(s) + return nil + case string(kube.OrderedWaitStrategy): + if !ws.allowOrdered { + break + } + *ws.wait = kube.WaitStrategy(s) return nil case "true": slog.Warn("--wait=true is deprecated (boolean value) and can be replaced with --wait=watcher") - *ws = waitValue(kube.StatusWatcherStrategy) + *ws.wait = kube.StatusWatcherStrategy return nil case "false": slog.Warn("--wait=false is deprecated (boolean value) and can be replaced with --wait=hookOnly") - *ws = waitValue(kube.HookOnlyStrategy) + *ws.wait = kube.HookOnlyStrategy return nil default: - return fmt.Errorf("invalid wait input %q. Valid inputs are %s, %s, and %s", s, kube.StatusWatcherStrategy, kube.HookOnlyStrategy, kube.LegacyStrategy) } + + return fmt.Errorf("invalid wait input %q. Valid inputs are %s", s, formatWaitInputs(ws.allowOrdered)) } func (ws *waitValue) Type() string { return "WaitStrategy" } +func addReadinessTimeoutFlag(f *pflag.FlagSet, readinessTimeout *time.Duration) { + // Default 0 means "unset": the per-batch default of 1m is applied at the + // point of use on the ordered path (install/upgrade/rollback). Defaulting + // the flag itself to 1m would make the readiness-timeout<=timeout check + // reject any plain run with --timeout < 1m, even without --wait=ordered. + f.DurationVar(readinessTimeout, "readiness-timeout", 0, "per-batch timeout when --wait=ordered is used; each resource batch must become ready within this duration (defaults to 1m when unset, and must not exceed --timeout). \"Ready\" is determined by kstatus signals for the resource kind (Deployment/StatefulSet/Pod/etc.) or by helm.sh/readiness-success and helm.sh/readiness-failure annotations when set; vanilla Jobs require --wait-for-jobs for the per-batch readiness gate to apply") +} + +func formatWaitInputs(allowOrdered bool) string { + valid := []string{ + string(kube.StatusWatcherStrategy), + string(kube.HookOnlyStrategy), + string(kube.LegacyStrategy), + } + if allowOrdered { + valid = append(valid, string(kube.OrderedWaitStrategy)) + } + + switch len(valid) { + case 0: + return "" + case 1: + return valid[0] + case 2: + return fmt.Sprintf("%s and %s", valid[0], valid[1]) + default: + return fmt.Sprintf("%s, and %s", strings.Join(valid[:len(valid)-1], ", "), valid[len(valid)-1]) + } +} + func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) { f.StringVar(&c.Version, "version", "", "specify a version constraint for the chart version to use. This constraint can be a specific tag (e.g. 1.1.1) or it may reference a valid range (e.g. ^2.0.0). If this is not specified, the latest version is used") f.BoolVar(&c.Verify, "verify", false, "verify the package before using it") diff --git a/pkg/cmd/flags_test.go b/pkg/cmd/flags_test.go index 614970252..3245cfec3 100644 --- a/pkg/cmd/flags_test.go +++ b/pkg/cmd/flags_test.go @@ -18,6 +18,7 @@ package cmd import ( "fmt" + "io" "testing" "time" @@ -99,6 +100,113 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) { runTestCmd(t, tests) } +func TestWaitFlag(t *testing.T) { + t.Run("install accepts ordered wait", func(t *testing.T) { + cmd := newInstallCmd(&action.Configuration{}, io.Discard) + require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"})) + require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String()) + }) + + t.Run("upgrade accepts ordered wait", func(t *testing.T) { + cmd := newUpgradeCmd(&action.Configuration{}, io.Discard) + require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"})) + require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String()) + }) + + t.Run("template accepts ordered wait", func(t *testing.T) { + cmd := newTemplateCmd(&action.Configuration{}, io.Discard) + require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"})) + require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String()) + }) + + t.Run("rollback accepts ordered wait", func(t *testing.T) { + cmd := newRollbackCmd(&action.Configuration{}, io.Discard) + require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"})) + require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String()) + }) + + t.Run("uninstall accepts ordered wait", func(t *testing.T) { + cmd := newUninstallCmd(&action.Configuration{}, io.Discard) + require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"})) + require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String()) + }) +} + +func TestReadinessTimeout(t *testing.T) { + t.Run("install registers readiness-timeout unset by default", func(t *testing.T) { + cmd := newInstallCmd(&action.Configuration{}, io.Discard) + flag := cmd.Flags().Lookup("readiness-timeout") + require.NotNil(t, flag) + require.Equal(t, "0s", flag.DefValue) + require.NoError(t, cmd.ParseFlags([]string{"--readiness-timeout=30s"})) + require.Equal(t, "30s", flag.Value.String()) + }) + + t.Run("upgrade registers readiness-timeout unset by default", func(t *testing.T) { + cmd := newUpgradeCmd(&action.Configuration{}, io.Discard) + flag := cmd.Flags().Lookup("readiness-timeout") + require.NotNil(t, flag) + require.Equal(t, "0s", flag.DefValue) + require.NoError(t, cmd.ParseFlags([]string{"--readiness-timeout=30s"})) + require.Equal(t, "30s", flag.Value.String()) + }) + + t.Run("template and non-applicable commands do not register readiness-timeout", func(t *testing.T) { + require.Nil(t, newTemplateCmd(&action.Configuration{}, io.Discard).Flags().Lookup("readiness-timeout")) + require.Nil(t, newUninstallCmd(&action.Configuration{}, io.Discard).Flags().Lookup("readiness-timeout")) + }) + + t.Run("rollback registers readiness-timeout unset by default", func(t *testing.T) { + cmd := newRollbackCmd(&action.Configuration{}, io.Discard) + flag := cmd.Flags().Lookup("readiness-timeout") + require.NotNil(t, flag) + require.Equal(t, "0s", flag.DefValue) + require.NoError(t, cmd.ParseFlags([]string{"--readiness-timeout=30s"})) + require.Equal(t, "30s", flag.Value.String()) + }) + + t.Run("install rejects readiness-timeout longer than timeout", func(t *testing.T) { + _, _, err := executeActionCommand("install timed-install testdata/testcharts/empty --wait=ordered --timeout 30s --readiness-timeout 60s") + require.Error(t, err) + require.Contains(t, err.Error(), "--readiness-timeout (1m0s) must not exceed --timeout (30s)") + }) + + t.Run("upgrade rejects readiness-timeout longer than timeout", func(t *testing.T) { + releaseName := "timed-upgrade" + relMock, ch, chartPath := prepareMockRelease(t, releaseName) + store := storageFixture() + require.NoError(t, store.Create(relMock(releaseName, 1, ch))) + + _, _, err := executeActionCommandC(store, fmt.Sprintf("upgrade %s '%s' --wait=ordered --timeout 30s --readiness-timeout 60s", releaseName, chartPath)) + require.Error(t, err) + require.Contains(t, err.Error(), "--readiness-timeout (1m0s) must not exceed --timeout (30s)") + }) + + t.Run("upgrade --install fallback carries readiness-timeout to install", func(t *testing.T) { + // Regression for hip-0025-vw1: the upgrade --install copy block dropped + // ReadinessTimeout, so first-time installs silently ignored the flag. + // With an empty store the upgrade falls back to install; if the value + // is carried over, install's readiness-timeout<=timeout guard fires. + store := storageFixture() + _, _, err := executeActionCommandC(store, "upgrade missing-release testdata/testcharts/empty --install --wait=ordered --timeout 30s --readiness-timeout 60s") + require.Error(t, err) + require.Contains(t, err.Error(), "--readiness-timeout (1m0s) must not exceed --timeout (30s)") + }) + + t.Run("readiness-timeout is ignored without ordered wait", func(t *testing.T) { + _, _, err := executeActionCommand("install plain-install testdata/testcharts/empty --readiness-timeout 30s") + require.NoError(t, err) + }) + + // Regression for hip-0025: a plain install with --timeout below the old + // 1m readiness-timeout default and no --readiness-timeout / --wait=ordered + // must not be rejected by the readiness-timeout<=timeout guard. + t.Run("plain install with sub-minute timeout and no readiness flag succeeds", func(t *testing.T) { + _, _, err := executeActionCommand("install short-timeout testdata/testcharts/empty --timeout 30s") + require.NoError(t, err) + }) +} + func TestPostRendererFlagSetOnce(t *testing.T) { cfg := action.Configuration{} client := action.NewInstall(&cfg) diff --git a/pkg/cmd/get_manifest.go b/pkg/cmd/get_manifest.go index 253b011c1..6eeda796b 100644 --- a/pkg/cmd/get_manifest.go +++ b/pkg/cmd/get_manifest.go @@ -34,6 +34,11 @@ This command fetches the generated manifest for a given release. A manifest is a YAML-encoded representation of the Kubernetes resources that were generated from this release's chart(s). If a chart is dependent on other charts, those resources will also be included in the manifest. + +The manifest is printed exactly as stored, including Helm-internal sequencing +annotations such as helm.sh/depends-on/resource-groups. They are part of the +release record and drive sequenced uninstall/rollback. Use 'helm template' for +apply-ready output. ` func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { diff --git a/pkg/cmd/get_manifest_test.go b/pkg/cmd/get_manifest_test.go index cfb5215bf..a20097d28 100644 --- a/pkg/cmd/get_manifest_test.go +++ b/pkg/cmd/get_manifest_test.go @@ -17,6 +17,7 @@ limitations under the License. package cmd import ( + "strings" "testing" release "helm.sh/helm/v4/pkg/release/v1" @@ -37,6 +38,40 @@ func TestGetManifest(t *testing.T) { runTestCmd(t, tests) } +func TestGetManifestPrintsStoredManifestVerbatim(t *testing.T) { + const annotationLine = ` helm.sh/depends-on/resource-groups: '["db"]'` + manifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: sequenced + annotations: + helm.sh/resource-group: app +` + annotationLine + ` +data: + key: value +` + rel := release.Mock(&release.MockReleaseOptions{Name: "sequenced"}) + rel.Manifest = manifest + + store := storageFixture() + if err := store.Create(rel); err != nil { + t.Fatal(err) + } + _, out, err := executeActionCommandC(store, "get manifest sequenced") + if err != nil { + t.Fatal(err) + } + + // Invariant pin: get manifest prints the stored release record verbatim, + // including sequencing annotations that template output strips. + if !strings.Contains(out, "helm.sh/depends-on/resource-groups") { + t.Fatalf("expected stored sequencing annotation key in output:\n%s", out) + } + if !strings.Contains(out, annotationLine) { + t.Fatalf("expected exact stored sequencing annotation line in output:\n%s", out) + } +} + func TestGetManifestCompletion(t *testing.T) { checkReleaseCompletion(t, "get manifest", false) } diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 832968ff0..dc07a02a7 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -173,6 +173,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() addInstallFlags(cmd, f, client, valueOpts) + addReadinessTimeoutFlag(f, &client.ReadinessTimeout) // hide-secret is not available in all places the install flags are used so // it is added separately f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") @@ -232,7 +233,7 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal addValueOptionsFlags(f, valueOpts) addChartPathOptionsFlags(f, &client.ChartPathOptions) - AddWaitFlag(cmd, &client.WaitStrategy) + AddOrderedWaitFlag(cmd, &client.WaitStrategy) cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index 01d8b1866..e23ba6287 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -93,7 +93,8 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this rollback when rollback fails") f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit") addDryRunFlag(cmd) - AddWaitFlag(cmd, &client.WaitStrategy) + AddOrderedWaitFlag(cmd, &client.WaitStrategy) + addReadinessTimeoutFlag(f, &client.ReadinessTimeout) cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 3203ced25..aa188a4c5 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -267,6 +267,7 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg cmd.AddCommand( // chart commands newCreateCmd(out), + newDagCmd(actionConfig, out), newDependencyCmd(actionConfig, out), newPullCmd(actionConfig, out), newShowCmd(actionConfig, out), diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index ccc1db719..bda65e853 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "io/fs" + "log/slog" "os" "path/filepath" "regexp" @@ -35,8 +36,11 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/kube" + "helm.sh/helm/v4/pkg/release/v1/sequence" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" ) @@ -105,6 +109,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client.Replace = true // Skip the name check client.APIVersions = common.VersionSet(extraAPIs) client.IncludeCRDs = includeCrds + orderedTemplateOutput := client.WaitStrategy == kube.OrderedWaitStrategy && len(showFiles) == 0 && client.OutputDir == "" rel, err := runInstall(args, client, valueOpts, out) if err != nil && !settings.Debug { @@ -118,87 +123,108 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { // We ignore a potential error here because, when the --debug flag was specified, // we always want to print the YAML, even if it is not valid. The error is still returned afterwards. if rel != nil { - var manifests bytes.Buffer - fmt.Fprintln(&manifests, strings.TrimSpace(rel.Manifest)) - if !client.DisableHooks { - fileWritten := make(map[string]bool) - for _, m := range rel.Hooks { - if skipTests && isTestHook(m) { - continue - } - if client.OutputDir == "" { - fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) - } else { - newDir := client.OutputDir - if client.UseReleaseName { - newDir = filepath.Join(client.OutputDir, client.ReleaseName) - } - _, err := os.Stat(filepath.Join(newDir, m.Path)) - if err == nil { - fileWritten[m.Path] = true - } - - err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path]) - if err != nil { - return err + orderedRendered := false + if orderedTemplateOutput { + if renderErr := renderOrderedTemplate(rel.Chart, strings.TrimSpace(rel.Manifest), out); renderErr != nil { + // Honor the --debug contract: always print the manifests, even if + // ordered rendering fails (e.g., a document fails YAML structural + // parsing). Fall back to the flat path with a stderr warning. + fmt.Fprintf(os.Stderr, "WARNING: ordered template rendering failed (%v); falling back to flat output\n", renderErr) + } else { + orderedRendered = true + if !client.DisableHooks { + for _, m := range rel.Hooks { + if skipTests && isTestHook(m) { + continue + } + fmt.Fprintf(out, "---\n# Source: %s\n%s\n", m.Path, releaseutil.StripHelmInternalAnnotations(m.Manifest)) } } } } - - // if we have a list of files to render, then check that each of the - // provided files exists in the chart. - if len(showFiles) > 0 { - // This is necessary to ensure consistent manifest ordering when using --show-only - // with globs or directory names. - splitManifests := releaseutil.SplitManifests(manifests.String()) - manifestsKeys := make([]string, 0, len(splitManifests)) - for k := range splitManifests { - manifestsKeys = append(manifestsKeys, k) - } - sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys)) - - manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)") - var manifestsToRender []string - for _, f := range showFiles { - missing := true - // Use linux-style filepath separators to unify user's input path - f = filepath.ToSlash(f) - for _, manifestKey := range manifestsKeys { - manifest := splitManifests[manifestKey] - submatch := manifestNameRegex.FindStringSubmatch(manifest) - if len(submatch) == 0 { + if !orderedRendered { + var manifests bytes.Buffer + fmt.Fprintln(&manifests, strings.TrimSpace(releaseutil.StripHelmInternalAnnotations(rel.Manifest))) + if !client.DisableHooks { + fileWritten := make(map[string]bool) + for _, m := range rel.Hooks { + if skipTests && isTestHook(m) { continue } - manifestName := submatch[1] - // manifest.Name is rendered using linux-style filepath separators on Windows as - // well as macOS/linux. - manifestPathSplit := strings.Split(manifestName, "/") - // manifest.Path is connected using linux-style filepath separators on Windows as - // well as macOS/linux - manifestPath := strings.Join(manifestPathSplit, "/") - - // if the filepath provided matches a manifest path in the - // chart, render that manifest - if matched, _ := filepath.Match(f, manifestPath); !matched { - continue + if client.OutputDir == "" { + fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, releaseutil.StripHelmInternalAnnotations(m.Manifest)) + } else { + newDir := client.OutputDir + if client.UseReleaseName { + newDir = filepath.Join(client.OutputDir, client.ReleaseName) + } + _, err := os.Stat(filepath.Join(newDir, m.Path)) + if err == nil { + fileWritten[m.Path] = true + } + + err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path]) + if err != nil { + return err + } } - manifestsToRender = append(manifestsToRender, manifest) - missing = false } - if missing { - if installErr != nil && settings.Debug { - // assume the manifest itself is too malformed to be rendered - return installErr + } + + // if we have a list of files to render, then check that each of the + // provided files exists in the chart. + if len(showFiles) > 0 { + // This is necessary to ensure consistent manifest ordering when using --show-only + // with globs or directory names. + splitManifests := releaseutil.SplitManifests(manifests.String()) + manifestsKeys := make([]string, 0, len(splitManifests)) + for k := range splitManifests { + manifestsKeys = append(manifestsKeys, k) + } + sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys)) + + manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)") + var manifestsToRender []string + for _, f := range showFiles { + missing := true + // Use linux-style filepath separators to unify user's input path + f = filepath.ToSlash(f) + for _, manifestKey := range manifestsKeys { + manifest := splitManifests[manifestKey] + submatch := manifestNameRegex.FindStringSubmatch(manifest) + if len(submatch) == 0 { + continue + } + manifestName := submatch[1] + // manifest.Name is rendered using linux-style filepath separators on Windows as + // well as macOS/linux. + manifestPathSplit := strings.Split(manifestName, "/") + // manifest.Path is connected using linux-style filepath separators on Windows as + // well as macOS/linux + manifestPath := strings.Join(manifestPathSplit, "/") + + // if the filepath provided matches a manifest path in the + // chart, render that manifest + if matched, _ := filepath.Match(f, manifestPath); !matched { + continue + } + manifestsToRender = append(manifestsToRender, manifest) + missing = false + } + if missing { + if installErr != nil && settings.Debug { + // assume the manifest itself is too malformed to be rendered + return installErr + } + return fmt.Errorf("could not find template %s in chart", f) } - return fmt.Errorf("could not find template %s in chart", f) } + for _, m := range manifestsToRender { + fmt.Fprintf(out, "---\n%s\n", m) + } + } else { + fmt.Fprintf(out, "%s", manifests.String()) } - for _, m := range manifestsToRender { - fmt.Fprintf(out, "---\n%s\n", m) - } - } else { - fmt.Fprintf(out, "%s", manifests.String()) } } @@ -229,6 +255,80 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return cmd } +func renderOrderedTemplate(chrt *chart.Chart, manifest string, out io.Writer) error { + if manifest == "" { + return nil + } + + manifests, err := sequence.ParseStoredManifests(manifest) + if err != nil { + // Return the parse error so the caller falls back to the flat-output + // path, which strips Helm-internal annotations before emitting. Writing + // the raw manifest here would re-emit stripped sequencing annotations + // (e.g. helm.sh/depends-on/resource-groups) and break the invariant that + // `helm template` output stays directly apply-able. No output has been + // written to `out` yet at this point, so the fallback cannot duplicate. + return err + } + plan, err := sequence.Build(chrt, manifests) + if err != nil { + // Return the plan error so the caller falls back to the flat-output + // path, preserving `helm template`'s apply-ready annotation stripping + // contract while still surfacing cycles or invalid multi-group resources. + return err + } + logSequencePlanWarnings(plan) + + // Render into a buffer so we can normalize trailing whitespace to match + // the flat path, which TrimSpaces the whole manifest blob then writes a + // single trailing newline (template.go flat branch). Per-manifest emission + // would otherwise leave one trailing blank line after the final document, + // breaking the HIP-0025 byte-for-byte backwards-compat guarantee for charts + // with no sequencing annotations. + var buf bytes.Buffer + for _, batch := range plan.Batches { + switch batch.Kind { + case sequence.BatchKindGroups: + for _, group := range batch.Groups { + fmt.Fprintf(&buf, "## START resource-group: %s %s\n", sequence.DisplayPath(batch.ChartPath), group.Name) + for _, manifest := range group.Manifests { + writeOrderedManifest(&buf, manifest.Content) + } + fmt.Fprintf(&buf, "## END resource-group: %s %s\n", sequence.DisplayPath(batch.ChartPath), group.Name) + } + case sequence.BatchKindUnsequenced: + for _, manifest := range batch.Manifests() { + writeOrderedManifest(&buf, manifest.Content) + } + } + } + _, err = fmt.Fprintln(out, strings.TrimRight(buf.String(), "\n")) + return err +} + +// logSequencePlanWarnings surfaces non-fatal sequencing-plan warnings with the +// same shape as the action layer's logPlanWarnings. +func logSequencePlanWarnings(plan *sequence.Plan) { + for _, w := range plan.Warnings { + slog.Warn("sequencing: "+w.Message, "chart", w.ChartPath) + } +} + +// writeOrderedManifest emits a single manifest document with the same +// inter-document whitespace as the flat `helm template` path +// (pkg/action/action.go), which writes "---\n# Source: %s\n%s\n" per +// manifest using renderer-supplied content that always ends with a newline. +// SplitManifests strips trailing newlines from intermediate chunks, so we +// normalize to exactly one trailing newline here before adding the format's +// own trailing "\n" — producing "---\nCONTENT\n\n" between docs and keeping +// `helm template` byte-identical to default mode for charts with no +// sequencing annotations (HIP-0025 backwards-compat guarantee, S04-05). +func writeOrderedManifest(out io.Writer, content string) { + stripped := releaseutil.StripHelmInternalAnnotations(content) + stripped = strings.TrimRight(stripped, "\n") + "\n" + fmt.Fprintf(out, "---\n%s\n", stripped) +} + func isTestHook(h *release.Hook) bool { return slices.Contains(h.Events, release.HookTest) } diff --git a/pkg/cmd/template_test.go b/pkg/cmd/template_test.go index b43151413..2c00cdc91 100644 --- a/pkg/cmd/template_test.go +++ b/pkg/cmd/template_test.go @@ -17,9 +17,16 @@ limitations under the License. package cmd import ( + "bytes" "fmt" "path/filepath" + "strings" "testing" + + "github.com/stretchr/testify/require" + + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/release/v1/sequence" ) var chartPath = "testdata/testcharts/subchart" @@ -172,10 +179,175 @@ func TestTemplateCmd(t *testing.T) { cmd: fmt.Sprintf("template '%s' -f %s/extra_values.yaml", chartPath, chartPath), golden: "output/template-subchart-cm-set-file.txt", }, + { + name: "template with ordered wait strategy shows resource group delimiters", + cmd: "template --wait=ordered 'testdata/testcharts/sequenced-chart'", + golden: "output/template-ordered-delimiters.txt", + }, + { + name: "template with ordered wait strategy demotes isolated groups", + cmd: "template --wait=ordered 'testdata/testcharts/sequenced-isolated-chart'", + golden: "output/template-ordered-isolated.txt", + }, } runTestCmd(t, tests) } +func TestTemplateWithoutOrderedWaitHasNoDelimiters(t *testing.T) { + _, out, err := executeActionCommand("template 'testdata/testcharts/sequenced-chart'") + require.NoError(t, err) + require.NotContains(t, out, "## START resource-group:") + require.NotContains(t, out, "## END resource-group:") + require.Contains(t, out, "# Source: sequenced-chart/charts/worker/templates/aa-worker-configmap.yaml") +} + +// TestTemplateOrderedBackwardsCompat asserts the HIP-0025 backwards-compat +// invariant: for a chart with no sequencing annotations, `helm template` and +// `helm template --wait=ordered` produce the same manifest body (after +// stripping the `## START`/`## END` resource-group delimiter lines that only +// the ordered path emits). This is the unit-test analogue of harness scenario +// S04-05; it regressed twice — once when the ordered path's per-manifest +// emission used tighter inter-document whitespace than the flat path, and once +// when the fix for that left an extra trailing blank line at EOF. The trailing +// whitespace is deliberately NOT trimmed here so this test catches both — the +// shell harness's `$()` substitution masks trailing-newline drift, so a stricter +// comparison belongs at the unit level. +func TestTemplateOrderedBackwardsCompat(t *testing.T) { + const chartPath = "testdata/testcharts/alpine" + _, flat, err := executeActionCommand("template t " + chartPath) + require.NoError(t, err) + _, ordered, err := executeActionCommand("template t " + chartPath + " --wait=ordered") + require.NoError(t, err) + + // Strip only the marker lines the ordered path adds; preserve all other + // whitespace including the trailing newline structure. + stripMarkers := func(s string) string { + lines := strings.Split(s, "\n") + out := lines[:0] + for _, l := range lines { + if strings.HasPrefix(l, "## START resource-group:") || + strings.HasPrefix(l, "## END resource-group:") { + continue + } + out = append(out, l) + } + return strings.Join(out, "\n") + } + + require.Equal(t, stripMarkers(flat), stripMarkers(ordered), + "ordered template output must be byte-identical (incl. trailing newline) to flat output for charts without sequencing annotations") +} + +// TestTemplateStripsHelmInternalAnnotations asserts that `helm template` output +// never contains the multi-slash internal annotation key +// `helm.sh/depends-on/resource-groups` — its presence in the K8s API would +// fail annotation-key validation and break `helm template | kubectl apply -f -`. +// Valid sibling keys (single-slash) like `helm.sh/resource-group` must survive. +func TestTemplateStripsHelmInternalAnnotations(t *testing.T) { + const internalKey = "helm.sh/depends-on/resource-groups" + const siblingKey = "helm.sh/resource-group" + + t.Run("flat path", func(t *testing.T) { + _, out, err := executeActionCommand("template 'testdata/testcharts/sequenced-chart'") + require.NoError(t, err) + require.NotContains(t, out, internalKey, "internal annotation must be stripped from flat template output") + require.Contains(t, out, siblingKey, "valid-key sibling annotation must be preserved") + }) + + t.Run("ordered path", func(t *testing.T) { + _, out, err := executeActionCommand("template --wait=ordered 'testdata/testcharts/sequenced-chart'") + require.NoError(t, err) + require.NotContains(t, out, internalKey, "internal annotation must be stripped from ordered template output") + require.Contains(t, out, siblingKey, "valid-key sibling annotation must be preserved") + require.Contains(t, out, "## START resource-group:", "ordered output must still emit group delimiters") + }) +} + +func TestTemplateOrderedMatchesPlan(t *testing.T) { + manifest := `# Source: parent/templates/aa-alpha.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-alpha + annotations: + helm.sh/resource-group: alpha +--- +# Source: parent/templates/bb-beta.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-beta + annotations: + helm.sh/resource-group: beta + helm.sh/depends-on/resource-groups: '["alpha"]' +--- +# Source: parent/templates/cc-gamma.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-gamma + annotations: + helm.sh/resource-group: gamma +--- +# Source: parent/templates/dd-plain.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-plain +--- +# Source: parent/charts/vendored/templates/aa-vendored.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-vendored + annotations: + helm.sh/resource-group: vendored +` + chrt := &chart.Chart{Metadata: &chart.Metadata{Name: "parent"}} + manifests, err := sequence.ParseStoredManifests(manifest) + require.NoError(t, err) + plan, err := sequence.Build(chrt, manifests) + require.NoError(t, err) + + var out bytes.Buffer + require.NoError(t, renderOrderedTemplate(chrt, manifest, &out)) + require.Equal(t, orderedTemplateSequenceFromPlan(plan), orderedTemplateSequenceFromOutput(out.String())) +} + +func orderedTemplateSequenceFromPlan(plan *sequence.Plan) []string { + var ordered []string + for _, batch := range plan.Batches { + switch batch.Kind { + case sequence.BatchKindGroups: + for _, group := range batch.Groups { + ordered = append(ordered, "group:"+sequence.DisplayPath(batch.ChartPath)+" "+group.Name) + for _, manifest := range group.Manifests { + ordered = append(ordered, "source:"+manifest.Name) + } + } + case sequence.BatchKindUnsequenced: + for _, manifest := range batch.Manifests() { + ordered = append(ordered, "source:"+manifest.Name) + } + } + } + return ordered +} + +func orderedTemplateSequenceFromOutput(output string) []string { + var ordered []string + for line := range strings.SplitSeq(output, "\n") { + if marker, ok := strings.CutPrefix(line, "## START resource-group: "); ok { + ordered = append(ordered, "group:"+marker) + continue + } + if source, ok := strings.CutPrefix(line, "# Source: "); ok { + ordered = append(ordered, "source:"+source) + } + } + return ordered +} + func TestTemplateVersionCompletion(t *testing.T) { repoFile := "testdata/helmhome/helm/repositories.yaml" repoCache := "testdata/helmhome/helm/repository" diff --git a/pkg/cmd/testdata/output/dag-sequenced-chart.txt b/pkg/cmd/testdata/output/dag-sequenced-chart.txt new file mode 100644 index 000000000..97144243e --- /dev/null +++ b/pkg/cmd/testdata/output/dag-sequenced-chart.txt @@ -0,0 +1,11 @@ +Chart: sequenced-chart + Subchart batches: + Batch 1: worker + Resource-group batches: + Batch 1: databases + Batch 2: app + Unsequenced (deployed last): ConfigMap/unsequenced-config + Chart: sequenced-chart/charts/worker + Subchart batches: (none) + Resource-group batches: + Batch 1: bootstrap diff --git a/pkg/cmd/testdata/output/dag-sequenced-isolated.txt b/pkg/cmd/testdata/output/dag-sequenced-isolated.txt new file mode 100644 index 000000000..3bcd72b40 --- /dev/null +++ b/pkg/cmd/testdata/output/dag-sequenced-isolated.txt @@ -0,0 +1,6 @@ +Chart: sequenced-isolated-chart + Subchart batches: (none) + Resource-group batches: + Batch 1: alpha + Batch 2: beta + Unsequenced (deployed last): ConfigMap/cm-gamma, ConfigMap/cm-plain diff --git a/pkg/cmd/testdata/output/template-ordered-delimiters.txt b/pkg/cmd/testdata/output/template-ordered-delimiters.txt new file mode 100644 index 000000000..fff815155 --- /dev/null +++ b/pkg/cmd/testdata/output/template-ordered-delimiters.txt @@ -0,0 +1,47 @@ +## START resource-group: sequenced-chart/worker bootstrap +--- +# Source: sequenced-chart/charts/worker/templates/aa-worker-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: worker-bootstrap + annotations: + helm.sh/resource-group: bootstrap +data: + worker: ready + +## END resource-group: sequenced-chart/worker bootstrap +## START resource-group: sequenced-chart databases +--- +# Source: sequenced-chart/templates/aa-databases-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: database-config + annotations: + helm.sh/resource-group: databases +data: + host: localhost + +## END resource-group: sequenced-chart databases +## START resource-group: sequenced-chart app +--- +# Source: sequenced-chart/templates/bb-app-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + annotations: + helm.sh/resource-group: app +data: + db_host: localhost + +## END resource-group: sequenced-chart app +--- +# Source: sequenced-chart/templates/cc-unsequenced-configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: unsequenced-config +data: + key: value diff --git a/pkg/cmd/testdata/output/template-ordered-isolated.txt b/pkg/cmd/testdata/output/template-ordered-isolated.txt new file mode 100644 index 000000000..2cc372173 --- /dev/null +++ b/pkg/cmd/testdata/output/template-ordered-isolated.txt @@ -0,0 +1,45 @@ +## START resource-group: sequenced-isolated-chart alpha +--- +# Source: sequenced-isolated-chart/templates/aa-alpha.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-alpha + annotations: + helm.sh/resource-group: alpha +data: + key: alpha + +## END resource-group: sequenced-isolated-chart alpha +## START resource-group: sequenced-isolated-chart beta +--- +# Source: sequenced-isolated-chart/templates/bb-beta.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-beta + annotations: + helm.sh/resource-group: beta +data: + key: beta + +## END resource-group: sequenced-isolated-chart beta +--- +# Source: sequenced-isolated-chart/templates/dd-plain.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-plain +data: + key: plain + +--- +# Source: sequenced-isolated-chart/templates/cc-gamma.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-gamma + annotations: + helm.sh/resource-group: gamma +data: + key: gamma diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/Chart.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/Chart.yaml new file mode 100644 index 000000000..b1bde1d8c --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: sequenced-chart +description: A test chart with ordered template output +type: application +version: 0.1.0 +dependencies: + - name: worker + version: 0.1.0 + repository: file://charts/worker diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/charts/worker/Chart.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/charts/worker/Chart.yaml new file mode 100644 index 000000000..8f3807ae6 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/charts/worker/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: worker +description: A subchart used to test ordered template output +type: application +version: 0.1.0 diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/charts/worker/templates/aa-worker-configmap.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/charts/worker/templates/aa-worker-configmap.yaml new file mode 100644 index 000000000..263ac9e22 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/charts/worker/templates/aa-worker-configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: worker-bootstrap + annotations: + helm.sh/resource-group: bootstrap +data: + worker: ready diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/templates/aa-databases-configmap.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/aa-databases-configmap.yaml new file mode 100644 index 000000000..8ea614d38 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/aa-databases-configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: database-config + annotations: + helm.sh/resource-group: databases +data: + host: localhost diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/templates/bb-app-configmap.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/bb-app-configmap.yaml new file mode 100644 index 000000000..0472c0318 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/bb-app-configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + annotations: + helm.sh/resource-group: app + helm.sh/depends-on/resource-groups: '["databases"]' +data: + db_host: localhost diff --git a/pkg/cmd/testdata/testcharts/sequenced-chart/templates/cc-unsequenced-configmap.yaml b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/cc-unsequenced-configmap.yaml new file mode 100644 index 000000000..03454569c --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-chart/templates/cc-unsequenced-configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: unsequenced-config +data: + key: value diff --git a/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/Chart.yaml b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/Chart.yaml new file mode 100644 index 000000000..96fc7e056 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: sequenced-isolated-chart +description: A test chart with an isolated resource group +type: application +version: 0.1.0 diff --git a/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/aa-alpha.yaml b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/aa-alpha.yaml new file mode 100644 index 000000000..bc8c2c291 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/aa-alpha.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-alpha + annotations: + helm.sh/resource-group: alpha +data: + key: alpha diff --git a/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/bb-beta.yaml b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/bb-beta.yaml new file mode 100644 index 000000000..c52b63bf7 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/bb-beta.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-beta + annotations: + helm.sh/resource-group: beta + helm.sh/depends-on/resource-groups: '["alpha"]' +data: + key: beta diff --git a/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/cc-gamma.yaml b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/cc-gamma.yaml new file mode 100644 index 000000000..99a4284d1 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/cc-gamma.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-gamma + annotations: + helm.sh/resource-group: gamma +data: + key: gamma diff --git a/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/dd-plain.yaml b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/dd-plain.yaml new file mode 100644 index 000000000..7423a84ef --- /dev/null +++ b/pkg/cmd/testdata/testcharts/sequenced-isolated-chart/templates/dd-plain.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-plain +data: + key: plain diff --git a/pkg/cmd/uninstall.go b/pkg/cmd/uninstall.go index bfecd4741..8794ede31 100644 --- a/pkg/cmd/uninstall.go +++ b/pkg/cmd/uninstall.go @@ -81,7 +81,7 @@ func newUninstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVar(&client.DeletionPropagation, "cascade", "background", "Must be \"background\", \"orphan\", or \"foreground\". Selects the deletion cascading strategy for the dependents. Defaults to background. Use \"foreground\" with --wait to ensure resources with finalizers are fully deleted before returning.") f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") f.StringVar(&client.Description, "description", "", "add a custom description") - AddWaitFlag(cmd, &client.WaitStrategy) + AddOrderedWaitFlag(cmd, &client.WaitStrategy) return cmd } diff --git a/pkg/cmd/uninstall_test.go b/pkg/cmd/uninstall_test.go index ce436e68c..77f8cdb50 100644 --- a/pkg/cmd/uninstall_test.go +++ b/pkg/cmd/uninstall_test.go @@ -73,6 +73,12 @@ func TestUninstall(t *testing.T) { golden: "output/uninstall-wait.txt", rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, }, + { + name: "wait ordered", + cmd: "uninstall aeneas --wait=ordered", + golden: "output/uninstall-wait.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, + }, { name: "uninstall without release", cmd: "uninstall", diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 9ec4f4ef2..e8a7b9591 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -139,6 +139,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { instClient.SkipCRDs = client.SkipCRDs instClient.Timeout = client.Timeout instClient.WaitStrategy = client.WaitStrategy + instClient.ReadinessTimeout = client.ReadinessTimeout instClient.WaitForJobs = client.WaitForJobs instClient.Devel = client.Devel instClient.Namespace = client.Namespace @@ -305,7 +306,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { addValueOptionsFlags(f, valueOpts) bindOutputFlag(cmd, &outfmt) bindPostRenderFlag(cmd, &client.PostRenderer, settings) - AddWaitFlag(cmd, &client.WaitStrategy) + AddOrderedWaitFlag(cmd, &client.WaitStrategy) + addReadinessTimeoutFlag(f, &client.ReadinessTimeout) cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") diff --git a/pkg/kube/client.go b/pkg/kube/client.go index 7b02f47ef..5f81b061c 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -32,6 +32,7 @@ import ( "sync" jsonpatch "github.com/evanphx/json-patch/v5" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine" v1 "k8s.io/api/core/v1" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" @@ -117,6 +118,10 @@ const ( // HookOnlyStrategy: wait only for hook Pods/Jobs to complete; does not wait for general chart resources. HookOnlyStrategy WaitStrategy = "hookOnly" + + // OrderedWaitStrategy: action-layer sequencing uses ordered batches while the kube layer + // reuses status watching for per-batch readiness checks. + OrderedWaitStrategy WaitStrategy = "ordered" ) type FieldValidationDirective string @@ -166,6 +171,7 @@ func (c *Client) newStatusWatcher(opts ...WaitOption) (*statusWaiter, error) { if waitContext == nil { waitContext = c.WaitContext } + readers := append([]engine.StatusReader(nil), o.statusReaders...) sw := &statusWaiter{ restMapper: restMapper, client: dynamicClient, @@ -174,7 +180,8 @@ func (c *Client) newStatusWatcher(opts ...WaitOption) (*statusWaiter, error) { waitCtx: o.waitCtx, waitWithJobsCtx: o.waitWithJobsCtx, waitForDeleteCtx: o.waitForDeleteCtx, - readers: o.statusReaders, + readers: readers, + customReadiness: o.enableCustomReadinessStatusReader, } sw.SetLogger(c.Logger().Handler()) return sw, nil @@ -200,10 +207,12 @@ func (c *Client) GetWaiterWithOptions(strategy WaitStrategy, opts ...WaitOption) return nil, err } return &hookOnlyWaiter{sw: sw}, nil + case OrderedWaitStrategy: + return c.newStatusWatcher(opts...) case "": - return nil, errors.New("wait strategy not set. Choose one of: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy)) + return nil, errors.New("wait strategy not set. Choose one of: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy) + ", " + string(OrderedWaitStrategy)) default: - return nil, errors.New("unknown wait strategy (s" + string(strategy) + "). Valid values are: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy)) + return nil, errors.New("unknown wait strategy (" + string(strategy) + "). Valid values are: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy) + ", " + string(OrderedWaitStrategy)) } } diff --git a/pkg/kube/client_wait_strategy_test.go b/pkg/kube/client_wait_strategy_test.go new file mode 100644 index 000000000..19e532cb5 --- /dev/null +++ b/pkg/kube/client_wait_strategy_test.go @@ -0,0 +1,54 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kube + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientGetWaiterWithOptions_OrderedWaitStrategy(t *testing.T) { + client := newTestClient(t) + + waiter, err := client.GetWaiterWithOptions(OrderedWaitStrategy) + require.NoError(t, err) + assert.IsType(t, &statusWaiter{}, waiter) +} + +func TestClientGetWaiterWithOptions_UnknownStrategyListsOrdered(t *testing.T) { + client := newTestClient(t) + + waiter, err := client.GetWaiterWithOptions(WaitStrategy("bogus")) + require.Error(t, err) + assert.Nil(t, waiter) + assert.Contains(t, err.Error(), string(OrderedWaitStrategy)) +} + +func TestNewStatusWatcherCustomReadinessFlag(t *testing.T) { + client := newTestClient(t) + + w, err := client.newStatusWatcher(WithCustomReadinessStatusReader()) + require.NoError(t, err) + assert.True(t, w.customReadiness) + assert.Empty(t, w.readers, "custom readiness must not inject a reader into the shared reader list") + + w, err = client.newStatusWatcher() + require.NoError(t, err) + assert.False(t, w.customReadiness) +} diff --git a/pkg/kube/custom_readiness_status_reader.go b/pkg/kube/custom_readiness_status_reader.go new file mode 100644 index 000000000..79c9993a7 --- /dev/null +++ b/pkg/kube/custom_readiness_status_reader.go @@ -0,0 +1,141 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kube // import "helm.sh/helm/v4/pkg/kube" + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event" + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/cli-utils/pkg/object" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type customReadinessStatusReader struct { + logger *slog.Logger + // fallback is the reader chain the enclosing wait method would use if + // custom readiness were disabled. Resources that do not carry both + // readiness annotations are delegated to it, so built-in readers (the + // composite Deployment/ReplicaSet/StatefulSet readers, the Job reader in + // WaitWithJobs) and caller-supplied readers keep their semantics. + fallback engine.StatusReader + // warnedExpressions dedups authoring-mistake warnings so a bad expression + // logs once per resource rather than on every watch event. Keys are + // "|". The reader is invoked + // concurrently by the status watcher, hence sync.Map. + warnedExpressions sync.Map +} + +// newCustomReadinessStatusReader wraps fallback with per-resource custom +// readiness evaluation. fallback must be non-nil and must support every +// GroupKind this reader can receive (every chain built in statuswait.go +// terminates in a generic reader that supports all GroupKinds). +func newCustomReadinessStatusReader(logger *slog.Logger, fallback engine.StatusReader) engine.StatusReader { + if logger == nil { + logger = slog.New(slog.DiscardHandler) + } + return &customReadinessStatusReader{logger: logger, fallback: fallback} +} + +// Supports returns true for every GroupKind: annotated resources of any kind +// are evaluated here, and everything else is delegated to the fallback chain. +func (*customReadinessStatusReader) Supports(schema.GroupKind) bool { + return true +} + +func (r *customReadinessStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: resource.GroupKind.Group, + Kind: resource.GroupKind.Kind, + }) + if err := reader.Get(ctx, client.ObjectKey{Namespace: resource.Namespace, Name: resource.Name}, u); err != nil { + return nil, err + } + return r.readStatus(ctx, reader, resource, u) +} + +func (r *customReadinessStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) { + identifier, err := object.RuntimeToObjMeta(resource) + if err != nil { + return nil, err + } + return r.readStatus(ctx, reader, identifier, resource) +} + +func (r *customReadinessStatusReader) readStatus(ctx context.Context, reader engine.ClusterReader, identifier object.ObjMetadata, resource *unstructured.Unstructured) (*event.ResourceStatus, error) { + annotations := resource.GetAnnotations() + + successExprs, err := parseReadinessExpressions(annotations[AnnotationReadinessSuccess]) + if err != nil { + return nil, fmt.Errorf("parsing %s for %s/%s: %w", AnnotationReadinessSuccess, identifier.Namespace, identifier.Name, err) + } + failureExprs, err := parseReadinessExpressions(annotations[AnnotationReadinessFailure]) + if err != nil { + return nil, fmt.Errorf("parsing %s for %s/%s: %w", AnnotationReadinessFailure, identifier.Namespace, identifier.Name, err) + } + + result, useKstatus, warnings, err := EvaluateCustomReadiness(resource, successExprs, failureExprs) + if err != nil { + return nil, err + } + if useKstatus { + // Missing or partial annotations: this resource does not opt in to + // custom readiness, so the default reader chain decides its status. + return r.fallback.ReadStatusForObject(ctx, reader, resource) + } + + for _, w := range warnings { + key := identifier.String() + "|" + w.Expression + if _, alreadyWarned := r.warnedExpressions.LoadOrStore(key, struct{}{}); alreadyWarned { + continue + } + r.logger.Warn("custom readiness expression cannot be evaluated; treating condition as not met", + "kind", identifier.GroupKind.Kind, + "namespace", identifier.Namespace, + "name", identifier.Name, + "expression", w.Expression, + "detail", w.Detail, + ) + } + + st, message := status.InProgressStatus, "waiting for custom readiness conditions" + switch result { + case ReadinessReady: + st, message = status.CurrentStatus, "custom readiness conditions met" + case ReadinessFailed: + st, message = status.FailedStatus, "custom readiness failure condition met" + default: + // ReadinessPending (and any future status) keep the in-progress + // defaults; name any skipped expression so an eventual wait timeout + // error is self-explanatory. + if len(warnings) > 0 { + message = fmt.Sprintf("waiting for custom readiness conditions (%d expression(s) skipped: %s)", len(warnings), warnings[0].Detail) + } + } + return &event.ResourceStatus{ + Identifier: identifier, + Status: st, + Message: message, + }, nil +} diff --git a/pkg/kube/custom_readiness_status_reader_test.go b/pkg/kube/custom_readiness_status_reader_test.go new file mode 100644 index 000000000..af4f0eca1 --- /dev/null +++ b/pkg/kube/custom_readiness_status_reader_test.go @@ -0,0 +1,617 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kube + +import ( + "bytes" + "context" + "log/slog" + "strings" + "testing" + "time" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/clusterreader" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders" + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/cli-utils/pkg/object" + "github.com/fluxcd/cli-utils/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes/scheme" +) + +func TestStatusWaitWithCustomReadinessReader(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manifest string + expectErrStrs []string + notExpectErrStrs []string + }{ + { + name: "custom readiness makes resource current", + manifest: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: ready-config + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} == \"Ready\""]' + helm.sh/readiness-failure: '["{.phase} == \"Failed\""]' +status: + phase: Ready +`, + }, + { + name: "custom readiness failure stops wait", + manifest: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: failed-config + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} == \"Ready\""]' + helm.sh/readiness-failure: '["{.phase} == \"Failed\""]' +status: + phase: Failed +`, + expectErrStrs: []string{"resource ConfigMap/default/failed-config not ready. status: Failed"}, + }, + { + name: "resources without annotations fall back to kstatus", + manifest: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: current-config + namespace: default +`, + }, + { + // END-TO-END pin for hip-0025-gy6: an ordering operator on a + // non-numeric value must leave the resource InProgress (and let + // the wait time out per-resource) — NOT kill the status reporter + // and abort the whole batch. + name: "ordering operator on non-numeric value does not abort the wait", + manifest: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: bad-ordering + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} > \"Ready\""]' + helm.sh/readiness-failure: '["{.phase} == \"Failed\""]' +status: + phase: Running +`, + expectErrStrs: []string{"resource ConfigMap/default/bad-ordering not ready. status: InProgress", "skipped"}, + notExpectErrStrs: []string{"failed to compute object status"}, + }, + { + name: "ordering on numeric string value compares numerically", + manifest: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: numeric-string + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} >= 5"]' + helm.sh/readiness-failure: '["{.failed} >= 1"]' +status: + phase: "5" +`, + }, + { + name: "incomparable expression skipped when another success condition is met", + manifest: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: mixed-success + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} > \"Ready\"", "{.phase} == \"Running\""]' + helm.sh/readiness-failure: '["{.phase} == \"Failed\""]' +status: + phase: Running +`, + }, + { + // Partial annotations (only one of success/failure): custom + // evaluation must NOT engage; the resource is judged by the + // default chain (a bare ConfigMap is Current), even though the + // success expression is unmet. + name: "partial annotation falls back to default readiness", + manifest: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: partial-config + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} == \"Ready\""]' +status: + phase: NotReady +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := newTestClient(t) + fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap")) + waiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + customReadiness: true, + } + + objs := getRuntimeObjFromManifests(t, []string{tt.manifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace())) + } + + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := waiter.Wait(resourceList, time.Second) + if tt.expectErrStrs == nil { + require.NoError(t, err) + return + } + require.Error(t, err) + for _, expected := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expected) + } + for _, unexpected := range tt.notExpectErrStrs { + assert.NotContains(t, err.Error(), unexpected) + } + }) + } +} + +func TestWithCustomReadinessStatusReader(t *testing.T) { + opts := &waitOptions{} + WithCustomReadinessStatusReader()(opts) + assert.True(t, opts.enableCustomReadinessStatusReader) +} + +func TestCustomReadinessStatusReaderReadStatusForObject(t *testing.T) { + reader := newCustomReadinessStatusReader(nil, statusreaders.NewDefaultStatusReader(testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap")))) + u := &unstructured.Unstructured{} + u.SetAPIVersion("v1") + u.SetKind("ConfigMap") + u.SetName("ready-config") + u.SetNamespace("default") + u.SetAnnotations(map[string]string{ + AnnotationReadinessSuccess: `["{.phase} == \"Ready\""]`, + AnnotationReadinessFailure: `["{.phase} == \"Failed\""]`, + }) + require.NoError(t, unstructured.SetNestedField(u.Object, "Ready", "status", "phase")) + + result, err := reader.ReadStatusForObject(context.Background(), nil, u) + require.NoError(t, err) + assert.Equal(t, status.CurrentStatus, result.Status) +} + +func TestCustomReadinessStatusReaderWarnsOnceForIncomparableExpression(t *testing.T) { + var buf bytes.Buffer + reader := newCustomReadinessStatusReader(slog.New(slog.NewTextHandler(&buf, nil)), statusreaders.NewDefaultStatusReader(testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap")))) + + u := &unstructured.Unstructured{} + u.SetAPIVersion("v1") + u.SetKind("ConfigMap") + u.SetName("bad-ordering") + u.SetNamespace("default") + u.SetAnnotations(map[string]string{ + AnnotationReadinessSuccess: `["{.phase} > \"Ready\""]`, + AnnotationReadinessFailure: `["{.failed} >= 1"]`, + }) + require.NoError(t, unstructured.SetNestedField(u.Object, "Running", "status", "phase")) + + for range 2 { + result, err := reader.ReadStatusForObject(context.Background(), nil, u) + require.NoError(t, err) + assert.Equal(t, status.InProgressStatus, result.Status) + assert.Contains(t, result.Message, "skipped") + assert.Contains(t, result.Message, "ordering operators") + } + + logged := buf.String() + assert.Equal(t, 1, strings.Count(logged, "treating condition as not met"), + "warning must be deduplicated to once per resource+expression") + assert.Contains(t, logged, "bad-ordering") + assert.Contains(t, logged, "ordering operators") +} + +// The composite Deployment reader is the fallback observable: it populates +// GeneratedResources with the child ReplicaSets it aggregated, which neither +// the generic kstatus reader nor a bare status.Compute ever does. (In +// cli-utils v1.2.2 the composite readers compute the same top-level status as +// status.Compute, so GeneratedResources is the distinguishing signal that the +// default chain — not an internal kstatus shortcut — handled the resource.) +func TestCustomReadinessReaderDelegatesToFallbackChain(t *testing.T) { + t.Parallel() + + deploymentYAML := func(annotations string) string { + return ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: default + generation: 1` + annotations + ` +spec: + replicas: 1 + selector: + matchLabels: + app: web +status: + observedGeneration: 1 + replicas: 1 + updatedReplicas: 1 + readyReplicas: 1 + availableReplicas: 1 + conditions: + - type: Available + status: "True" + reason: MinimumReplicasAvailable +` + } + replicaSetManifest := ` +apiVersion: apps/v1 +kind: ReplicaSet +metadata: + name: web-abc123 + namespace: default + generation: 1 + labels: + app: web +spec: + replicas: 1 + selector: + matchLabels: + app: web +status: + observedGeneration: 1 + replicas: 1 + readyReplicas: 1 + availableReplicas: 1 +` + + tests := []struct { + name string + annotations string + expectStatus status.Status + expectGeneratedResults bool + }{ + { + // No annotations: must be delegated to the composite chain. + name: "non-annotated deployment handled by composite reader", + annotations: "", + expectStatus: status.CurrentStatus, + expectGeneratedResults: true, + }, + { + // One annotation only: partial config must also delegate. + name: "partial-annotated deployment handled by composite reader", + annotations: ` + annotations: + helm.sh/readiness-success: '["{.readyReplicas} >= 1"]'`, + expectStatus: status.CurrentStatus, + expectGeneratedResults: true, + }, + { + // Both annotations: custom evaluation engages (unmet expression + // keeps it InProgress) and the fallback chain is bypassed. + name: "fully annotated deployment evaluated by expressions", + annotations: ` + annotations: + helm.sh/readiness-success: '["{.readyReplicas} >= 2"]' + helm.sh/readiness-failure: '["{.failed} >= 1"]'`, + expectStatus: status.InProgressStatus, + expectGeneratedResults: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fakeMapper := testutil.NewFakeRESTMapper( + appsv1.SchemeGroupVersion.WithKind("Deployment"), + appsv1.SchemeGroupVersion.WithKind("ReplicaSet"), + v1.SchemeGroupVersion.WithKind("Pod"), + ) + fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme) + objs := getRuntimeObjFromManifests(t, []string{deploymentYAML(tt.annotations), replicaSetManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace())) + } + clusterReader := &clusterreader.DynamicClusterReader{ + DynamicClient: fakeClient, + Mapper: fakeMapper, + } + reader := newCustomReadinessStatusReader(nil, statusreaders.NewStatusReader(fakeMapper)) + + deployment := objs[0].(*unstructured.Unstructured) + result, err := reader.ReadStatusForObject(context.Background(), clusterReader, deployment) + require.NoError(t, err) + assert.Equal(t, tt.expectStatus, result.Status) + if tt.expectGeneratedResults { + require.NotEmpty(t, result.GeneratedResources, + "fallback composite reader must aggregate the child ReplicaSet") + assert.Equal(t, "web-abc123", result.GeneratedResources[0].Identifier.Name) + } else { + assert.Empty(t, result.GeneratedResources) + assert.Contains(t, result.Message, "custom readiness") + } + + // The identifier-based entry point must route identically. + id, err := object.RuntimeToObjMeta(deployment) + require.NoError(t, err) + byID, err := reader.ReadStatus(context.Background(), clusterReader, id) + require.NoError(t, err) + assert.Equal(t, tt.expectStatus, byID.Status) + assert.Equal(t, tt.expectGeneratedResults, len(byID.GeneratedResources) > 0) + }) + } +} + +// A plain (non-annotated) Job in a batch that enables custom readiness must +// keep helm's completion gating: helm's job reader holds a started Job +// InProgress until a Complete/Failed condition, while plain kstatus reports a +// started Job as Current. Before the fix the custom readiness reader shadowed +// the job reader and the wait returned early. +func TestStatusWaitWithJobsMixedCustomReadiness(t *testing.T) { + t.Parallel() + + startedJobManifest := ` +apiVersion: batch/v1 +kind: Job +metadata: + name: slow-job + namespace: default + generation: 1 +status: + startTime: 2025-02-06T16:34:20-05:00 + active: 1 + ready: 1 +` + completeJobManifest := ` +apiVersion: batch/v1 +kind: Job +metadata: + name: slow-job + namespace: default + generation: 1 +status: + succeeded: 1 + conditions: + - type: Complete + status: "True" +` + readyConfigMapManifest := ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: gated-config + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} == \"Ready\""]' + helm.sh/readiness-failure: '["{.phase} == \"Failed\""]' +status: + phase: Ready +` + pendingConfigMapManifest := ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: gated-config + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} == \"Ready\""]' + helm.sh/readiness-failure: '["{.phase} == \"Failed\""]' +status: + phase: Pending +` + + tests := []struct { + name string + manifests []string + expectErrStrs []string + notExpectErrStrs []string + }{ + { + name: "plain job keeps completion gating in mixed batch", + manifests: []string{startedJobManifest, readyConfigMapManifest}, + expectErrStrs: []string{ + "resource Job/default/slow-job not ready. status: InProgress", + }, + notExpectErrStrs: []string{"ConfigMap"}, + }, + { + name: "annotated resource keeps expression gating in mixed batch", + manifests: []string{completeJobManifest, pendingConfigMapManifest}, + expectErrStrs: []string{ + "resource ConfigMap/default/gated-config not ready. status: InProgress", + }, + notExpectErrStrs: []string{"Job/default/slow-job"}, + }, + { + name: "batch completes when job is complete and expression met", + manifests: []string{completeJobManifest, readyConfigMapManifest}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := newTestClient(t) + fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + batchv1.SchemeGroupVersion.WithKind("Job"), + v1.SchemeGroupVersion.WithKind("ConfigMap"), + ) + waiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + customReadiness: true, + } + objs := getRuntimeObjFromManifests(t, tt.manifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace())) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := waiter.WaitWithJobs(resourceList, 2*time.Second) + if tt.expectErrStrs == nil { + require.NoError(t, err) + return + } + require.Error(t, err) + for _, expected := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expected) + } + for _, unexpected := range tt.notExpectErrStrs { + assert.NotContains(t, err.Error(), unexpected) + } + }) + } +} + +// WatchUntilReady is the hook path: hook readiness must be governed by the +// hook readers (helm's job/pod readers + always-ready for everything else), +// never by the readiness annotations, even when custom readiness is enabled +// on the waiter. Two directions: +// - a started-but-incomplete hook Job must still time out (kstatus would +// wrongly report a started Job as Current); +// - an annotated non-Job hook must complete even though its success +// expression is unmet (annotations are ignored for hooks). +func TestWatchUntilReadyIgnoresCustomReadiness(t *testing.T) { + t.Parallel() + + startedJobManifest := ` +apiVersion: batch/v1 +kind: Job +metadata: + name: hook-job + namespace: default + generation: 1 +status: + startTime: 2025-02-06T16:34:20-05:00 + active: 1 +` + completeJobManifest := ` +apiVersion: batch/v1 +kind: Job +metadata: + name: hook-job + namespace: default + generation: 1 +status: + succeeded: 1 + conditions: + - type: Complete + status: "True" +` + annotatedConfigMapManifest := ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-config + namespace: default + annotations: + helm.sh/readiness-success: '["{.phase} == \"Ready\""]' + helm.sh/readiness-failure: '["{.phase} == \"Failed\""]' +status: + phase: Pending +` + + tests := []struct { + name string + manifests []string + expectErrStrs []string + }{ + { + name: "incomplete hook job still gated by hook job reader", + manifests: []string{startedJobManifest}, + expectErrStrs: []string{ + "resource Job/default/hook-job not ready. status: InProgress", + }, + }, + { + name: "annotated hook resource ignores readiness annotations", + manifests: []string{annotatedConfigMapManifest}, + }, + { + name: "complete hook job succeeds", + manifests: []string{completeJobManifest}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := newTestClient(t) + fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + batchv1.SchemeGroupVersion.WithKind("Job"), + v1.SchemeGroupVersion.WithKind("ConfigMap"), + ) + waiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + customReadiness: true, + } + objs := getRuntimeObjFromManifests(t, tt.manifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace())) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := waiter.WatchUntilReady(resourceList, 2*time.Second) + if tt.expectErrStrs == nil { + require.NoError(t, err) + return + } + require.Error(t, err) + for _, expected := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expected) + } + }) + } +} diff --git a/pkg/kube/options.go b/pkg/kube/options.go index 3326c284b..49bd49670 100644 --- a/pkg/kube/options.go +++ b/pkg/kube/options.go @@ -72,11 +72,19 @@ func WithKStatusReaders(readers ...engine.StatusReader) WaitOption { } } +// WithCustomReadinessStatusReader enables custom readiness evaluation while waiting. +func WithCustomReadinessStatusReader() WaitOption { + return func(wo *waitOptions) { + wo.enableCustomReadinessStatusReader = true + } +} + type waitOptions struct { - ctx context.Context - watchUntilReadyCtx context.Context - waitCtx context.Context - waitWithJobsCtx context.Context - waitForDeleteCtx context.Context - statusReaders []engine.StatusReader + ctx context.Context + watchUntilReadyCtx context.Context + waitCtx context.Context + waitWithJobsCtx context.Context + waitForDeleteCtx context.Context + statusReaders []engine.StatusReader + enableCustomReadinessStatusReader bool } diff --git a/pkg/kube/readiness.go b/pkg/kube/readiness.go new file mode 100644 index 000000000..f8403b8b7 --- /dev/null +++ b/pkg/kube/readiness.go @@ -0,0 +1,369 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kube // import "helm.sh/helm/v4/pkg/kube" + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/util/jsonpath" + + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +// The custom-readiness annotation keys' canonical home is +// pkg/release/v1/util, next to the other sequencing annotations, so the pure +// plan builder (pkg/release/v1/sequence) can read them without importing +// pkg/kube. These value-identical aliases keep existing kube callers working. +const ( + // AnnotationReadinessSuccess declares custom readiness success conditions. + AnnotationReadinessSuccess = releaseutil.AnnotationReadinessSuccess + + // AnnotationReadinessFailure declares custom readiness failure conditions. + // Failure conditions take precedence over success conditions. + AnnotationReadinessFailure = releaseutil.AnnotationReadinessFailure +) + +// ReadinessStatus represents the evaluated readiness of a resource. +type ReadinessStatus int + +const ( + // ReadinessPending means neither success nor failure conditions are met. + ReadinessPending ReadinessStatus = iota + // ReadinessReady means at least one success condition is true. + ReadinessReady + // ReadinessFailed means at least one failure condition is true. + ReadinessFailed +) + +func (r ReadinessStatus) String() string { + switch r { + case ReadinessPending: + return "Pending" + case ReadinessReady: + return "Ready" + case ReadinessFailed: + return "Failed" + default: + return "Unknown" + } +} + +// ExpressionWarning describes a readiness expression that could not be +// evaluated against the observed object state and was therefore treated as +// "condition not met" rather than failing the whole wait. +type ExpressionWarning struct { + // Expression is the original annotation expression, e.g. `{.phase} > "Ready"`. + Expression string + // Detail is a human-readable reason, including the observed value. + Detail string +} + +// errIncomparableOrdering marks a comparison that cannot succeed against the +// observed values: an ordering operator (<, <=, >, >=) applied to operands +// that are not both numeric. The actual value's type is only known at runtime, +// so lint cannot always catch this. EvaluateCustomReadiness downgrades it to +// an ExpressionWarning — the expression counts as "condition not met", +// mirroring how a missing status key is treated — instead of returning an +// error that would abort the entire wait. +var errIncomparableOrdering = errors.New("ordering operators (<, <=, >, >=) require numeric values") + +// EvaluateCustomReadiness evaluates custom readiness expressions against a +// resource's .status field. +// +// When both successExprs and failureExprs are empty or nil, or when only one +// side is provided, the caller should fall back to kstatus and useKstatus will +// be returned as true. +// +// Expressions that cannot be evaluated against the observed values (an +// ordering operator applied to non-numeric operands) are treated as +// "condition not met" and reported in the returned warnings rather than as an +// error. Expressions after the first met condition are not evaluated. All +// other evaluation problems (invalid JSONPath, unsupported operator) are +// returned as errors. +func EvaluateCustomReadiness(obj *unstructured.Unstructured, successExprs, failureExprs []string) (ReadinessStatus, bool, []ExpressionWarning, error) { + hasSuccess := len(successExprs) > 0 + hasFailure := len(failureExprs) > 0 + + if !hasSuccess || !hasFailure { + // Partial annotations: fall back to kstatus. The warning for this + // case is emitted once at plan-build time by the sequence plan + // builder (pkg/release/v1/sequence), not here (which runs on every + // poll tick). + return ReadinessPending, true, nil, nil + } + + statusObj, found, err := unstructured.NestedMap(obj.Object, "status") + if err != nil || !found { + return ReadinessPending, false, nil, nil + } + + statusWrapper := map[string]any{"status": statusObj} + + var warnings []ExpressionWarning + + for _, expr := range failureExprs { + met, err := evaluateExpression(statusWrapper, expr) + if err != nil { + if errors.Is(err, errIncomparableOrdering) { + warnings = append(warnings, ExpressionWarning{Expression: expr, Detail: err.Error()}) + continue + } + return ReadinessPending, false, warnings, fmt.Errorf("evaluating failure expression %q: %w", expr, err) + } + if met { + return ReadinessFailed, false, warnings, nil + } + } + + for _, expr := range successExprs { + met, err := evaluateExpression(statusWrapper, expr) + if err != nil { + if errors.Is(err, errIncomparableOrdering) { + warnings = append(warnings, ExpressionWarning{Expression: expr, Detail: err.Error()}) + continue + } + return ReadinessPending, false, warnings, fmt.Errorf("evaluating success expression %q: %w", expr, err) + } + if met { + return ReadinessReady, false, warnings, nil + } + } + + return ReadinessPending, false, warnings, nil +} + +func parseReadinessExpressions(annotation string) ([]string, error) { + annotation = strings.TrimSpace(annotation) + if annotation == "" { + return nil, nil + } + + var exprs []string + if err := json.Unmarshal([]byte(annotation), &exprs); err != nil { + return nil, fmt.Errorf("parsing readiness annotation JSON: %w", err) + } + + for i, expr := range exprs { + exprs[i] = strings.TrimSpace(expr) + } + + return exprs, nil +} + +// ValidateReadinessExpressions parses a readiness annotation value (a JSON +// array of "{} " expressions) and verifies that every +// expression is well-formed and compiles to a valid JSONPath. It does not +// evaluate anything against a live object — it is the static-validation entry +// point used by `helm lint` so malformed expressions are caught at author time +// rather than silently failing readiness at install time. An empty or absent +// annotation is valid (returns nil). It also rejects ordering operators +// (<, <=, >, >=) whose comparison value is not numeric, since such a +// comparison can never evaluate at runtime. +func ValidateReadinessExpressions(annotation string) error { + exprs, err := parseReadinessExpressions(annotation) + if err != nil { + return err + } + + for _, expr := range exprs { + path, op, val, err := parseReadinessExpression(expr) + if err != nil { + return err + } + template, err := readinessJSONPath(path) + if err != nil { + return err + } + if err := jsonpath.New("readiness").Parse(template); err != nil { + return fmt.Errorf("invalid JSONPath %q: %w", template, err) + } + // An ordering comparison can only ever succeed numerically, and the + // literal's type is statically known: a non-numeric comparison value + // with <, <=, >, >= is a definite authoring error. + switch op { + case "<", "<=", ">", ">=": + if _, ok := tryParseFloat(trimReadinessValue(val)); !ok { + return fmt.Errorf("expression %q: ordering operator %q requires a numeric comparison value, got %s", expr, op, val) + } + } + } + + return nil +} + +func evaluateExpression(obj map[string]any, expr string) (bool, error) { + path, op, rawVal, err := parseReadinessExpression(expr) + if err != nil { + return false, err + } + + template, err := readinessJSONPath(path) + if err != nil { + return false, err + } + + jp := jsonpath.New("readiness") + if err := jp.Parse(template); err != nil { + return false, fmt.Errorf("invalid JSONPath %q: %w", template, err) + } + + var buf bytes.Buffer + if err := jp.Execute(&buf, obj); err != nil { + return false, nil + } + + return compareValues(strings.TrimSpace(buf.String()), op, rawVal) +} + +func readinessJSONPath(path string) (string, error) { + switch { + case strings.HasPrefix(path, ".status.") || strings.HasPrefix(path, ".status["): + // Already rooted at .status — avoid double-prefixing + return "{" + path + "}", nil + case strings.HasPrefix(path, "."): + return "{.status" + path + "}", nil + case strings.HasPrefix(path, "["): + return "{.status" + path + "}", nil + default: + return "", fmt.Errorf("invalid JSONPath %q: path must start with . or [", path) + } +} + +// parseReadinessExpression parses "{} " into its parts. +func parseReadinessExpression(expr string) (path, op, val string, err error) { + expr = strings.TrimSpace(expr) + if expr == "" { + return "", "", "", errors.New("expression cannot be empty") + } + if !strings.HasPrefix(expr, "{") { + return "", "", "", fmt.Errorf("expression must start with {}: %q", expr) + } + + closeBrace := strings.Index(expr, "}") + if closeBrace < 0 { + return "", "", "", fmt.Errorf("expression missing closing }: %q", expr) + } + + path = strings.TrimSpace(expr[1:closeBrace]) + if path == "" { + return "", "", "", fmt.Errorf("expression missing JSONPath: %q", expr) + } + + rest := strings.TrimSpace(expr[closeBrace+1:]) + if rest == "" { + return "", "", "", fmt.Errorf("expression missing operator and value: %q", expr) + } + + parts := strings.Fields(rest) + if len(parts) == 0 { + return "", "", "", fmt.Errorf("expression missing operator: %q", expr) + } + + switch parts[0] { + case "==", "!=", "<", "<=", ">", ">=": + op = parts[0] + default: + return "", "", "", fmt.Errorf("unsupported operator %q in expression %q", parts[0], expr) + } + + val = strings.TrimSpace(rest[len(op):]) + if val == "" { + return "", "", "", fmt.Errorf("expression missing comparison value: %q", expr) + } + + return path, op, val, nil +} + +func compareValues(actual, op, expected string) (bool, error) { + actual = strings.TrimSpace(actual) + expected = trimReadinessValue(strings.TrimSpace(expected)) + + if actualFloat, ok := tryParseFloat(actual); ok { + if expectedFloat, ok := tryParseFloat(expected); ok { + return compareNumeric(actualFloat, op, expectedFloat) + } + } + + if actualBool, actualIsBool := tryParseBool(actual); actualIsBool { + if expectedBool, expectedIsBool := tryParseBool(expected); expectedIsBool { + switch op { + case "==": + return actualBool == expectedBool, nil + case "!=": + return actualBool != expectedBool, nil + default: + return false, fmt.Errorf("cannot compare %q %s %q: %w", actual, op, expected, errIncomparableOrdering) + } + } + } + + switch op { + case "==": + return actual == expected, nil + case "!=": + return actual != expected, nil + default: + return false, fmt.Errorf("cannot compare %q %s %q: %w", actual, op, expected, errIncomparableOrdering) + } +} + +func trimReadinessValue(value string) string { + if len(value) >= 2 { + if value[0] == '"' && value[len(value)-1] == '"' { + return value[1 : len(value)-1] + } + if value[0] == '\'' && value[len(value)-1] == '\'' { + return value[1 : len(value)-1] + } + } + return value +} + +func tryParseFloat(value string) (float64, bool) { + parsed, err := strconv.ParseFloat(value, 64) + return parsed, err == nil +} + +func tryParseBool(value string) (bool, bool) { + parsed, err := strconv.ParseBool(value) + return parsed, err == nil +} + +func compareNumeric(actual float64, op string, expected float64) (bool, error) { + switch op { + case "==": + return actual == expected, nil + case "!=": + return actual != expected, nil + case "<": + return actual < expected, nil + case "<=": + return actual <= expected, nil + case ">": + return actual > expected, nil + case ">=": + return actual >= expected, nil + default: + return false, fmt.Errorf("unknown operator %q", op) + } +} diff --git a/pkg/kube/readiness_test.go b/pkg/kube/readiness_test.go new file mode 100644 index 000000000..a7fdd04c6 --- /dev/null +++ b/pkg/kube/readiness_test.go @@ -0,0 +1,526 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kube + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func makeUnstructuredWithStatus(t *testing.T, statusFields map[string]any) *unstructured.Unstructured { + t.Helper() + + obj := &unstructured.Unstructured{} + obj.SetAPIVersion("v1") + obj.SetKind("ConfigMap") + obj.SetName("test-resource") + + if statusFields != nil { + require.NoError(t, unstructured.SetNestedField(obj.Object, statusFields, "status")) + } + + return obj +} + +func TestEvaluateReadiness_BothNil_UsesKstatus(t *testing.T) { + obj := makeUnstructuredWithStatus(t, nil) + + result, useKstatus, _, err := EvaluateCustomReadiness(obj, nil, nil) + + require.NoError(t, err) + assert.True(t, useKstatus) + assert.Equal(t, ReadinessPending, result) + + result, useKstatus, _, err = EvaluateCustomReadiness(obj, []string{}, []string{}) + + require.NoError(t, err) + assert.True(t, useKstatus) + assert.Equal(t, ReadinessPending, result) +} + +func TestEvaluateReadiness_OnlySuccess_FallsBackToKstatus(t *testing.T) { + obj := makeUnstructuredWithStatus(t, nil) + + result, useKstatus, _, err := EvaluateCustomReadiness(obj, []string{`{.ready} == true`}, nil) + + require.NoError(t, err) + assert.True(t, useKstatus) + assert.Equal(t, ReadinessPending, result) +} + +func TestEvaluateReadiness_OnlyFailure_FallsBackToKstatus(t *testing.T) { + obj := makeUnstructuredWithStatus(t, nil) + + result, useKstatus, _, err := EvaluateCustomReadiness(obj, nil, []string{`{.failed} == true`}) + + require.NoError(t, err) + assert.True(t, useKstatus) + assert.Equal(t, ReadinessPending, result) +} + +func TestEvaluateReadiness_SuccessTrue(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{ + "succeeded": int64(1), + }) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{`{.succeeded} == 1`}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessReady, result) +} + +func TestEvaluateReadiness_FailurePrecedesSuccess(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{ + "succeeded": int64(1), + "failed": int64(1), + }) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{`{.succeeded} == 1`}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessFailed, result) +} + +func TestEvaluateReadiness_ORSemantics_AnySuccessTrue(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{ + "phase": "Succeeded", + "another": "nope", + }) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{`{.another} == "yes"`, `{.phase} == "Succeeded"`}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessReady, result) +} + +func TestEvaluateReadiness_NeitherConditionMet(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{ + "succeeded": int64(0), + }) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{`{.succeeded} == 1`}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessPending, result) +} + +func TestEvaluateReadiness_MissingStatusField(t *testing.T) { + t.Run("missing status object", func(t *testing.T) { + obj := makeUnstructuredWithStatus(t, nil) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{`{.phase} == "Running"`}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessPending, result) + }) + + t.Run("missing status field", func(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{}) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{`{.phase} == "Running"`}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessPending, result) + }) +} + +func TestEvaluateReadiness_NumericComparisonOperators(t *testing.T) { + tests := []struct { + name string + expr string + status map[string]any + }{ + {name: "equal", expr: `{.succeeded} == 1`, status: map[string]any{"succeeded": int64(1)}}, + {name: "not equal", expr: `{.succeeded} != 2`, status: map[string]any{"succeeded": int64(1)}}, + {name: "less than", expr: `{.succeeded} < 2`, status: map[string]any{"succeeded": int64(1)}}, + {name: "less than or equal", expr: `{.succeeded} <= 1`, status: map[string]any{"succeeded": int64(1)}}, + {name: "greater than", expr: `{.succeeded} > 0`, status: map[string]any{"succeeded": int64(1)}}, + {name: "greater than or equal", expr: `{.succeeded} >= 1`, status: map[string]any{"succeeded": int64(1)}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := makeUnstructuredWithStatus(t, tt.status) + + result, useKstatus, warnings, err := EvaluateCustomReadiness( + obj, + []string{tt.expr}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Empty(t, warnings) + assert.Equal(t, ReadinessReady, result) + }) + } +} + +func TestEvaluateReadiness_BooleanComparison(t *testing.T) { + tests := []struct { + name string + expr string + status map[string]any + }{ + {name: "boolean equals", expr: `{.ready} == true`, status: map[string]any{"ready": true}}, + {name: "boolean not equals", expr: `{.ready} != false`, status: map[string]any{"ready": true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := makeUnstructuredWithStatus(t, tt.status) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{tt.expr}, + []string{`{.failed} == true`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessReady, result) + }) + } +} + +func TestEvaluateReadiness_StringComparison(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{ + "phase": "Running", + }) + + result, useKstatus, warnings, err := EvaluateCustomReadiness( + obj, + []string{`{.phase} == "Running"`}, + []string{`{.phase} == "Failed"`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Empty(t, warnings) + assert.Equal(t, ReadinessReady, result) + + result, useKstatus, warnings, err = EvaluateCustomReadiness( + obj, + []string{`{.phase} != "Failed"`}, + []string{`{.phase} == "Failed"`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Empty(t, warnings) + assert.Equal(t, ReadinessReady, result) +} + +func TestEvaluateReadiness_InvalidJSONPath(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{ + "phase": "Running", + }) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{`{.phase[} == "Running"`}, + []string{`{.failed} >= 1`}, + ) + + assert.ErrorContains(t, err, "invalid JSONPath") + assert.False(t, useKstatus) + assert.Equal(t, ReadinessPending, result) +} + +func TestEvaluateReadiness_InvalidOperator(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{ + "phase": "Running", + }) + + result, useKstatus, _, err := EvaluateCustomReadiness( + obj, + []string{`{.phase} <> "Running"`}, + []string{`{.failed} >= 1`}, + ) + + assert.ErrorContains(t, err, "unsupported operator") + assert.False(t, useKstatus) + assert.Equal(t, ReadinessPending, result) +} + +func TestParseReadinessExpression(t *testing.T) { + t.Run("valid expressions", func(t *testing.T) { + tests := []struct { + name string + expr string + wantPath string + wantOp string + wantVal string + }{ + {name: "string equality", expr: `{.phase} == "Running"`, wantPath: ".phase", wantOp: "==", wantVal: `"Running"`}, + {name: "numeric greater than or equal", expr: `{.count} >= 3`, wantPath: ".count", wantOp: ">=", wantVal: "3"}, + {name: "boolean not equal", expr: `{.ready} != false`, wantPath: ".ready", wantOp: "!=", wantVal: "false"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, op, val, err := parseReadinessExpression(tt.expr) + + require.NoError(t, err) + assert.Equal(t, tt.wantPath, path) + assert.Equal(t, tt.wantOp, op) + assert.Equal(t, tt.wantVal, val) + }) + } + }) + + t.Run("invalid expressions", func(t *testing.T) { + tests := []struct { + name string + expr string + wantErrPart string + }{ + {name: "missing opening brace", expr: `.phase == "Running"`, wantErrPart: "must start"}, + {name: "missing closing brace", expr: `{.phase == "Running"`, wantErrPart: "missing closing"}, + {name: "missing operator", expr: `{.phase} "Running"`, wantErrPart: "unsupported operator"}, + {name: "unsupported operator", expr: `{.phase} <> "Running"`, wantErrPart: "unsupported operator"}, + {name: "missing value", expr: `{.phase} ==`, wantErrPart: "missing comparison value"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, _, err := parseReadinessExpression(tt.expr) + assert.ErrorContains(t, err, tt.wantErrPart) + }) + } + }) +} + +func TestReadinessJSONPath(t *testing.T) { + // Locks the contract that paths already rooted at .status do NOT get + // .status double-prepended. Chart authors commonly write + // `{.status.phase}` (the canonical form in K8s docs); without the + // guard, the effective JSONPath becomes `.status.status.phase` and + // the readiness gate silently fails to find any value. + tests := []struct { + name string + path string + want string + wantErr string + }{ + {name: "already rooted at .status. — no double-prepend", path: ".status.phase", want: "{.status.phase}"}, + {name: "already rooted at .status[ — no double-prepend", path: `.status['observedGeneration']`, want: `{.status['observedGeneration']}`}, + {name: "non-status dot path gets .status prefix", path: ".phase", want: "{.status.phase}"}, + {name: "bracket-only path gets .status prefix", path: "[0]", want: "{.status[0]}"}, + {name: "invalid path rejected", path: "phase", wantErr: "must start with . or ["}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := readinessJSONPath(tt.path) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestValidateReadinessExpressions(t *testing.T) { + tests := []struct { + name string + annotation string + wantErr string + }{ + {name: "empty annotation is valid", annotation: ""}, + {name: "well-formed single expression", annotation: `["{.status.phase} == \"Ready\""]`}, + {name: "well-formed multiple expressions", annotation: `["{.status.succeeded} >= 1", "{.status.ready} == true"]`}, + {name: "not a JSON array", annotation: `not-json`, wantErr: "parsing readiness annotation JSON"}, + {name: "missing closing brace", annotation: `["{.status.succeeded >= 1"]`, wantErr: "missing closing"}, + {name: "path not rooted at . or [", annotation: `["{status.phase} == \"Ready\""]`, wantErr: "must start with . or ["}, + {name: "ordering operator with quoted string value", annotation: `["{.status.phase} > \"Running\""]`, wantErr: "requires a numeric comparison value"}, + {name: "ordering operator with boolean value", annotation: `["{.status.ready} >= true"]`, wantErr: "requires a numeric comparison value"}, + {name: "ordering operator with quoted numeric value is valid", annotation: `["{.status.replicas} >= \"3\""]`}, + {name: "ordering operator with unquoted non-numeric value", annotation: `["{.status.phase} > Running"]`, wantErr: "requires a numeric comparison value"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateReadinessExpressions(tt.annotation) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestReadinessExpressionsJSON(t *testing.T) { + t.Run("empty list", func(t *testing.T) { + exprs, err := parseReadinessExpressions(`[]`) + + require.NoError(t, err) + assert.Empty(t, exprs) + }) + + t.Run("malformed annotation JSON", func(t *testing.T) { + _, err := parseReadinessExpressions(`["{.ready} == true"`) + assert.ErrorContains(t, err, "parsing readiness annotation JSON") + }) +} + +func TestEvaluateReadiness_OrderingOnNonNumericString_TreatedAsNotMet(t *testing.T) { + for _, op := range []string{"<", "<=", ">", ">="} { + t.Run(op, func(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"}) + expr := fmt.Sprintf(`{.phase} %s "Ready"`, op) + + result, useKstatus, warnings, err := EvaluateCustomReadiness( + obj, + []string{expr}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessPending, result) + require.Len(t, warnings, 1) + assert.Equal(t, expr, warnings[0].Expression) + assert.Contains(t, warnings[0].Detail, "ordering operators") + assert.Contains(t, warnings[0].Detail, `"Running"`) + }) + } +} + +func TestEvaluateReadiness_IncomparableFailureExpression_Symmetric(t *testing.T) { + // A type-mismatched FAILURE expression must mean "failure condition not + // met" — not "failed" and not an error — so a satisfied success + // expression still marks the resource ready, with the mistake surfaced + // as a warning. + obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"}) + + result, useKstatus, warnings, err := EvaluateCustomReadiness( + obj, + []string{`{.phase} == "Running"`}, + []string{`{.phase} > "Failed"`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessReady, result) + require.Len(t, warnings, 1) + assert.Equal(t, `{.phase} > "Failed"`, warnings[0].Expression) +} + +func TestEvaluateReadiness_NumericStringCoercionPreserved(t *testing.T) { + // A string status value that parses as a number keeps comparing + // numerically — only genuinely non-numeric values changed behavior. + tests := []struct{ name, expr string }{ + {name: "unquoted numeric literal", expr: `{.phase} >= 5`}, + {name: "quoted numeric literal", expr: `{.phase} >= "5"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "5"}) + + result, useKstatus, warnings, err := EvaluateCustomReadiness( + obj, + []string{tt.expr}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Empty(t, warnings) + assert.Equal(t, ReadinessReady, result) + }) + } +} + +func TestEvaluateReadiness_OrderingOnBooleans_TreatedAsNotMet(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{"ready": true}) + + result, useKstatus, warnings, err := EvaluateCustomReadiness( + obj, + []string{`{.ready} >= true`}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.False(t, useKstatus) + assert.Equal(t, ReadinessPending, result) + require.Len(t, warnings, 1) +} + +func TestEvaluateReadiness_AllSuccessExpressionsIncomparable_Pending(t *testing.T) { + obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"}) + + result, _, warnings, err := EvaluateCustomReadiness( + obj, + []string{`{.phase} > "Ready"`, `{.phase} < "Ready"`}, + []string{`{.failed} >= 1`}, + ) + + require.NoError(t, err) + assert.Equal(t, ReadinessPending, result) + assert.Len(t, warnings, 2) +} + +func TestCompareValues_OrderingOnNonNumeric_ReturnsSentinel(t *testing.T) { + met, err := compareValues("Running", ">", `"Ready"`) + require.ErrorIs(t, err, errIncomparableOrdering) + assert.False(t, met) + + // A numeric actual against a non-numeric literal is equally incomparable. + met, err = compareValues("5", ">", `"high"`) + require.ErrorIs(t, err, errIncomparableOrdering) + assert.False(t, met) +} diff --git a/pkg/kube/statuswait.go b/pkg/kube/statuswait.go index 29de0af2b..a84c1e377 100644 --- a/pkg/kube/statuswait.go +++ b/pkg/kube/statuswait.go @@ -51,6 +51,12 @@ type statusWaiter struct { waitWithJobsCtx context.Context waitForDeleteCtx context.Context readers []engine.StatusReader + // customReadiness enables per-resource evaluation of the + // helm.sh/readiness-success|failure annotations in Wait and WaitWithJobs. + // WatchUntilReady deliberately ignores it: hook readiness is defined by + // hook semantics, and the partial-annotation warning only runs for + // sequenced release resources. + customReadiness bool logging.LogHolder } @@ -74,6 +80,17 @@ func getStatusWatcher(dynamicClient dynamic.Interface, mapper meta.RESTMapper) * return sw } +// wrapCustomReadiness layers custom readiness evaluation on top of the reader +// chain a wait method would otherwise use. It must wrap the complete chain so +// that resources without the annotations are delegated to exactly the readers +// they would get with custom readiness disabled. +func (w *statusWaiter) wrapCustomReadiness(fallback engine.StatusReader) engine.StatusReader { + if !w.customReadiness { + return fallback + } + return newCustomReadinessStatusReader(w.Logger(), fallback) +} + func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error { if timeout == 0 { timeout = DefaultStatusWatcherTimeout @@ -87,6 +104,7 @@ func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.D // We don't want to wait on any other resources as watchUntilReady is only for Helm hooks. // If custom readers are defined they can be used as Helm hooks support any resource. // We put them in front since the DelegatingStatusReader uses the first reader that matches. + // Custom readiness annotations (w.customReadiness) are deliberately not applied to hooks. genericSR := statusreaders.NewGenericStatusReader(w.restMapper, alwaysReady) sr := &statusreaders.DelegatingStatusReader{ @@ -104,7 +122,7 @@ func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) er defer cancel() w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) sw := getStatusWatcher(w.client, w.restMapper) - sw.StatusReader = statusreaders.NewStatusReader(w.restMapper, w.readers...) + sw.StatusReader = w.wrapCustomReadiness(statusreaders.NewStatusReader(w.restMapper, w.readers...)) return w.wait(ctx, resourceList, sw) } @@ -119,8 +137,7 @@ func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Dura newCustomJobStatusReader := helmStatusReaders.NewCustomJobStatusReader(w.restMapper) readers := append([]engine.StatusReader(nil), w.readers...) readers = append(readers, newCustomJobStatusReader) - customSR := statusreaders.NewStatusReader(w.restMapper, readers...) - sw.StatusReader = customSR + sw.StatusReader = w.wrapCustomReadiness(statusreaders.NewStatusReader(w.restMapper, readers...)) return w.wait(ctx, resourceList, sw) } diff --git a/pkg/release/v1/release.go b/pkg/release/v1/release.go index 3bbc0e4ce..d38391900 100644 --- a/pkg/release/v1/release.go +++ b/pkg/release/v1/release.go @@ -25,6 +25,15 @@ type ApplyMethod string const ApplyMethodClientSideApply ApplyMethod = "csa" const ApplyMethodServerSideApply ApplyMethod = "ssa" +// SequencingInfo is the legacy ordered-sequencing record stored by earlier +// builds of this fork. It is retained so those releases keep decoding; new +// code reads Release.IsSequenced and writers are migrating to the Sequenced +// field. +type SequencingInfo struct { + Enabled bool `json:"enabled,omitempty"` + Strategy string `json:"strategy,omitempty"` +} + // Release describes a deployment of a chart, together with the chart // and the variables used to deploy that chart. type Release struct { @@ -51,6 +60,13 @@ type Release struct { // ApplyMethod stores whether server-side or client-side apply was used for the release // Unset (empty string) should be treated as the default of client-side apply ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa" + // Sequenced records that this revision was deployed with ordered sequencing + // (--wait=ordered / WaitStrategy=ordered). It is set before the release record + // is first persisted so that any failure-cleanup path sees it. + Sequenced bool `json:"sequenced,omitempty"` + // SequencingInfo is read for releases stored by earlier builds of this fork. + // Legacy, decode-only: writers are migrating to Sequenced; read via IsSequenced. + SequencingInfo *SequencingInfo `json:"sequencing_info,omitempty"` } // SetStatus is a helper for setting the status on a release. @@ -58,3 +74,11 @@ func (r *Release) SetStatus(status common.Status, msg string) { r.Info.Status = status r.Info.Description = msg } + +// IsSequenced reports whether this release revision was deployed with ordered +// sequencing. It reads both the current Sequenced field and the legacy +// SequencingInfo shape stored by earlier builds, so pre-existing sequenced +// releases keep sequencing through uninstall and rollback. +func (r *Release) IsSequenced() bool { + return r.Sequenced || (r.SequencingInfo != nil && r.SequencingInfo.Enabled) +} diff --git a/pkg/release/v1/release_test.go b/pkg/release/v1/release_test.go new file mode 100644 index 000000000..2d2b3340e --- /dev/null +++ b/pkg/release/v1/release_test.go @@ -0,0 +1,95 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReleaseSequencingInfoBackwardCompatibility(t *testing.T) { + release := Release{Name: "demo"} + + data, err := json.Marshal(&release) + require.NoError(t, err) + assert.NotContains(t, string(data), "sequencing_info") + + var decoded Release + err = json.Unmarshal([]byte(`{"name":"demo"}`), &decoded) + require.NoError(t, err) + assert.Nil(t, decoded.SequencingInfo) +} + +func TestReleaseSequencingInfoRoundTrip(t *testing.T) { + release := Release{ + Name: "demo", + SequencingInfo: &SequencingInfo{ + Enabled: true, + Strategy: "ordered", + }, + } + + data, err := json.Marshal(&release) + require.NoError(t, err) + assert.Contains(t, string(data), `"sequencing_info"`) + + var decoded Release + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + require.NotNil(t, decoded.SequencingInfo) + assert.True(t, decoded.SequencingInfo.Enabled) + assert.Equal(t, "ordered", decoded.SequencingInfo.Strategy) +} + +func TestReleaseIsSequencedLegacyDecode(t *testing.T) { + var decoded Release + err := json.Unmarshal([]byte(`{"name":"demo","sequencing_info":{"enabled":true}}`), &decoded) + require.NoError(t, err) + assert.False(t, decoded.Sequenced) + assert.True(t, decoded.IsSequenced()) +} + +func TestReleaseSequencedRoundTrip(t *testing.T) { + release := Release{Name: "demo", Sequenced: true} + data, err := json.Marshal(&release) + require.NoError(t, err) + assert.Contains(t, string(data), `"sequenced":true`) + + var decoded Release + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.True(t, decoded.Sequenced) + assert.True(t, decoded.IsSequenced()) + assert.Nil(t, decoded.SequencingInfo) +} + +func TestReleaseNotSequencedOmitted(t *testing.T) { + release := Release{Name: "demo"} + data, err := json.Marshal(&release) + require.NoError(t, err) + // Non-sequenced release JSON is byte-identical to pre-schema-change output. + assert.NotContains(t, string(data), `"sequenced"`) + assert.NotContains(t, string(data), `"sequencing_info"`) + assert.False(t, release.IsSequenced()) +} + +func TestReleaseIsSequencedDisabledLegacyInfo(t *testing.T) { + release := Release{Name: "demo", SequencingInfo: &SequencingInfo{Enabled: false}} + assert.False(t, release.IsSequenced()) +} diff --git a/pkg/release/v1/sequence/builder.go b/pkg/release/v1/sequence/builder.go new file mode 100644 index 000000000..842ed2935 --- /dev/null +++ b/pkg/release/v1/sequence/builder.go @@ -0,0 +1,359 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sequence + +import ( + "fmt" + "maps" + "slices" + "strings" + + chart "helm.sh/helm/v4/pkg/chart/v2" + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +type builder struct { + plan *Plan +} + +// GroupManifestsByDirectSubchart groups manifests by the direct subchart they belong to. +// chartPath is the full path-prefix for the current chart level — at the top level +// it is the chart name (e.g. "parent"); at deeper recursion levels it is the joined +// path through "/charts/" segments (e.g. "parent/charts/sub"). +// The current chart level's own manifests are returned under the empty string key "". +// Direct subcharts are keyed by their immediate directory name under +// "/charts//". Nested grandchildren are grouped under their +// direct subchart parent ("sub"), since nested sequencing is handled recursively. +func GroupManifestsByDirectSubchart(manifests []releaseutil.Manifest, chartPath string) map[string][]releaseutil.Manifest { + result := make(map[string][]releaseutil.Manifest) + if chartPath == "" { + result[""] = append(result[""], manifests...) + return result + } + + chartsPrefix := chartPath + "/charts/" + for _, m := range manifests { + if !strings.HasPrefix(m.Name, chartsPrefix) { + result[""] = append(result[""], m) + continue + } + rest := m.Name[len(chartsPrefix):] + subchartName, _, ok := strings.Cut(rest, "/") + if !ok { + result[""] = append(result[""], m) + continue + } + result[subchartName] = append(result[subchartName], m) + } + return result +} + +// FindSubchart resolves a direct dependency by effective name (alias if set, +// else name) — the single home for the chart-walker subchart lookup. It uses +// the parent chart's Metadata.Dependencies to resolve aliases, since the alias +// is stored on the Dependency struct in Chart.yaml, not on the subchart's own +// Metadata. +func FindSubchart(chrt *chart.Chart, nameOrAlias string) *chart.Chart { + if chrt == nil { + return nil + } + aliasMap := make(map[string]string) // chart name → effective name (alias or name) + if chrt.Metadata != nil { + for _, dep := range chrt.Metadata.Dependencies { + if dep == nil { + continue + } + effective := dep.Name + if dep.Alias != "" { + effective = dep.Alias + } + aliasMap[dep.Name] = effective + } + } + for _, dep := range chrt.Dependencies() { + effective := dep.Name() + if alias, ok := aliasMap[dep.Name()]; ok { + effective = alias + } + if effective == nameOrAlias || dep.Name() == nameOrAlias { + return dep + } + } + return nil +} + +// Build constructs the deployment plan for chrt's rendered manifests. +// +// Preconditions: manifests are hook-free (SortManifests output or a stored +// rel.Manifest — hooks are stored separately) and chrt has been through +// ProcessDependencies. Storage-decoded charts satisfy this in metadata only: +// the release codec drops the loaded dependency tree (Chart.dependencies is +// unexported), so only the root chart object and its pruned, alias-rewritten +// Metadata.Dependencies survive. Build handles that shape: the root subchart +// DAG is built from the trusted metadata (see BuildSubchartDAG) and nested +// levels are walked structurally from manifest "# Source:" paths +// (buildStructuralLevel). Build does not re-filter hooks. +// chrt == nil yields a flat single-level plan at ChartPath "". +// +// Errors (fatal — the caller must not proceed): +// - subchart DAG construction failure: depends-on referencing unknown/disabled +// subcharts, malformed helm.sh/depends-on/subcharts JSON (BuildSubchartDAG) +// - cycle in a subchart DAG or a resource-group DAG (any level) +// - a resource assigned to two different groups (ParseResourceGroups) +// +// Warnings (non-fatal, returned in Plan.Warnings; the manifest is still placed): +// - invalid depends-on/resource-groups JSON on a resource → demoted to unsequenced +// - group referencing a missing group → whole group demoted (transitively) +// - isolated group among ≥2 groups → demoted to unsequenced +// - resource with only one of the two readiness annotations (falls back to kstatus) +// - rendered subchart not declared in Chart.yaml (deployed after declared subcharts) +// - rendered subchart not resolvable to a chart object and with ≥2 nested +// sibling subcharts (structural walk cannot recover sibling ordering) +// +// Postcondition: every input manifest appears in exactly one batch; +// len(manifests) == Σ len(batch.Manifests()). Unit-enforced. +// +// Build is deterministic: identical input yields an identical plan. +func Build(chrt *chart.Chart, manifests []releaseutil.Manifest) (*Plan, error) { + b := &builder{plan: &Plan{}} + if chrt == nil { + b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: "", Depth: 0}) + if err := b.appendResourceGroupBatches("", 0, manifests); err != nil { + return nil, err + } + return b.plan, nil + } + + if err := b.buildLevel(chrt, manifests, chrt.Name(), 0); err != nil { + return nil, err + } + return b.plan, nil +} + +func (b *builder) warnf(kind WarningKind, chartPath, format string, args ...any) { + b.plan.Warnings = append(b.plan.Warnings, Warning{ + Kind: kind, + ChartPath: chartPath, + Message: fmt.Sprintf(format, args...), + }) +} + +func (b *builder) buildLevel(c *chart.Chart, manifests []releaseutil.Manifest, chartPath string, depth int) error { + levelIdx := len(b.plan.Levels) + b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: chartPath, Depth: depth}) + + grouped := GroupManifestsByDirectSubchart(manifests, chartPath) + + dag, err := chartutil.BuildSubchartDAG(c) + if err != nil { + return fmt.Errorf("building subchart DAG for %s: %w", chartPath, err) + } + + batches, err := dag.GetBatches() + if err != nil { + return fmt.Errorf("subchart circular dependency detected in %s: %w", chartPath, err) + } + b.plan.Levels[levelIdx].SubchartBatches = batches + + declared := make(map[string]bool, len(batches)) + for _, batch := range batches { + for _, name := range batch { + declared[name] = true + if err := b.buildSubchart(c, chartPath, name, grouped[name], depth, levelIdx); err != nil { + return err + } + } + } + + for _, name := range slices.Sorted(maps.Keys(grouped)) { + if name == "" || declared[name] { + continue + } + b.plan.Levels[levelIdx].Undeclared = append(b.plan.Levels[levelIdx].Undeclared, name) + b.warnf(WarningKindUndeclaredSubchart, chartPath, "rendered subchart %q is not declared in Chart.yaml dependencies; sequencing it after declared subcharts", name) + if err := b.buildSubchart(c, chartPath, name, grouped[name], depth, levelIdx); err != nil { + return err + } + } + slices.Sort(b.plan.Levels[levelIdx].Unresolved) + + return b.appendResourceGroupBatches(chartPath, depth, grouped[""]) +} + +func (b *builder) buildSubchart(parent *chart.Chart, chartPath, name string, manifests []releaseutil.Manifest, depth, parentLevelIdx int) error { + if len(manifests) == 0 { + return nil + } + + subPath := chartPath + "/charts/" + name + sub := FindSubchart(parent, name) + if sub == nil { + b.plan.Levels[parentLevelIdx].Unresolved = append(b.plan.Levels[parentLevelIdx].Unresolved, name) + return b.buildStructuralLevel(subPath, depth+1, manifests) + } + + return b.buildLevel(sub, manifests, subPath, depth+1) +} + +// buildStructuralLevel sequences a subtree whose chart object is unavailable. +// Charts decoded from release storage lose their loaded dependency tree (the +// release codec serializes only exported fields, and Chart.dependencies is +// not one), so at uninstall/rollback time nested subchart levels have +// manifests but no chart. The tree structure is recovered from the manifests' +// "# Source:" path prefixes and walked exactly like buildLevel — subchart +// subtrees first, then the level's own resource-group batches (the group +// annotations live on the manifests and fully survive storage). The only +// ordering information lost with the chart is depends-on edges BETWEEN +// sibling subcharts: siblings are walked in name order, and a warning is +// recorded when a level has two or more. +func (b *builder) buildStructuralLevel(chartPath string, depth int, manifests []releaseutil.Manifest) error { + levelIdx := len(b.plan.Levels) + b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: chartPath, Depth: depth}) + + grouped := GroupManifestsByDirectSubchart(manifests, chartPath) + subcharts := slices.DeleteFunc(slices.Sorted(maps.Keys(grouped)), func(name string) bool { return name == "" }) + + if len(subcharts) > 0 { + b.plan.Levels[levelIdx].SubchartBatches = [][]string{subcharts} + } + if len(subcharts) >= 2 { + b.warnf(WarningKindUnresolvedSubchart, chartPath, "chart metadata for %s is unavailable; sequencing its subcharts %v in name order (depends-on between them, if any, is not recoverable)", chartPath, subcharts) + } + for _, name := range subcharts { + if err := b.buildStructuralLevel(chartPath+"/charts/"+name, depth+1, grouped[name]); err != nil { + return err + } + } + return b.appendResourceGroupBatches(chartPath, depth, grouped[""]) +} + +func (b *builder) appendResourceGroupBatches(chartPath string, depth int, manifests []releaseutil.Manifest) error { + if len(manifests) == 0 { + return nil + } + + for _, manifest := range manifests { + if manifest.Head == nil || manifest.Head.Metadata == nil { + continue + } + annotations := manifest.Head.Metadata.Annotations + _, hasSuccess := annotations[releaseutil.AnnotationReadinessSuccess] + _, hasFailure := annotations[releaseutil.AnnotationReadinessFailure] + if hasSuccess != hasFailure { + b.warnf(WarningKindPartialReadiness, chartPath, "resource %q has only one of %s and %s; falling back to kstatus readiness", manifest.Head.Metadata.Name, releaseutil.AnnotationReadinessSuccess, releaseutil.AnnotationReadinessFailure) + } + } + + result, warnings, err := releaseutil.ParseResourceGroups(manifests) + if err != nil { + return fmt.Errorf("parsing resource-group annotations for %s: %w", chartPath, err) + } + for _, warning := range warnings { + b.plan.Warnings = append(b.plan.Warnings, Warning{ + Kind: WarningKindResourceGroupDemotion, + ChartPath: chartPath, + Message: warning, + }) + } + + unsequenced := result.Unsequenced + if len(result.Groups) >= 2 { + dependents := resourceGroupDependents(result.GroupDeps) + for _, groupName := range slices.Sorted(maps.Keys(result.Groups)) { + if len(result.GroupDeps[groupName]) != 0 || dependents[groupName] { + continue + } + b.warnf(WarningKindIsolatedGroup, chartPath, "resource-group %q is isolated (no depends-on edges and no dependents); deploying it in the unsequenced batch after sequenced groups", groupName) + unsequenced = append(unsequenced, result.Groups[groupName]...) + delete(result.Groups, groupName) + delete(result.GroupDeps, groupName) + } + } + + if len(result.Groups) > 0 { + dag, err := releaseutil.BuildResourceGroupDAG(result) + if err != nil { + return fmt.Errorf("building resource-group DAG for %s: %w", chartPath, err) + } + + groupBatches, err := dag.GetBatches() + if err != nil { + return fmt.Errorf("resource-group circular dependency detected in %s: %w", chartPath, err) + } + + dependents := resourceGroupDependents(result.GroupDeps) + for _, groupBatch := range groupBatches { + batch := Batch{ + ChartPath: chartPath, + Depth: depth, + Kind: BatchKindGroups, + Wait: true, + } + for _, groupName := range groupBatch { + batch.Groups = append(batch.Groups, Group{ + Name: groupName, + Manifests: result.Groups[groupName], + }) + if !dependents[groupName] { + batch.LeafGroups = append(batch.LeafGroups, groupName) + } + } + batch.HasCustomReadiness = hasCustomReadiness(batch.Manifests()) + b.plan.Batches = append(b.plan.Batches, batch) + } + } + + if len(unsequenced) > 0 { + b.plan.Batches = append(b.plan.Batches, Batch{ + ChartPath: chartPath, + Depth: depth, + Kind: BatchKindUnsequenced, + Groups: []Group{{Name: "", Manifests: unsequenced}}, + Wait: true, + HasCustomReadiness: hasCustomReadiness(unsequenced), + }) + } + + return nil +} + +func resourceGroupDependents(groupDeps map[string][]string) map[string]bool { + dependents := make(map[string]bool) + for _, deps := range groupDeps { + for _, dep := range deps { + dependents[dep] = true + } + } + return dependents +} + +func hasCustomReadiness(manifests []releaseutil.Manifest) bool { + for _, manifest := range manifests { + if manifest.Head == nil || manifest.Head.Metadata == nil { + continue + } + annotations := manifest.Head.Metadata.Annotations + if annotations == nil { + continue + } + if annotations[releaseutil.AnnotationReadinessSuccess] != "" && annotations[releaseutil.AnnotationReadinessFailure] != "" { + return true + } + } + return false +} diff --git a/pkg/release/v1/sequence/builder_test.go b/pkg/release/v1/sequence/builder_test.go new file mode 100644 index 000000000..cfa755012 --- /dev/null +++ b/pkg/release/v1/sequence/builder_test.go @@ -0,0 +1,1044 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sequence + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chart "helm.sh/helm/v4/pkg/chart/v2" + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +func TestBuild_NilChart_FlatPlan(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")), + makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")), + makeManifest("plain", "parent/templates/plain.yaml", nil), + } + + plan, err := Build(nil, manifests) + require.NoError(t, err) + assert.Equal(t, []ChartLevel{{Path: "", Depth: 0}}, plan.Levels) + assert.Equal(t, []batchSummary{ + {ChartPath: "", Depth: 0, Kind: BatchKindGroups, Groups: []string{"db"}}, + {ChartPath: "", Depth: 0, Kind: BatchKindGroups, Groups: []string{"app"}}, + {ChartPath: "", Depth: 0, Kind: BatchKindUnsequenced, Groups: []string{""}}, + }, batchSummaries(plan)) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_EmptyChart(t *testing.T) { + t.Parallel() + + plan, err := Build(newChart("parent"), nil) + require.NoError(t, err) + assert.Equal(t, []ChartLevel{{Path: "parent", Depth: 0}}, plan.Levels) + assert.Empty(t, plan.Batches) + assert.Empty(t, plan.Warnings) +} + +func TestGroupManifestsByDirectSubchart(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("parent", "parent/templates/one.yaml", nil), + makeManifest("db", "parent/charts/database/templates/one.yaml", nil), + makeManifest("cache", "parent/charts/database/charts/cache/templates/one.yaml", nil), + } + + grouped := GroupManifestsByDirectSubchart(manifests, "parent") + + require.Len(t, grouped[""], 1) + require.Len(t, grouped["database"], 2) +} + +// TestGroupManifestsByDirectSubchart_Nested verifies that when called +// with a deeper chartPath (i.e., during recursion into a subchart), nested +// grandchildren are routed to the correct subchart key instead of being merged +// into the parent batch. +func TestGroupManifestsByDirectSubchart_Nested(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("db-own", "parent/charts/database/templates/one.yaml", nil), + makeManifest("cache", "parent/charts/database/charts/cache/templates/one.yaml", nil), + } + + grouped := GroupManifestsByDirectSubchart(manifests, "parent/charts/database") + + require.Len(t, grouped[""], 1, "database's own resources should be under the empty key") + require.Len(t, grouped["cache"], 1, "nested cache subchart should be routed under its own key") + require.Equal(t, "parent/charts/database/templates/one.yaml", grouped[""][0].Name) + require.Equal(t, "parent/charts/database/charts/cache/templates/one.yaml", grouped["cache"][0].Name) +} + +func TestFindSubchart(t *testing.T) { + // makeSubchart constructs a chart with the given chart-name, its own + // Metadata.Name, which BuildSubchartDAG and the legacy lookup resolve + // against. + makeSubchart := func(chartName string) *chart.Chart { + return &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v1", + Name: chartName, + Version: "0.1.0", + }, + } + } + + // makeParent attaches subcharts as dependencies and declares the + // parent's Metadata.Dependencies, which carry the Alias field. + makeParent := func(deps []*chart.Chart, metaDeps []*chart.Dependency) *chart.Chart { + parent := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v1", + Name: "parent", + Version: "0.1.0", + Dependencies: metaDeps, + }, + } + for _, d := range deps { + parent.AddDependency(d) + } + return parent + } + + t.Run("resolves by chart name when no alias is declared", func(t *testing.T) { + db := makeSubchart("database") + parent := makeParent( + []*chart.Chart{db}, + []*chart.Dependency{{Name: "database"}}, + ) + + got := FindSubchart(parent, "database") + require.NotNil(t, got) + assert.Equal(t, "database", got.Name()) + }) + + t.Run("resolves by alias when alias is declared", func(t *testing.T) { + postgres := makeSubchart("postgres") + parent := makeParent( + []*chart.Chart{postgres}, + []*chart.Dependency{{Name: "postgres", Alias: "db"}}, + ) + + got := FindSubchart(parent, "db") + require.NotNil(t, got, "alias lookup should resolve to the underlying chart") + assert.Equal(t, "postgres", got.Name()) + }) + + t.Run("resolves by underlying chart name even when an alias is declared", func(t *testing.T) { + // An alias does not hide the chart's real name. Manifests rendered + // under the chart's actual chart-name path should still resolve. + postgres := makeSubchart("postgres") + parent := makeParent( + []*chart.Chart{postgres}, + []*chart.Dependency{{Name: "postgres", Alias: "db"}}, + ) + + got := FindSubchart(parent, "postgres") + require.NotNil(t, got) + assert.Equal(t, "postgres", got.Name()) + }) + + t.Run("alias collides with another chart's real name, first match wins", func(t *testing.T) { + // dep1: chart "foo" aliased as "bar". + // dep2: chart "bar" with no alias. + // Query "bar" must resolve deterministically. Current contract: + // iteration order over Dependencies() is preserved, so the first + // dep whose effective name (alias or real) matches the query wins. + foo := makeSubchart("foo") + bar := makeSubchart("bar") + parent := makeParent( + []*chart.Chart{foo, bar}, + []*chart.Dependency{ + {Name: "foo", Alias: "bar"}, + {Name: "bar"}, + }, + ) + + got := FindSubchart(parent, "bar") + require.NotNil(t, got, "collision must resolve, not return nil") + assert.Equal(t, "foo", got.Name(), + "first matching dependency wins; aliased 'foo' is declared before raw 'bar'") + + // And the raw name "foo" must still resolve to chart "foo" even + // though its effective name has been shifted by the alias. + gotFoo := FindSubchart(parent, "foo") + require.NotNil(t, gotFoo) + assert.Equal(t, "foo", gotFoo.Name()) + }) + + t.Run("returns nil when not found", func(t *testing.T) { + db := makeSubchart("database") + parent := makeParent( + []*chart.Chart{db}, + []*chart.Dependency{{Name: "database"}}, + ) + + assert.Nil(t, FindSubchart(parent, "nonexistent")) + }) + + t.Run("returns nil when parent has no dependencies", func(t *testing.T) { + parent := &chart.Chart{ + Metadata: &chart.Metadata{APIVersion: "v1", Name: "parent", Version: "0.1.0"}, + } + assert.Nil(t, FindSubchart(parent, "anything")) + }) +} + +func TestBuild_NoAnnotations_SingleFlatBatch(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("a", "parent/templates/a.yaml", nil), + makeManifest("b", "parent/templates/b.yaml", nil), + makeManifest("c", "parent/templates/c.yaml", nil), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + require.Len(t, plan.Batches, 1) + assert.Equal(t, BatchKindUnsequenced, plan.Batches[0].Kind) + assert.Equal(t, manifestNames(manifests), manifestNames(plan.Batches[0].Manifests())) + assert.True(t, plan.Batches[0].Wait) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_ResourceGroupOrdering(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")), + makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")), + makeManifest("plain", "parent/templates/plain.yaml", nil), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + assert.Equal(t, []batchSummary{ + {ChartPath: "parent", Depth: 0, Kind: BatchKindGroups, Groups: []string{"db"}}, + {ChartPath: "parent", Depth: 0, Kind: BatchKindGroups, Groups: []string{"app"}}, + {ChartPath: "parent", Depth: 0, Kind: BatchKindUnsequenced, Groups: []string{""}}, + }, batchSummaries(plan)) + for _, batch := range plan.Batches { + assert.True(t, batch.Wait) + } + assert.Empty(t, plan.Batches[0].LeafGroups) + assert.Equal(t, []string{"app"}, plan.Batches[1].LeafGroups) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_LeafGroups_Diamond(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("base", "parent/templates/base.yaml", groupAnnotations("base")), + makeManifest("left", "parent/templates/left.yaml", groupAnnotations("left", "base")), + makeManifest("right", "parent/templates/right.yaml", groupAnnotations("right", "base")), + makeManifest("top", "parent/templates/top.yaml", groupAnnotations("top", "left", "right")), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + assert.Equal(t, [][]string{{"base"}, {"left", "right"}, {"top"}}, batchGroupNames(plan)) + assert.Empty(t, plan.Batches[0].LeafGroups) + assert.Empty(t, plan.Batches[1].LeafGroups) + assert.Equal(t, []string{"top"}, plan.Batches[2].LeafGroups) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_NestedSubcharts_ThreeLevels(t *testing.T) { + t.Parallel() + + grand := newChart("grand") + child := newChart("child", enabledDependency("grand")) + child.SetDependencies(grand) + parent := newChart("parent", enabledDependency("child")) + parent.SetDependencies(child) + manifests := []releaseutil.Manifest{ + makeManifest("parent", "parent/templates/parent.yaml", nil), + makeManifest("child", "parent/charts/child/templates/child.yaml", nil), + makeManifest("grand", "parent/charts/child/charts/grand/templates/grand.yaml", nil), + } + + plan, err := Build(parent, manifests) + require.NoError(t, err) + assert.Equal(t, []string{ + "parent/charts/child/charts/grand", + "parent/charts/child", + "parent", + }, batchChartPaths(plan)) + assert.Equal(t, []int{2, 1, 0}, batchDepths(plan)) + assert.Equal(t, []ChartLevel{ + {Path: "parent", Depth: 0, SubchartBatches: [][]string{{"child"}}}, + {Path: "parent/charts/child", Depth: 1, SubchartBatches: [][]string{{"grand"}}}, + {Path: "parent/charts/child/charts/grand", Depth: 2}, + }, plan.Levels) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_SubchartDependencyOrder(t *testing.T) { + t.Parallel() + + parent := newChart( + "parent", + enabledDependency("postgres"), + enabledDependency("rabbitmq", "postgres"), + enabledDependency("app", "rabbitmq"), + ) + manifests := []releaseutil.Manifest{ + makeManifest("app", "parent/charts/app/templates/app.yaml", nil), + makeManifest("rabbitmq", "parent/charts/rabbitmq/templates/rabbitmq.yaml", nil), + makeManifest("postgres", "parent/charts/postgres/templates/postgres.yaml", nil), + } + + plan, err := Build(parent, manifests) + require.NoError(t, err) + assert.Equal(t, []string{"parent/charts/postgres", "parent/charts/rabbitmq", "parent/charts/app"}, batchChartPaths(plan)) + require.Len(t, plan.Levels, 4) + assert.Equal(t, [][]string{{"postgres"}, {"rabbitmq"}, {"app"}}, plan.Levels[0].SubchartBatches) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_Aliases_RealPipeline(t *testing.T) { + t.Parallel() + + parent := pipelineChart( + pipelineDependency("postgres", "primary-db"), + pipelineDependency("app", "", "postgres"), + ) + require.NoError(t, chartutil.ProcessDependencies(parent, map[string]any{})) + manifests := []releaseutil.Manifest{ + makeManifest("app", "parent/charts/app/templates/app.yaml", nil), + makeManifest("primary", "parent/charts/primary-db/templates/primary.yaml", nil), + } + + plan, err := Build(parent, manifests) + require.NoError(t, err) + assert.Equal(t, []string{"parent/charts/primary-db", "parent/charts/app"}, batchChartPaths(plan)) + require.NotEmpty(t, plan.Levels) + assert.Equal(t, [][]string{{"primary-db"}, {"app"}}, plan.Levels[0].SubchartBatches) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_SubchartCycle_Fatal(t *testing.T) { + t.Parallel() + + parent := newChart("parent", enabledDependency("a", "b"), enabledDependency("b", "a")) + plan, err := Build(parent, []releaseutil.Manifest{makeManifest("a", "parent/charts/a/templates/a.yaml", nil)}) + require.Error(t, err) + assert.Nil(t, plan) + assert.Contains(t, err.Error(), "cycle") +} + +func TestBuild_UnknownDependsOnRef_Fatal(t *testing.T) { + t.Parallel() + + parent := newChart("parent", enabledDependency("app", "missing")) + plan, err := Build(parent, nil) + require.Error(t, err) + assert.Nil(t, plan) + assert.Contains(t, err.Error(), `depends-on unknown or disabled subchart "missing"`) +} + +func TestBuild_ResourceGroupCycle_Fatal_NestedLevel(t *testing.T) { + t.Parallel() + + parent := newChart("parent", enabledDependency("child")) + manifests := []releaseutil.Manifest{ + makeManifest("a", "parent/charts/child/templates/a.yaml", groupAnnotations("a", "b")), + makeManifest("b", "parent/charts/child/templates/b.yaml", groupAnnotations("b", "a")), + } + + plan, err := Build(parent, manifests) + require.Error(t, err) + assert.Nil(t, plan) + assert.Contains(t, err.Error(), "cycle") + assert.Contains(t, err.Error(), "parent/charts/child") +} + +func TestBuild_MultiGroupAssignment_Fatal(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeTypedManifest("same", "parent/templates/one.yaml", "v1", "ConfigMap", "default", groupAnnotations("one")), + makeTypedManifest("same", "parent/templates/two.yaml", "v1", "ConfigMap", "default", groupAnnotations("two")), + } + + plan, err := Build(newChart("parent"), manifests) + require.Error(t, err) + assert.Nil(t, plan) + assert.Contains(t, err.Error(), "assigned to multiple resource groups") +} + +func TestBuild_IsolatedGroupDemoted(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")), + makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")), + makeManifest("plain", "parent/templates/plain.yaml", nil), + makeManifest("metrics", "parent/templates/metrics.yaml", groupAnnotations("metrics")), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + assert.Equal(t, [][]string{{"db"}, {"app"}, {""}}, batchGroupNames(plan)) + assert.NotContains(t, slices.Concat(batchGroupNames(plan)[:2]...), "metrics") + require.Len(t, plan.Batches, 3) + assert.Equal(t, BatchKindUnsequenced, plan.Batches[2].Kind) + assert.ElementsMatch(t, []string{"parent/templates/plain.yaml", "parent/templates/metrics.yaml"}, manifestNames(plan.Batches[2].Manifests())) + assertWarningContains(t, plan, "parent", "isolated", "metrics") + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_TwoGroupChain_NotDemoted(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")), + makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + assert.Equal(t, [][]string{{"db"}, {"app"}}, batchGroupNames(plan)) + assertNoWarningContains(t, plan, "isolated") + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_SingleGroup_NotDemoted(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("solo", "parent/templates/solo.yaml", groupAnnotations("solo")), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + assert.Equal(t, [][]string{{"solo"}}, batchGroupNames(plan)) + assert.Equal(t, BatchKindGroups, plan.Batches[0].Kind) + assertNoWarningContains(t, plan, "isolated") + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_AllGroupsIsolated_AllDemoted(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("beta", "parent/templates/beta.yaml", groupAnnotations("beta")), + makeManifest("alpha", "parent/templates/alpha.yaml", groupAnnotations("alpha")), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + require.Len(t, plan.Batches, 1) + assert.Equal(t, BatchKindUnsequenced, plan.Batches[0].Kind) + assert.Equal(t, []string{"parent/templates/alpha.yaml", "parent/templates/beta.yaml"}, manifestNames(plan.Batches[0].Manifests())) + require.Len(t, plan.Warnings, 2) + assert.Contains(t, plan.Warnings[0].Message, "alpha") + assert.Contains(t, plan.Warnings[1].Message, "beta") + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_MissingGroupDep_TransitiveDemotion(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "nope")), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + require.Len(t, plan.Batches, 1) + assert.Equal(t, BatchKindUnsequenced, plan.Batches[0].Kind) + assert.Equal(t, []string{"parent/templates/app.yaml"}, manifestNames(plan.Batches[0].Manifests())) + assertWarningContains(t, plan, "parent", "depends-on non-existent group") + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_InvalidDependsOnJSON_Demoted(t *testing.T) { + t.Parallel() + + annotations := groupAnnotations("app") + annotations[releaseutil.AnnotationDependsOnResourceGroups] = "not-json" + manifests := []releaseutil.Manifest{ + makeManifest("app", "parent/templates/app.yaml", annotations), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + require.Len(t, plan.Batches, 1) + assert.Equal(t, BatchKindUnsequenced, plan.Batches[0].Kind) + assert.Equal(t, []string{"parent/templates/app.yaml"}, manifestNames(plan.Batches[0].Manifests())) + assertWarningContains(t, plan, "parent", "invalid JSON") + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_UndeclaredSubchartIncluded(t *testing.T) { + t.Parallel() + + parent := newChart("parent", enabledDependency("declared")) + parent.AddDependency(newChart("vendored")) + manifests := []releaseutil.Manifest{ + makeManifest("declared", "parent/charts/declared/templates/declared.yaml", nil), + makeManifest("vendored", "parent/charts/vendored/templates/vendored.yaml", nil), + makeManifest("parent", "parent/templates/parent.yaml", nil), + } + + plan, err := Build(parent, manifests) + require.NoError(t, err) + assert.Equal(t, []string{"parent/charts/declared", "parent/charts/vendored", "parent"}, batchChartPaths(plan)) + require.NotEmpty(t, plan.Levels) + assert.Equal(t, []string{"vendored"}, plan.Levels[0].Undeclared) + assertWarningContains(t, plan, "parent", "not declared") + assertPlanComplete(t, plan, manifests) + + reversedPaths := batchChartPaths(plan.Reverse()) + assert.Contains(t, reversedPaths, "parent/charts/vendored") +} + +func TestBuild_UnresolvableSubchart_StructuralFallback(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("db", "parent/charts/ghost/templates/db.yaml", groupAnnotations("db")), + makeManifest("app", "parent/charts/ghost/templates/app.yaml", groupAnnotations("app", "db")), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + // ghost is undeclared in parent's metadata (warned as such, unchanged), + // but a single unresolved subchart is fully recovered structurally, so + // there is no sibling-order warning. + assertWarningContains(t, plan, "parent", "not declared") + assertNoWarningContains(t, plan, "name order") + require.Len(t, plan.Levels, 2) + assert.Equal(t, []string{"ghost"}, plan.Levels[0].Unresolved) + assert.Equal(t, ChartLevel{Path: "parent/charts/ghost", Depth: 1}, plan.Levels[1]) + assert.Equal(t, []string{"parent/charts/ghost", "parent/charts/ghost"}, batchChartPaths(plan)) + assert.Equal(t, []int{1, 1}, batchDepths(plan)) + assert.Equal(t, [][]string{{"db"}, {"app"}}, batchGroupNames(plan)) + assertPlanComplete(t, plan, manifests) +} + +// storageRoundTrip encodes and decodes a chart the way the release storage +// codec does (json.Marshal in pkg/storage/driver): the unexported loaded +// dependency tree is dropped, only exported fields survive. +func storageRoundTrip(t *testing.T, c *chart.Chart) *chart.Chart { + t.Helper() + + encoded, err := json.Marshal(c) + require.NoError(t, err) + decoded := &chart.Chart{} + require.NoError(t, json.Unmarshal(encoded, decoded)) + require.Empty(t, decoded.Dependencies(), "release codec is expected to drop the loaded dependency tree") + return decoded +} + +// TestBuild_StorageDecodedChart_ThreeLevels reproduces bead xmn: a 3-level +// chart decoded from release storage (as uninstall and rollback receive it) +// must yield the same batch order the freshly loaded chart produced at +// install time, not fail building the subchart DAG. +func TestBuild_StorageDecodedChart_ThreeLevels(t *testing.T) { + t.Parallel() + + grand := newChart("grand") + child := newChart("child", enabledDependency("grand")) + child.SetDependencies(grand) + child.Metadata.Annotations = map[string]string{chartutil.AnnotationDependsOnSubcharts: `["grand"]`} + parent := newChart("parent", enabledDependency("child")) + parent.SetDependencies(child) + parent.Metadata.Annotations = map[string]string{chartutil.AnnotationDependsOnSubcharts: `["child"]`} + + manifests := []releaseutil.Manifest{ + makeManifest("parent", "parent/templates/parent.yaml", nil), + makeManifest("child", "parent/charts/child/templates/child.yaml", nil), + makeManifest("grand", "parent/charts/child/charts/grand/templates/grand.yaml", nil), + } + + fresh, err := Build(parent, manifests) + require.NoError(t, err) + + plan, err := Build(storageRoundTrip(t, parent), manifests) + require.NoError(t, err) + assert.Equal(t, batchChartPaths(fresh), batchChartPaths(plan)) + assert.Equal(t, []string{ + "parent/charts/child/charts/grand", + "parent/charts/child", + "parent", + }, batchChartPaths(plan)) + assert.Equal(t, []int{2, 1, 0}, batchDepths(plan)) + assert.Equal(t, []ChartLevel{ + {Path: "parent", Depth: 0, SubchartBatches: [][]string{{"child"}}, Unresolved: []string{"child"}}, + {Path: "parent/charts/child", Depth: 1, SubchartBatches: [][]string{{"grand"}}}, + {Path: "parent/charts/child/charts/grand", Depth: 2}, + }, plan.Levels) + assert.Empty(t, plan.Warnings) + assertPlanComplete(t, plan, manifests) + + assert.Equal(t, []string{ + "parent", + "parent/charts/child", + "parent/charts/child/charts/grand", + }, batchChartPaths(plan.Reverse())) +} + +// TestBuild_StorageDecodedChart_AliasedSiblingOrder runs the real +// ProcessDependencies pipeline (alias rename + depends-on resolution), then +// storage-decodes the chart: root-level sibling order and alias resolution +// must survive via the stored metadata. +func TestBuild_StorageDecodedChart_AliasedSiblingOrder(t *testing.T) { + t.Parallel() + + parent := pipelineChart( + pipelineDependency("postgres", "primary-db"), + pipelineDependency("app", "", "postgres"), + ) + require.NoError(t, chartutil.ProcessDependencies(parent, map[string]any{})) + manifests := []releaseutil.Manifest{ + makeManifest("app", "parent/charts/app/templates/app.yaml", nil), + makeManifest("primary", "parent/charts/primary-db/templates/primary.yaml", nil), + } + + plan, err := Build(storageRoundTrip(t, parent), manifests) + require.NoError(t, err) + assert.Equal(t, []string{"parent/charts/primary-db", "parent/charts/app"}, batchChartPaths(plan)) + require.NotEmpty(t, plan.Levels) + assert.Equal(t, [][]string{{"primary-db"}, {"app"}}, plan.Levels[0].SubchartBatches) + assertPlanComplete(t, plan, manifests) +} + +// TestBuild_StorageDecodedChart_DisabledDepNotResurrected: a dependency +// disabled by condition at install time is pruned from Metadata.Dependencies +// by ProcessDependencies BEFORE the release is stored, so trusting the stored +// metadata cannot resurrect it at uninstall. +func TestBuild_StorageDecodedChart_DisabledDepNotResurrected(t *testing.T) { + t.Parallel() + + parent := pipelineChart( + &chart.Dependency{Name: "cache", Version: "0.1.0", Condition: "cache.enabled"}, + &chart.Dependency{Name: "db", Version: "0.1.0"}, + &chart.Dependency{Name: "app", Version: "0.1.0", DependsOn: []string{"db"}}, + ) + require.NoError(t, chartutil.ProcessDependencies(parent, map[string]any{ + "cache": map[string]any{"enabled": false}, + })) + + decoded := storageRoundTrip(t, parent) + for _, dep := range decoded.Metadata.Dependencies { + require.NotEqual(t, "cache", dep.Name, "disabled dependency must be pruned from stored metadata") + } + + // The disabled subchart was never rendered, so no manifests exist for it. + manifests := []releaseutil.Manifest{ + makeManifest("app", "parent/charts/app/templates/app.yaml", nil), + makeManifest("db", "parent/charts/db/templates/db.yaml", nil), + makeManifest("parent", "parent/templates/parent.yaml", nil), + } + + plan, err := Build(decoded, manifests) + require.NoError(t, err) + assert.Equal(t, []string{"parent/charts/db", "parent/charts/app", "parent"}, batchChartPaths(plan)) + for _, level := range plan.Levels { + assert.NotContains(t, level.Path, "cache") + } + assertPlanComplete(t, plan, manifests) +} + +// TestBuild_StorageDecodedChart_NestedSiblingOrderWarning: depends-on edges +// between SIBLING subcharts of a NESTED level live in that level's Chart.yaml, +// which storage does not preserve. The structural walk orders such siblings +// by name and says so. +func TestBuild_StorageDecodedChart_NestedSiblingOrderWarning(t *testing.T) { + t.Parallel() + + grandA := newChart("g-a") + grandB := newChart("g-b") + child := newChart("child", enabledDependency("g-a"), enabledDependency("g-b", "g-a")) + child.SetDependencies(grandA, grandB) + parent := newChart("parent", enabledDependency("child")) + parent.SetDependencies(child) + + manifests := []releaseutil.Manifest{ + makeManifest("parent", "parent/templates/parent.yaml", nil), + makeManifest("child", "parent/charts/child/templates/child.yaml", nil), + makeManifest("g-a", "parent/charts/child/charts/g-a/templates/a.yaml", nil), + makeManifest("g-b", "parent/charts/child/charts/g-b/templates/b.yaml", nil), + } + + plan, err := Build(storageRoundTrip(t, parent), manifests) + require.NoError(t, err) + assertWarningContains(t, plan, "parent/charts/child", "name order") + assert.Equal(t, []string{ + "parent/charts/child/charts/g-a", + "parent/charts/child/charts/g-b", + "parent/charts/child", + "parent", + }, batchChartPaths(plan)) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_HookManifestNotFiltered(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("hook", "parent/templates/hook.yaml", map[string]string{"helm.sh/hook": "pre-install"}), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + require.Len(t, plan.Batches, 1) + assert.Equal(t, []string{"parent/templates/hook.yaml"}, manifestNames(plan.Batches[0].Manifests())) + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_HasCustomReadiness(t *testing.T) { + t.Parallel() + + groupA := groupAnnotations("a") + groupA[releaseutil.AnnotationReadinessSuccess] = `{.status.ready} == true` + groupA[releaseutil.AnnotationReadinessFailure] = `{.status.failed} == true` + groupB := groupAnnotations("b", "a") + groupB[releaseutil.AnnotationReadinessSuccess] = `{.status.ready} == true` + manifests := []releaseutil.Manifest{ + makeManifest("a", "parent/templates/a.yaml", groupA), + makeManifest("b", "parent/templates/b.yaml", groupB), + makeManifest("plain", "parent/templates/plain.yaml", nil), + } + + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + require.Len(t, plan.Batches, 3) + assert.True(t, plan.Batches[0].HasCustomReadiness) + assert.False(t, plan.Batches[1].HasCustomReadiness) + assert.False(t, plan.Batches[2].HasCustomReadiness) + assertWarningContains(t, plan, "parent", "only one of") + assertPlanComplete(t, plan, manifests) +} + +func TestBuild_Deterministic(t *testing.T) { + t.Parallel() + + chartOne, manifestsOne := kitchenSinkFixture() + chartTwo, manifestsTwo := kitchenSinkFixture() + + planOne, err := Build(chartOne, manifestsOne) + require.NoError(t, err) + planTwo, err := Build(chartTwo, manifestsTwo) + require.NoError(t, err) + assert.Equal(t, planOne, planTwo) +} + +func TestBuild_Completeness_KitchenSink(t *testing.T) { + t.Parallel() + + chrt, manifests := kitchenSinkFixture() + plan, err := Build(chrt, manifests) + require.NoError(t, err) + assertPlanComplete(t, plan, manifests) + for _, batch := range plan.Batches { + assert.NotEmpty(t, batch.Manifests()) + if batch.Kind == BatchKindUnsequenced { + require.Len(t, batch.Groups, 1) + assert.Empty(t, batch.Groups[0].Name) + } + } +} + +type batchSummary struct { + ChartPath string + Depth int + Kind BatchKind + Groups []string +} + +func summarizeBatch(batch Batch) batchSummary { + return batchSummary{ + ChartPath: batch.ChartPath, + Depth: batch.Depth, + Kind: batch.Kind, + Groups: groupNames(batch), + } +} + +func batchSummaries(plan *Plan) []batchSummary { + summaries := make([]batchSummary, 0, len(plan.Batches)) + for _, batch := range plan.Batches { + summaries = append(summaries, summarizeBatch(batch)) + } + return summaries +} + +func batchGroupNames(plan *Plan) [][]string { + names := make([][]string, 0, len(plan.Batches)) + for _, batch := range plan.Batches { + names = append(names, groupNames(batch)) + } + return names +} + +func batchChartPaths(plan *Plan) []string { + paths := make([]string, 0, len(plan.Batches)) + for _, batch := range plan.Batches { + paths = append(paths, batch.ChartPath) + } + return paths +} + +func batchDepths(plan *Plan) []int { + depths := make([]int, 0, len(plan.Batches)) + for _, batch := range plan.Batches { + depths = append(depths, batch.Depth) + } + return depths +} + +func groupNames(batch Batch) []string { + names := make([]string, 0, len(batch.Groups)) + for _, group := range batch.Groups { + names = append(names, group.Name) + } + return names +} + +func manifestNames(manifests []releaseutil.Manifest) []string { + names := make([]string, 0, len(manifests)) + for _, manifest := range manifests { + names = append(names, manifest.Name) + } + return names +} + +func assertPlanComplete(t *testing.T, plan *Plan, manifests []releaseutil.Manifest) { + t.Helper() + + require.NotNil(t, plan) + expected := make(map[string]int, len(manifests)) + for _, manifest := range manifests { + expected[manifest.Name]++ + } + + actual := make(map[string]int, len(manifests)) + var total int + for _, batch := range plan.Batches { + for _, manifest := range batch.Manifests() { + actual[manifest.Name]++ + total++ + } + } + + assert.Equal(t, len(manifests), total) + assert.Equal(t, expected, actual) +} + +func assertWarningContains(t *testing.T, plan *Plan, chartPath string, parts ...string) { + t.Helper() + + for _, warning := range plan.Warnings { + if warning.ChartPath != chartPath { + continue + } + matches := true + for _, part := range parts { + if !strings.Contains(warning.Message, part) { + matches = false + break + } + } + if matches { + return + } + } + assert.Failf(t, "missing warning", "chartPath=%q parts=%v warnings=%v", chartPath, parts, plan.Warnings) +} + +func assertNoWarningContains(t *testing.T, plan *Plan, part string) { + t.Helper() + + for _, warning := range plan.Warnings { + assert.NotContains(t, warning.Message, part) + } +} + +func makeManifest(name, sourcePath string, annotations map[string]string) releaseutil.Manifest { + return makeTypedManifest(name, sourcePath, "v1", "ConfigMap", "", annotations) +} + +func makeTypedManifest(name, sourcePath, version, kind, namespace string, annotations map[string]string) releaseutil.Manifest { + annotations = maps.Clone(annotations) + var content strings.Builder + fmt.Fprintf(&content, "apiVersion: %s\nkind: %s\nmetadata:\n name: %s\n", version, kind, name) + if namespace != "" { + fmt.Fprintf(&content, " namespace: %s\n", namespace) + } + if len(annotations) > 0 { + content.WriteString(" annotations:\n") + for _, key := range slices.Sorted(maps.Keys(annotations)) { + fmt.Fprintf(&content, " %s: %q\n", key, annotations[key]) + } + } + + head := &releaseutil.SimpleHead{ + Version: version, + Kind: kind, + Metadata: &struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Annotations map[string]string `json:"annotations"` + }{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + } + + return releaseutil.Manifest{ + Name: sourcePath, + Content: content.String(), + Head: head, + } +} + +func groupAnnotations(group string, deps ...string) map[string]string { + annotations := map[string]string{ + releaseutil.AnnotationResourceGroup: group, + } + if len(deps) > 0 { + encoded, err := json.Marshal(deps) + if err != nil { + panic(err) + } + annotations[releaseutil.AnnotationDependsOnResourceGroups] = string(encoded) + } + return annotations +} + +func newChart(name string, deps ...*chart.Dependency) *chart.Chart { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: name, + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + Dependencies: deps, + }, + } + for _, dep := range deps { + if dep == nil || !dep.Enabled { + continue + } + subName := dep.Alias + if subName == "" { + subName = dep.Name + } + c.AddDependency(newChart(subName)) + } + return c +} + +func enabledDependency(name string, dependsOn ...string) *chart.Dependency { + return &chart.Dependency{ + Name: name, + Version: "0.1.0", + Enabled: true, + DependsOn: dependsOn, + } +} + +func aliasedDependency(name, alias string, dependsOn ...string) *chart.Dependency { + return &chart.Dependency{ + Name: name, + Version: "0.1.0", + Alias: alias, + Enabled: true, + DependsOn: dependsOn, + } +} + +func pipelineChart(deps ...*chart.Dependency) *chart.Chart { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + Dependencies: deps, + }, + } + added := make(map[string]bool) + for _, dep := range deps { + if added[dep.Name] { + continue + } + added[dep.Name] = true + c.AddDependency(&chart.Chart{ + Metadata: &chart.Metadata{ + Name: dep.Name, + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + }, + }) + } + return c +} + +func pipelineDependency(name, alias string, dependsOn ...string) *chart.Dependency { + return &chart.Dependency{ + Name: name, + Version: "0.1.0", + Alias: alias, + DependsOn: dependsOn, + } +} + +func kitchenSinkFixture() (*chart.Chart, []releaseutil.Manifest) { + primary := newChart("primary-db") + worker := newChart("worker") + app := newChart("app", enabledDependency("worker")) + app.SetDependencies(worker) + vendored := newChart("vendored") + + parent := newChart( + "parent", + aliasedDependency("postgres", "primary-db"), + enabledDependency("app", "primary-db"), + ) + parent.SetDependencies(primary, app, vendored) + + manifests := []releaseutil.Manifest{ + makeManifest("primary", "parent/charts/primary-db/templates/primary.yaml", nil), + makeManifest("worker", "parent/charts/app/charts/worker/templates/worker.yaml", nil), + makeManifest("app-sub", "parent/charts/app/templates/app.yaml", groupAnnotations("app-sub")), + makeManifest("vendored", "parent/charts/vendored/templates/vendored.yaml", nil), + makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")), + makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")), + makeManifest("metrics", "parent/templates/metrics.yaml", groupAnnotations("metrics")), + makeManifest("plain", "parent/templates/plain.yaml", nil), + } + return parent, manifests +} diff --git a/pkg/release/v1/sequence/manifest_parse.go b/pkg/release/v1/sequence/manifest_parse.go new file mode 100644 index 000000000..2d68c0ebf --- /dev/null +++ b/pkg/release/v1/sequence/manifest_parse.go @@ -0,0 +1,75 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sequence + +import ( + "fmt" + "sort" + "strings" + + "sigs.k8s.io/yaml" + + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +// ParseStoredManifests parses a stored release manifest stream (rel.Manifest) +// or `helm template` output into named manifests, recovering source-template +// paths from "# Source:" comments. Documents without a Source comment keep +// their positional "manifest-%d" split key, and therefore route to the +// top-level/flat portion of a plan built from them. Returns an error only on +// YAML head-parse failure. +func ParseStoredManifests(manifest string) ([]releaseutil.Manifest, error) { + rawManifests := releaseutil.SplitManifests(manifest) + keys := make([]string, 0, len(rawManifests)) + for key := range rawManifests { + keys = append(keys, key) + } + sort.Sort(releaseutil.BySplitManifestsOrder(keys)) + + manifests := make([]releaseutil.Manifest, 0, len(keys)) + for _, key := range keys { + content := rawManifests[key] + name := manifestSourcePath(content) + if name == "" { + name = key + } + + var head releaseutil.SimpleHead + if err := yaml.Unmarshal([]byte(content), &head); err != nil { + return nil, fmt.Errorf("YAML parse error on %s: %w", name, err) + } + + manifests = append(manifests, releaseutil.Manifest{ + Name: name, + Content: content, + Head: &head, + }) + } + + return manifests, nil +} + +func manifestSourcePath(content string) string { + for line := range strings.SplitSeq(content, "\n") { + line = strings.TrimSpace(line) + if source, ok := strings.CutPrefix(line, "# Source: "); ok { + return source + } + } + + return "" +} diff --git a/pkg/release/v1/sequence/plan.go b/pkg/release/v1/sequence/plan.go new file mode 100644 index 000000000..17245e3d1 --- /dev/null +++ b/pkg/release/v1/sequence/plan.go @@ -0,0 +1,160 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package sequence computes the ordered deployment plan for a release's +// rendered manifests per HIP-0025: a two-level walk over the chart's subchart +// DAG and each chart level's resource-group DAG, flattened into an ordered +// list of apply-and-wait batches. The package is pure — it never talks to a +// cluster and must not import pkg/kube, pkg/action, or pkg/cmd — so the same +// plan is usable by install/upgrade drivers, uninstall/rollback (reversed), +// `helm template`/`helm dag` rendering, and lint. +package sequence + +import ( + "strings" + + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +// BatchKind distinguishes the two batch flavors a chart level produces. +type BatchKind uint8 + +const ( + // BatchKindGroups: one topological level of the resource-group DAG — + // one or more groups deployable in parallel. + BatchKindGroups BatchKind = iota + // BatchKindUnsequenced: a chart level's trailing batch — resources with no + // group annotation, resources demoted for invalid depends-on JSON, + // groups demoted for missing references, and isolated groups. + BatchKindUnsequenced +) + +// Group is a named set of manifests deployed as one unit within a batch. +type Group struct { + Name string // "" only inside a BatchKindUnsequenced batch + Manifests []releaseutil.Manifest // input order preserved +} + +// Batch is one apply-and-wait unit. Drivers process Batches strictly in order. +type Batch struct { + // ChartPath is the manifest path-prefix of the owning chart level: + // "parent", "parent/charts/db", "parent/charts/db/charts/redis". + // "" when the plan was built without a chart (flat fallback). + ChartPath string + // Depth is the chart-nesting depth (0 = top level). Display-only. + Depth int + Kind BatchKind + // Groups: for BatchKindGroups, ≥1 groups sorted by Name (one DAG level); + // for BatchKindUnsequenced, exactly one Group{Name: ""}. + Groups []Group + // Wait: gate on readiness (install/upgrade) or deletion (reversed plans) + // before starting the next batch. The builder sets true for every batch; + // the field exists so a strict-HIP mode can flip leaf-only batches later. + Wait bool + // HasCustomReadiness: at least one manifest in the batch carries BOTH + // helm.sh/readiness-success and helm.sh/readiness-failure (drivers add + // kube.WithCustomReadinessStatusReader for such batches). + HasCustomReadiness bool + // LeafGroups: names of groups in this batch with no dependents in this + // chart level's DAG. Informational — display and future policy only. + LeafGroups []string +} + +// Manifests flattens the batch's groups in order. Convenience for drivers. +func (b Batch) Manifests() []releaseutil.Manifest { + var manifests []releaseutil.Manifest + for _, group := range b.Groups { + manifests = append(manifests, group.Manifests...) + } + return manifests +} + +// WarningKind classifies a Warning so consumers can map runtime demotion +// classes to severities without string-matching messages. +type WarningKind uint8 + +const ( + // WarningKindResourceGroupDemotion: a resource or group demoted to the + // unsequenced batch by ParseResourceGroups (malformed depends-on JSON, + // reference to a non-existent group). Lint treats these as errors. + WarningKindResourceGroupDemotion WarningKind = iota + // WarningKindIsolatedGroup: a group with no edges demoted to unsequenced. + WarningKindIsolatedGroup + // WarningKindPartialReadiness: only one of the two readiness annotations + // present; the resource falls back to kstatus. + WarningKindPartialReadiness + // WarningKindUndeclaredSubchart: rendered subchart absent from Chart.yaml. + WarningKindUndeclaredSubchart + // WarningKindUnresolvedSubchart: rendered subchart dir with no resolvable + // chart object (typical for nested levels of storage-decoded charts) whose + // structural walk found ≥2 sibling subcharts, so their depends-on order, + // if any was declared, could not be recovered. + WarningKindUnresolvedSubchart +) + +// Warning is a non-fatal misconfiguration surfaced during Build. +// Drivers log them; lint maps them to rule severities. +type Warning struct { + Kind WarningKind + ChartPath string // "" for chart-independent warnings + Message string +} + +// ChartLevel summarizes one chart's slot in the walk, in pre-order. +// Consumed by `helm dag` (subchart-batch display) and diagnostics. +type ChartLevel struct { + Path string // manifest path-prefix (as Batch.ChartPath) + Depth int + SubchartBatches [][]string // declared subcharts per parallel DAG batch (effective names) + Undeclared []string // rendered subcharts absent from Chart.yaml deps (sorted) + Unresolved []string // rendered subchart dirs with no resolvable chart object (sorted) +} + +// Plan is the complete deployment sequence for one release's manifests. +type Plan struct { + Batches []Batch // forward (install) order + Levels []ChartLevel // pre-order chart traversal + Warnings []Warning +} + +// Reverse returns a new Plan whose Batches are in exact reverse order, for +// uninstall and removed-resource deletion (HIP: uninstall is the exact reverse +// of install). Levels and Warnings are shared unchanged; Wait retains its +// meaning ("gate before next batch", i.e. wait-for-delete). +func (p *Plan) Reverse() *Plan { + if p == nil { + return nil + } + + reversed := make([]Batch, len(p.Batches)) + for i, batch := range p.Batches { + reversed[len(p.Batches)-1-i] = batch + } + + return &Plan{ + Batches: reversed, + Levels: p.Levels, + Warnings: p.Warnings, + } +} + +// DisplayPath converts a manifest path-prefix to the HIP display form used by +// template markers and `helm dag`: "parent/charts/db/charts/redis" → "parent/db/redis". +// (Chart names cannot contain '/', so replacing "/charts/" is unambiguous; +// a subchart literally named "charts" is pathological and unsupported.) +func DisplayPath(chartPath string) string { + return strings.ReplaceAll(chartPath, "/charts/", "/") +} diff --git a/pkg/release/v1/sequence/plan_test.go b/pkg/release/v1/sequence/plan_test.go new file mode 100644 index 000000000..c84dacbce --- /dev/null +++ b/pkg/release/v1/sequence/plan_test.go @@ -0,0 +1,171 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sequence + +import ( + "fmt" + "go/parser" + "go/token" + "os" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" +) + +func TestPlanReverse(t *testing.T) { + t.Parallel() + + manifests := []releaseutil.Manifest{ + makeManifest("base", "parent/templates/base.yaml", groupAnnotations("base")), + makeManifest("left", "parent/templates/left.yaml", groupAnnotations("left", "base")), + makeManifest("right", "parent/templates/right.yaml", groupAnnotations("right", "base")), + makeManifest("top", "parent/templates/top.yaml", groupAnnotations("top", "left", "right")), + makeManifest("plain", "parent/templates/plain.yaml", nil), + } + plan, err := Build(newChart("parent"), manifests) + require.NoError(t, err) + require.GreaterOrEqual(t, len(plan.Batches), 4) + + original := batchSummaries(plan) + reversed := plan.Reverse() + require.NotNil(t, reversed) + require.Len(t, reversed.Batches, len(plan.Batches)) + + for i := range reversed.Batches { + assert.Equal(t, original[len(original)-1-i], summarizeBatch(reversed.Batches[i])) + } + assert.Equal(t, original, batchSummaries(plan), "Reverse must not mutate the original plan") + assert.Equal(t, plan.Levels, reversed.Levels) + assert.Equal(t, plan.Warnings, reversed.Warnings) + assert.Equal(t, original, batchSummaries(reversed.Reverse())) +} + +func TestDisplayPath(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "parent": "parent", + "parent/charts/db": "parent/db", + "parent/charts/db/charts/redis": "parent/db/redis", + "": "", + } + + for input, expected := range tests { + assert.Equal(t, expected, DisplayPath(input)) + } +} + +func TestBatchManifests(t *testing.T) { + t.Parallel() + + first := makeManifest("first", "parent/templates/first.yaml", nil) + second := makeManifest("second", "parent/templates/second.yaml", nil) + third := makeManifest("third", "parent/templates/third.yaml", nil) + batch := Batch{ + Groups: []Group{ + {Name: "a", Manifests: []releaseutil.Manifest{first, second}}, + {Name: "b", Manifests: []releaseutil.Manifest{third}}, + }, + } + + assert.Equal(t, []releaseutil.Manifest{first, second, third}, batch.Manifests()) +} + +func TestReadinessAnnotationValues(t *testing.T) { + t.Parallel() + + assert.Equal(t, "helm.sh/readiness-success", releaseutil.AnnotationReadinessSuccess) + assert.Equal(t, "helm.sh/readiness-failure", releaseutil.AnnotationReadinessFailure) +} + +func TestPackageImportPurity(t *testing.T) { + t.Parallel() + + entries, err := os.ReadDir(".") + require.NoError(t, err) + + allowedHelmImports := map[string]bool{ + "helm.sh/helm/v4/pkg/chart/v2": true, + "helm.sh/helm/v4/pkg/chart/v2/util": true, + "helm.sh/helm/v4/pkg/release/v1/util": true, + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + + file, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, parser.ImportsOnly) + require.NoError(t, err) + for _, imported := range file.Imports { + path, err := strconv.Unquote(imported.Path.Value) + require.NoError(t, err) + if strings.HasPrefix(path, "helm.sh/helm/v4/") { + assert.Truef(t, allowedHelmImports[path], "forbidden helm import %q in %s", path, entry.Name()) + } + assert.Falsef(t, strings.HasPrefix(path, "k8s.io/"), "forbidden Kubernetes import %q in %s", path, entry.Name()) + } + } +} + +func TestParseStoredManifests(t *testing.T) { + t.Parallel() + + stream := strings.Join([]string{ + "---\n# Source: parent/templates/a.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n annotations:\n example.com/key: value\n", + "---\n# Source: parent/charts/db/templates/b.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: b\n", + "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n", + }, "") + + parsed, err := ParseStoredManifests(stream) + require.NoError(t, err) + require.Len(t, parsed, 3) + assert.Equal(t, []string{"parent/templates/a.yaml", "parent/charts/db/templates/b.yaml", "manifest-2"}, manifestNames(parsed)) + assert.Equal(t, "ConfigMap", parsed[0].Head.Kind) + assert.Equal(t, "a", parsed[0].Head.Metadata.Name) + assert.Equal(t, "value", parsed[0].Head.Metadata.Annotations["example.com/key"]) + assert.Equal(t, "b", parsed[1].Head.Metadata.Name) + assert.Equal(t, "c", parsed[2].Head.Metadata.Name) + + _, err = ParseStoredManifests("---\n# Source: bad.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: [\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "YAML parse error") + + flat, err := Build(nil, parsed) + require.NoError(t, err) + assertPlanComplete(t, flat, parsed) + require.Len(t, flat.Batches, 1) + assert.Empty(t, flat.Batches[0].ChartPath) + + db := makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db")) + app := makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db")) + stored := fmt.Sprintf("---\n# Source: %s\n%s---\n# Source: %s\n%s", db.Name, db.Content, app.Name, app.Content) + parsedStored, err := ParseStoredManifests(stored) + require.NoError(t, err) + + fromStored, err := Build(newChart("parent"), parsedStored) + require.NoError(t, err) + fromDirect, err := Build(newChart("parent"), []releaseutil.Manifest{db, app}) + require.NoError(t, err) + assert.Equal(t, batchSummaries(fromDirect), batchSummaries(fromStored)) + assertPlanComplete(t, fromStored, parsedStored) +} diff --git a/pkg/release/v1/util/manifest.go b/pkg/release/v1/util/manifest.go index fa26f6256..24250e1f3 100644 --- a/pkg/release/v1/util/manifest.go +++ b/pkg/release/v1/util/manifest.go @@ -19,9 +19,12 @@ package util import ( "fmt" "regexp" + "slices" "strconv" "strings" "unicode" + + "sigs.k8s.io/yaml" ) // SimpleHead defines what the structure of the head of a manifest file @@ -30,6 +33,7 @@ type SimpleHead struct { Kind string `json:"kind,omitempty"` Metadata *struct { Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` Annotations map[string]string `json:"annotations"` } `json:"metadata,omitempty"` } @@ -80,3 +84,248 @@ func (a BySplitManifestsOrder) Less(i, j int) bool { return anum < bnum } func (a BySplitManifestsOrder) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +// StripHelmInternalAnnotations returns manifest content with Helm-internal +// sequencing annotations removed from top-level object metadata. This exists +// so that `helm template | kubectl apply -f -` remains valid even when charts +// use HIP-0025 sequencing annotation keys with multiple `/` separators, which +// fail Kubernetes annotation-key validation. +// +// The strip is block-scoped and conservative. Each YAML document first takes a +// byte-identity fast path unless the parsed SimpleHead confirms that a +// Helm-internal sequencing key is present in metadata.annotations. Confirmed +// documents are scanned as text, and the function only deletes whole lines +// inside the confirmed top-level metadata.annotations block, including +// continuation lines for stripped values. On any structural doubt, it returns +// the original document verbatim. The failure mode is "annotation retained", +// never "manifest corrupted". +// +// Stored release manifests keep these annotations because they are the +// uninstall/rollback plan input recovered by sequence.ParseStoredManifests. +// `helm get manifest` prints the stored record verbatim by design. Only output +// surfaces such as `helm template` and apply-time paths strip them. This aligns +// with the object-domain visitor stripSequencingAnnotations in pkg/action, +// which also strips only top-level object metadata. Annotations nested in pod +// templates or List items are not functional for sequencing and are no longer +// masked by this text strip. +func StripHelmInternalAnnotations(content string) string { + if strings.TrimSpace(content) == "" { + return content + } + + var out strings.Builder + var doc strings.Builder + for _, line := range strings.SplitAfter(content, "\n") { + if isYAMLDocumentSeparatorLine(line) { + out.WriteString(stripHelmInternalAnnotationsFromDoc(doc.String())) + doc.Reset() + out.WriteString(line) + continue + } + doc.WriteString(line) + } + out.WriteString(stripHelmInternalAnnotationsFromDoc(doc.String())) + return out.String() +} + +func stripHelmInternalAnnotationsFromDoc(doc string) string { + if strings.TrimSpace(doc) == "" { + return doc + } + + var head SimpleHead + if err := yaml.Unmarshal([]byte(doc), &head); err != nil { + return doc + } + if head.Metadata == nil || !hasHelmInternalSequencingAnnotation(head.Metadata.Annotations) { + return doc + } + + lines := strings.SplitAfter(doc, "\n") + metadataLine := -1 + for i, line := range lines { + if isExactYAMLKeyLine(line, 0, "metadata") { + metadataLine = i + break + } + } + if metadataLine == -1 { + return doc + } + + metadataStart := metadataLine + 1 + metadataEnd := len(lines) + for i := metadataStart; i < len(lines); i++ { + if isBlankYAMLLine(lines[i]) { + continue + } + if yamlLineIndent(lines[i]) == 0 { + metadataEnd = i + break + } + } + + metadataChildIndent, ok := minimumYAMLIndent(lines[metadataStart:metadataEnd]) + if !ok { + return doc + } + + annotationsLine := -1 + for i := metadataStart; i < metadataEnd; i++ { + if isExactYAMLKeyLine(lines[i], metadataChildIndent, "annotations") { + annotationsLine = i + break + } + } + if annotationsLine == -1 { + return doc + } + + annotationsStart := annotationsLine + 1 + annotationsEnd := metadataEnd + for i := annotationsStart; i < metadataEnd; i++ { + if isBlankYAMLLine(lines[i]) { + continue + } + if yamlLineIndent(lines[i]) <= metadataChildIndent { + annotationsEnd = i + break + } + } + + annotationEntryIndent, ok := minimumYAMLIndent(lines[annotationsStart:annotationsEnd]) + if !ok { + return doc + } + + var out strings.Builder + for i := 0; i < len(lines); { + if i >= annotationsStart && + i < annotationsEnd && + yamlLineIndent(lines[i]) == annotationEntryIndent && + isHelmInternalAnnotationEntryLine(lines[i], annotationEntryIndent) { + i++ + for i < annotationsEnd { + if isBlankYAMLLine(lines[i]) { + nextNonBlank := i + 1 + for nextNonBlank < annotationsEnd && isBlankYAMLLine(lines[nextNonBlank]) { + nextNonBlank++ + } + if nextNonBlank < annotationsEnd && yamlLineIndent(lines[nextNonBlank]) > annotationEntryIndent { + i = nextNonBlank + continue + } + break + } + if yamlLineIndent(lines[i]) <= annotationEntryIndent { + break + } + i++ + } + continue + } + out.WriteString(lines[i]) + i++ + } + return out.String() +} + +func hasHelmInternalSequencingAnnotation(annotations map[string]string) bool { + if annotations == nil { + return false + } + for _, key := range helmInternalSequencingAnnotations { + if _, ok := annotations[key]; ok { + return true + } + } + return false +} + +func isYAMLDocumentSeparatorLine(line string) bool { + body := strings.TrimSuffix(line, "\n") + return strings.TrimRight(body, " \t\r") == "---" +} + +func lineBody(line string) string { + body := strings.TrimSuffix(line, "\n") + return strings.TrimSuffix(body, "\r") +} + +func isBlankYAMLLine(line string) bool { + return strings.TrimSpace(lineBody(line)) == "" +} + +func yamlLineIndent(line string) int { + body := lineBody(line) + for i, r := range body { + if r != ' ' && r != '\t' { + return i + } + } + return len(body) +} + +func isExactYAMLKeyLine(line string, indent int, key string) bool { + body := lineBody(line) + if yamlLineIndent(line) != indent { + return false + } + return strings.TrimRight(body[indent:], " \t") == key+":" +} + +func minimumYAMLIndent(lines []string) (int, bool) { + minIndent := 0 + found := false + for _, line := range lines { + if isBlankYAMLLine(line) { + continue + } + indent := yamlLineIndent(line) + if !found || indent < minIndent { + minIndent = indent + found = true + } + } + return minIndent, found +} + +func isHelmInternalAnnotationEntryLine(line string, indent int) bool { + key, ok := yamlEntryKey(line, indent) + if !ok { + return false + } + return slices.Contains(helmInternalSequencingAnnotations, key) +} + +func yamlEntryKey(line string, indent int) (string, bool) { + body := lineBody(line) + if yamlLineIndent(line) != indent { + return "", false + } + entry := body[indent:] + if entry == "" { + return "", false + } + switch entry[0] { + case '\'', '"': + quote := entry[0] + end := strings.IndexByte(entry[1:], quote) + if end == -1 { + return "", false + } + key := entry[1 : end+1] + rest := entry[end+2:] + if !strings.HasPrefix(rest, ":") { + return "", false + } + return key, true + default: + for _, key := range helmInternalSequencingAnnotations { + if strings.HasPrefix(entry, key+":") { + return key, true + } + } + return "", false + } +} diff --git a/pkg/release/v1/util/manifest_test.go b/pkg/release/v1/util/manifest_test.go index d1efa21e1..88dc08ace 100644 --- a/pkg/release/v1/util/manifest_test.go +++ b/pkg/release/v1/util/manifest_test.go @@ -17,9 +17,11 @@ limitations under the License. package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( + "strings" "testing" "github.com/stretchr/testify/assert" + "sigs.k8s.io/yaml" ) func TestSplitManifests(t *testing.T) { @@ -514,3 +516,355 @@ metadata: }) } } + +func TestStripHelmInternalAnnotations(t *testing.T) { + tests := []struct { + name string + input string + expected string + mustNotContain []string + mustContain []string + mustEqualVerbatim bool + mustParseOutput bool + }{ + { + name: "strips multi-slash key, preserves siblings", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-a + annotations: + helm.sh/depends-on/resource-groups: '["foo"]' + helm.sh/resource-group: app + other.example.com/keep: "yes" +data: + k: v +`, + mustNotContain: []string{"helm.sh/depends-on/resource-groups"}, + mustContain: []string{"helm.sh/resource-group", "other.example.com/keep", "name: cm-a"}, + }, + { + name: "leaves dangling empty annotations key when sole annotation stripped", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-b + annotations: + helm.sh/depends-on/resource-groups: '["foo"]' +data: + k: v +`, + mustNotContain: []string{"helm.sh/depends-on/resource-groups"}, + mustContain: []string{"name: cm-b", "data:"}, + }, + { + name: "no annotations leaves doc unchanged", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-c +data: + k: v +`, + mustEqualVerbatim: true, + }, + { + name: "non-internal annotations preserved", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-d + annotations: + helm.sh/resource-group: app +data: + k: v +`, + mustEqualVerbatim: true, + }, + { + name: "non-kubernetes yaml passes through", + input: "foo: bar\nbaz: 1\n", + mustEqualVerbatim: true, + }, + { + name: "invalid yaml passes through", + input: ":\n\tnot valid yaml at all: [", + mustEqualVerbatim: true, + }, + { + name: "data block scalar look-alike survives", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-rules +data: + rules.txt: | + helm.sh/depends-on/resource-groups: ["a"] + second line +`, + mustEqualVerbatim: true, + }, + { + name: "annotation in metadata AND look-alike in data", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-mixed + annotations: + helm.sh/depends-on/resource-groups: '["db"]' + helm.sh/resource-group: app +data: + rules.txt: | + helm.sh/depends-on/resource-groups: '["db"]' + second line +`, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-mixed + annotations: + helm.sh/resource-group: app +data: + rules.txt: | + helm.sh/depends-on/resource-groups: '["db"]' + second line +`, + }, + { + name: "multi-line annotation value stripped whole", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-multiline + annotations: + helm.sh/depends-on/resource-groups: >- + ["databases", + "cache"] + keep.example.com/x: "y" +data: + k: v +`, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-multiline + annotations: + keep.example.com/x: "y" +data: + k: v +`, + }, + { + name: "folded annotation value with internal blank line stripped whole", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-blank + annotations: + helm.sh/depends-on/resource-groups: >- + ["a", + + "b"] + keep.example.com/x: "y" +data: + k: v +`, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-blank + annotations: + keep.example.com/x: "y" +data: + k: v +`, + mustParseOutput: true, + }, + { + name: "literal annotation value with internal blank line stripped whole", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-literal-blank + annotations: + helm.sh/depends-on/resource-groups: | + ["a", + + "b"] + keep.example.com/x: "y" +data: + script: | + preserve + + me +`, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-literal-blank + annotations: + keep.example.com/x: "y" +data: + script: | + preserve + + me +`, + mustParseOutput: true, + }, + { + name: "value containing '#' handled", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-hash + annotations: + helm.sh/depends-on/resource-groups: '["a#b"]' + keep.example.com/x: "y" +data: + k: v +`, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-hash + annotations: + keep.example.com/x: "y" +data: + k: v +`, + }, + { + name: "multi-doc stream: only affected doc changes", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-clean +data: + k: v +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-annotated + annotations: + helm.sh/depends-on/resource-groups: '["db"]' + keep.example.com/x: "y" +data: + k: v +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-data +data: + rules.txt: | + helm.sh/depends-on/resource-groups: ["db"] + keep +`, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-clean +data: + k: v +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-annotated + annotations: + keep.example.com/x: "y" +data: + k: v +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-data +data: + rules.txt: | + helm.sh/depends-on/resource-groups: ["db"] + keep +`, + }, + { + name: "byte-identity fast path odd formatting", + input: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm-fast \n # comment about annotations\n\ndata:\n k: v ", + mustEqualVerbatim: true, + }, + { + name: "byte-identity fast path CRLF", + input: "apiVersion: v1\r\nkind: ConfigMap\r\nmetadata:\r\n name: cm-crlf\r\ndata:\r\n k: v\r\n", + mustEqualVerbatim: true, + }, + { + name: "quoted key form stripped", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-quoted + annotations: + "helm.sh/depends-on/resource-groups": '["a"]' + keep.example.com/x: "y" +data: + k: v +`, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-quoted + annotations: + keep.example.com/x: "y" +data: + k: v +`, + }, + { + name: "separator inside block scalar does not corrupt", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-separator +data: + script: | +--- + still byte-identical +`, + mustEqualVerbatim: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StripHelmInternalAnnotations(tt.input) + if tt.mustParseOutput { + if err := yaml.Unmarshal([]byte(got), map[string]any{}); err != nil { + t.Errorf("expected output to parse as yaml, got error: %v\n%s", err, got) + } + } + if tt.expected != "" { + if got != tt.expected { + t.Errorf("expected exact output:\n%s\ngot:\n%s", tt.expected, got) + } + return + } + if tt.mustEqualVerbatim { + if got != tt.input { + t.Errorf("expected verbatim passthrough, got:\n%s", got) + } + return + } + for _, s := range tt.mustNotContain { + if strings.Contains(got, s) { + t.Errorf("output unexpectedly contains %q:\n%s", s, got) + } + } + for _, s := range tt.mustContain { + if !strings.Contains(got, s) { + t.Errorf("output missing expected %q:\n%s", s, got) + } + } + }) + } +} diff --git a/pkg/release/v1/util/resource_group.go b/pkg/release/v1/util/resource_group.go new file mode 100644 index 000000000..f65674331 --- /dev/null +++ b/pkg/release/v1/util/resource_group.go @@ -0,0 +1,261 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "encoding/json" + "fmt" + "slices" + + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" +) + +const ( + // AnnotationResourceGroup declares the resource-group a resource belongs to. + AnnotationResourceGroup = "helm.sh/resource-group" + + // AnnotationDependsOnResourceGroups declares prerequisite resource-groups for a + // resource's group as a JSON string array. + AnnotationDependsOnResourceGroups = "helm.sh/depends-on/resource-groups" + + // AnnotationReadinessSuccess declares custom readiness success conditions. + // Its value is a JSON array of expressions in the form: + // "{.fieldPath} " + // If any success condition evaluates to true, the resource is considered ready. + // This is the canonical home of the key (moved from pkg/kube, which keeps + // value-identical aliases) so the pure sequencing plan builder can read it + // without importing cluster machinery. + AnnotationReadinessSuccess = "helm.sh/readiness-success" + + // AnnotationReadinessFailure declares custom readiness failure conditions. + // Its value is a JSON array of expressions in the form: + // "{.fieldPath} " + // If any failure condition evaluates to true, the resource is considered failed. + // Failure conditions take precedence over success conditions. + AnnotationReadinessFailure = "helm.sh/readiness-failure" +) + +// helmInternalSequencingAnnotations lists annotation keys used by Helm for +// resource sequencing that are NOT valid Kubernetes annotation keys (their +// names contain multiple `/` separators). Helm strips these before applying +// resources to the API server, and before printing manifests via +// `helm template` so downstream tooling like `kubectl apply` accepts the +// output unmodified. +// +// It is unexported, and the regex in manifest.go is compiled from it once at +// init. External callers read it through the HelmInternalSequencingAnnotations +// accessor (which returns a copy) so the canonical list — and the regex derived +// from it — cannot be mutated, avoiding the silent-no-op and data-race footguns +// of an exported mutable slice. +var helmInternalSequencingAnnotations = []string{ + AnnotationDependsOnResourceGroups, +} + +// HelmInternalSequencingAnnotations returns a copy of the helm-internal +// sequencing annotation keys that Helm strips from rendered output. A copy is +// returned so callers cannot mutate the canonical list. +func HelmInternalSequencingAnnotations() []string { + return slices.Clone(helmInternalSequencingAnnotations) +} + +// ResourceGroupResult holds the output of ParseResourceGroups. +type ResourceGroupResult struct { + // Groups maps group name to manifests assigned to that group. + Groups map[string][]Manifest + + // GroupDeps maps group name to the group names it depends on. + GroupDeps map[string][]string + + // Unsequenced contains manifests that should be deployed outside the DAG. + Unsequenced []Manifest +} + +// ParseResourceGroups extracts resource-group annotations from rendered +// manifests and partitions them into sequenced groups and unsequenced manifests. +// +// Manifests without a resource-group annotation are treated as unsequenced. +// Invalid depends-on annotations emit a warning and demote that manifest to the +// unsequenced batch. References to unknown groups emit a warning and demote the +// entire referencing group to the unsequenced batch. If the same resource is +// assigned to different groups, ParseResourceGroups returns an error. +func ParseResourceGroups(manifests []Manifest) (ResourceGroupResult, []string, error) { + result := ResourceGroupResult{ + Groups: make(map[string][]Manifest), + GroupDeps: make(map[string][]string), + } + var warnings []string + + resourceAssignments := make(map[string]string) + groupDeps := make(map[string][]string) + groupOrder := make([]string, 0) + + for _, manifest := range manifests { + groupName, hasGroup := resourceGroupName(manifest) + if !hasGroup { + result.Unsequenced = append(result.Unsequenced, manifest) + continue + } + + resourceID := resourceGroupResourceID(manifest) + if existingGroup, ok := resourceAssignments[resourceID]; ok && existingGroup != groupName { + return ResourceGroupResult{}, warnings, fmt.Errorf( + "resource %q assigned to multiple resource groups %q and %q", + resourceID, + existingGroup, + groupName, + ) + } + resourceAssignments[resourceID] = groupName + + deps, warning, err := resourceGroupDependencies(manifest) + if err != nil { + warnings = append(warnings, warning) + result.Unsequenced = append(result.Unsequenced, manifest) + continue + } + + if _, ok := result.Groups[groupName]; !ok { + groupOrder = append(groupOrder, groupName) + } + + result.Groups[groupName] = append(result.Groups[groupName], manifest) + groupDeps[groupName] = appendUniqueStrings(groupDeps[groupName], deps...) + } + + for changed := true; changed; { + changed = false + + for _, groupName := range groupOrder { + groupManifests, ok := result.Groups[groupName] + if !ok { + continue + } + + missingDep := firstMissingDependency(result.Groups, groupDeps[groupName]) + if missingDep == "" { + continue + } + + warnings = append(warnings, fmt.Sprintf( + "group %q depends-on non-existent group %q; moving group to unsequenced batch", + groupName, + missingDep, + )) + result.Unsequenced = append(result.Unsequenced, groupManifests...) + delete(result.Groups, groupName) + changed = true + } + } + + for _, groupName := range groupOrder { + if _, ok := result.Groups[groupName]; !ok { + continue + } + + result.GroupDeps[groupName] = groupDeps[groupName] + } + + return result, warnings, nil +} + +// BuildResourceGroupDAG constructs a DAG from the resource-group parse result. +// Each group becomes a DAG node and every dependency becomes an edge. +func BuildResourceGroupDAG(result ResourceGroupResult) (*chartutil.DAG, error) { + dag := chartutil.NewDAG() + + for groupName := range result.Groups { + dag.AddNode(groupName) + } + + for groupName, deps := range result.GroupDeps { + for _, dep := range deps { + if err := dag.AddEdge(dep, groupName); err != nil { + return nil, fmt.Errorf("resource-group sequencing edge %s→%s: %w", dep, groupName, err) + } + } + } + + return dag, nil +} + +func resourceGroupName(manifest Manifest) (string, bool) { + if manifest.Head == nil || manifest.Head.Metadata == nil || len(manifest.Head.Metadata.Annotations) == 0 { + return "", false + } + + groupName, ok := manifest.Head.Metadata.Annotations[AnnotationResourceGroup] + if !ok || groupName == "" { + return "", false + } + + return groupName, true +} + +func resourceGroupDependencies(manifest Manifest) ([]string, string, error) { + annotations := manifest.Head.Metadata.Annotations + rawDeps, ok := annotations[AnnotationDependsOnResourceGroups] + if !ok { + return nil, "", nil + } + + var deps []string + if err := json.Unmarshal([]byte(rawDeps), &deps); err != nil { + return nil, fmt.Sprintf( + "manifest %q: invalid JSON in %s annotation: %v; moving to unsequenced batch", + manifest.Name, + AnnotationDependsOnResourceGroups, + err, + ), err + } + + return deps, "", nil +} + +func firstMissingDependency(groups map[string][]Manifest, deps []string) string { + for _, dep := range deps { + if _, ok := groups[dep]; !ok { + return dep + } + } + + return "" +} + +func appendUniqueStrings(existing []string, values ...string) []string { + seen := make(map[string]struct{}, len(existing)) + for _, value := range existing { + seen[value] = struct{}{} + } + + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + existing = append(existing, value) + seen[value] = struct{}{} + } + + return existing +} + +func resourceGroupResourceID(manifest Manifest) string { + if manifest.Head == nil || manifest.Head.Metadata == nil || manifest.Head.Metadata.Name == "" { + return manifest.Name + } + + return fmt.Sprintf("%s/%s/%s/%s", manifest.Head.Version, manifest.Head.Kind, manifest.Head.Metadata.Namespace, manifest.Head.Metadata.Name) +} diff --git a/pkg/release/v1/util/resource_group_test.go b/pkg/release/v1/util/resource_group_test.go new file mode 100644 index 000000000..14f6c95dc --- /dev/null +++ b/pkg/release/v1/util/resource_group_test.go @@ -0,0 +1,381 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseResourceGroups_NoAnnotations(t *testing.T) { + t.Parallel() + + result, warnings := parseResourceGroups(t, + makeManifest("plain-a", "chart/templates/plain-a.yaml", nil), + makeManifest("plain-b", "chart/templates/plain-b.yaml", nil), + ) + + assert.Empty(t, result.Groups) + assert.Empty(t, result.GroupDeps) + require.Len(t, result.Unsequenced, 2) + assert.Equal(t, []string{"chart/templates/plain-a.yaml", "chart/templates/plain-b.yaml"}, manifestPaths(result.Unsequenced)) + assert.Empty(t, warnings) +} + +func TestParseResourceGroups_GroupWithDependency(t *testing.T) { + t.Parallel() + + result, warnings, batches := parseResourceGroupBatches(t, + makeManifest("database", "chart/templates/database.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["database"]`, + }), + ) + + require.Len(t, result.Groups, 2) + assert.Equal(t, [][]string{{"database"}, {"app"}}, batches) + assert.Empty(t, result.Unsequenced) + assert.Empty(t, warnings) +} + +func TestParseResourceGroups_MultipleResourcesPerGroup(t *testing.T) { + t.Parallel() + + result, warnings, batches := parseResourceGroupBatches(t, + makeManifest("database-config", "chart/templates/database-config.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("database-secret", "chart/templates/database-secret.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["database"]`, + }), + ) + + require.Len(t, result.Groups["database"], 2) + assert.Equal(t, []string{ + "chart/templates/database-config.yaml", + "chart/templates/database-secret.yaml", + }, manifestPaths(result.Groups["database"])) + assert.Equal(t, [][]string{{"database"}, {"app"}}, batches) + assert.Empty(t, warnings) +} + +func TestParseResourceGroups_ResourceAssignedToMultipleGroups(t *testing.T) { + t.Parallel() + + _, _, err := ParseResourceGroups([]Manifest{ + makeManifest("shared", "chart/templates/database.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("shared", "chart/templates/cache.yaml", map[string]string{ + AnnotationResourceGroup: "cache", + }), + }) + + require.Error(t, err) + assert.ErrorContains(t, err, "assigned to multiple resource groups") + assert.ErrorContains(t, err, "database") + assert.ErrorContains(t, err, "cache") +} + +// TestParseResourceGroups_SameNameDifferentNamespaces locks the contract +// that two resources sharing apiVersion+kind+name but living in distinct +// namespaces are NOT collapsed into a single resource ID. Without the +// namespace dimension in resourceGroupResourceID, this scenario would +// raise a false "assigned to multiple resource groups" error. +func TestParseResourceGroups_SameNameDifferentNamespaces(t *testing.T) { + t.Parallel() + + m1 := makeManifest("shared", "chart/templates/a.yaml", map[string]string{ + AnnotationResourceGroup: "groupA", + }) + m1.Head.Metadata.Namespace = "ns-a" + m2 := makeManifest("shared", "chart/templates/b.yaml", map[string]string{ + AnnotationResourceGroup: "groupB", + }) + m2.Head.Metadata.Namespace = "ns-b" + + result, warnings, err := ParseResourceGroups([]Manifest{m1, m2}) + + require.NoError(t, err, "same-name resources in different namespaces must not collide") + assert.Empty(t, warnings) + assert.Len(t, result.Groups["groupA"], 1) + assert.Len(t, result.Groups["groupB"], 1) +} + +func TestParseResourceGroups_NonExistentGroupReferenceWarning(t *testing.T) { + t.Parallel() + + result, warnings := parseResourceGroups(t, + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["missing"]`, + }), + ) + + assert.Empty(t, result.Groups) + require.Len(t, result.Unsequenced, 1) + assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced)) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "non-existent group") + assert.Contains(t, warnings[0], "missing") +} + +func TestParseResourceGroups_CascadingMissingDeps(t *testing.T) { + t.Parallel() + + manifests := []Manifest{ + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeManifest("database", "chart/templates/database.yaml", map[string]string{ + AnnotationResourceGroup: "database", + AnnotationDependsOnResourceGroups: `["missing"]`, + }), + } + + for range 64 { + result, warnings := parseResourceGroups(t, manifests...) + + assert.Empty(t, result.Groups) + assert.Empty(t, result.GroupDeps) + require.Len(t, result.Unsequenced, 2) + assert.ElementsMatch(t, []string{ + "chart/templates/app.yaml", + "chart/templates/database.yaml", + }, manifestPaths(result.Unsequenced)) + require.Len(t, warnings, 2) + assert.Contains(t, warnings[0], `group "database" depends-on non-existent group "missing"`) + assert.Contains(t, warnings[1], `group "app" depends-on non-existent group "database"`) + + dag, err := BuildResourceGroupDAG(result) + require.NoError(t, err) + batches, err := dag.GetBatches() + require.NoError(t, err) + assert.Empty(t, batches) + } +} + +func TestParseResourceGroups_InvalidDependsOnJSON(t *testing.T) { + t.Parallel() + + result, warnings := parseResourceGroups(t, + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `not-valid-json`, + }), + ) + + assert.Empty(t, result.Groups) + require.Len(t, result.Unsequenced, 1) + assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced)) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "invalid JSON") +} + +func TestParseResourceGroups_MixedSequencedAndUnsequenced(t *testing.T) { + t.Parallel() + + result, warnings := parseResourceGroups(t, + makeManifest("database", "chart/templates/database.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("plain", "chart/templates/plain.yaml", nil), + ) + + require.Len(t, result.Groups, 1) + assert.Contains(t, result.Groups, "database") + require.Len(t, result.Unsequenced, 1) + assert.Equal(t, []string{"chart/templates/plain.yaml"}, manifestPaths(result.Unsequenced)) + assert.Empty(t, warnings) +} + +func TestResourceGroupDAG_CycleDetection(t *testing.T) { + t.Parallel() + + result, _ := parseResourceGroups(t, + makeManifest("a", "chart/templates/a.yaml", map[string]string{ + AnnotationResourceGroup: "a", + AnnotationDependsOnResourceGroups: `["c"]`, + }), + makeManifest("b", "chart/templates/b.yaml", map[string]string{ + AnnotationResourceGroup: "b", + AnnotationDependsOnResourceGroups: `["a"]`, + }), + makeManifest("c", "chart/templates/c.yaml", map[string]string{ + AnnotationResourceGroup: "c", + AnnotationDependsOnResourceGroups: `["b"]`, + }), + ) + + dag, err := BuildResourceGroupDAG(result) + require.NoError(t, err) + + batches, err := dag.GetBatches() + require.Error(t, err) + assert.Nil(t, batches) + assert.ErrorContains(t, err, "cycle") +} + +func TestParseResourceGroups_DeduplicatesDependencies(t *testing.T) { + t.Parallel() + + result, warnings, batches := parseResourceGroupBatches(t, + makeManifest("database", "chart/templates/database.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("app-config", "chart/templates/app-config.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["database"]`, + }), + makeManifest("app-secret", "chart/templates/app-secret.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["database"]`, + }), + ) + + assert.Equal(t, []string{"database"}, result.GroupDeps["app"]) + assert.Equal(t, [][]string{{"database"}, {"app"}}, batches) + assert.Empty(t, warnings) +} + +func TestParseResourceGroups_IsolatedGroupsRemainInBatch0(t *testing.T) { + t.Parallel() + + _, warnings, batches := parseResourceGroupBatches(t, + makeManifest("database", "chart/templates/database.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("metrics", "chart/templates/metrics.yaml", map[string]string{ + AnnotationResourceGroup: "metrics", + }), + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["database"]`, + }), + ) + + assert.Equal(t, [][]string{{"database", "metrics"}, {"app"}}, batches) + assert.Empty(t, warnings) +} + +func TestParseResourceGroups_ComplexDAG(t *testing.T) { + t.Parallel() + + _, warnings, batches := parseResourceGroupBatches(t, + makeManifest("cache", "chart/templates/cache.yaml", map[string]string{ + AnnotationResourceGroup: "cache", + }), + makeManifest("database", "chart/templates/database.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("queue", "chart/templates/queue.yaml", map[string]string{ + AnnotationResourceGroup: "queue", + }), + makeManifest("api", "chart/templates/api.yaml", map[string]string{ + AnnotationResourceGroup: "api", + AnnotationDependsOnResourceGroups: `["database","queue"]`, + }), + makeManifest("worker", "chart/templates/worker.yaml", map[string]string{ + AnnotationResourceGroup: "worker", + AnnotationDependsOnResourceGroups: `["cache","queue"]`, + }), + makeManifest("frontend", "chart/templates/frontend.yaml", map[string]string{ + AnnotationResourceGroup: "frontend", + AnnotationDependsOnResourceGroups: `["api"]`, + }), + ) + + assert.Equal(t, [][]string{ + {"cache", "database", "queue"}, + {"api", "worker"}, + {"frontend"}, + }, batches) + assert.Empty(t, warnings) +} + +func parseResourceGroups(t *testing.T, manifests ...Manifest) (ResourceGroupResult, []string) { + t.Helper() + + result, warnings, err := ParseResourceGroups(manifests) + require.NoError(t, err) + + return result, warnings +} + +func parseResourceGroupBatches(t *testing.T, manifests ...Manifest) (ResourceGroupResult, []string, [][]string) { + t.Helper() + + result, warnings := parseResourceGroups(t, manifests...) + dag, err := BuildResourceGroupDAG(result) + require.NoError(t, err) + + batches, err := dag.GetBatches() + require.NoError(t, err) + + return result, warnings, batches +} + +func makeManifest(name, sourcePath string, annotations map[string]string) Manifest { + var content strings.Builder + fmt.Fprintf(&content, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: %s\n", name) + if len(annotations) > 0 { + content.WriteString(" annotations:\n") + for key, value := range annotations { + fmt.Fprintf(&content, " %s: %q\n", key, value) + } + } + + head := &SimpleHead{ + Version: "v1", + Kind: "ConfigMap", + Metadata: &struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Annotations map[string]string `json:"annotations"` + }{ + Name: name, + Annotations: annotations, + }, + } + + return Manifest{ + Name: sourcePath, + Content: content.String(), + Head: head, + } +} + +func manifestPaths(manifests []Manifest) []string { + paths := make([]string, 0, len(manifests)) + for _, manifest := range manifests { + paths = append(paths, manifest.Name) + } + + return paths +}