diff --git a/pkg/action/install.go b/pkg/action/install.go index 2ae1b9b58..efd4ba8c4 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -563,6 +563,11 @@ func (i *Install) performSequencedInstall(ctx context.Context, chrt *chart.Chart } } + rel.SequencingInfo = &release.SequencingInfo{ + Enabled: true, + Strategy: string(i.WaitStrategy), + } + if len(i.Description) > 0 { rel.SetStatus(rcommon.StatusDeployed, i.Description) } else { diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 459569781..dd88944d2 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" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" ) @@ -92,7 +94,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(context.Background(), currentRelease, targetRelease, serverSideApply); err != nil { return err } @@ -184,22 +186,28 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele // message here, and only override it later if we experience failure. Description: fmt.Sprintf("Rollback to %d", previousVersion), }, - Version: currentRelease.Version + 1, - Labels: previousRelease.Labels, - Manifest: previousRelease.Manifest, - Hooks: previousRelease.Hooks, - ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)), + Version: currentRelease.Version + 1, + Labels: previousRelease.Labels, + Manifest: previousRelease.Manifest, + Hooks: previousRelease.Hooks, + ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)), + SequencingInfo: previousRelease.SequencingInfo, } 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 } + // Use sequenced rollback when the target revision was originally deployed with sequencing. + if targetRelease.SequencingInfo != nil && targetRelease.SequencingInfo.Enabled { + 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) @@ -304,3 +312,109 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas return targetRelease, nil } + +// 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) { + // Build the full set of current (deployed) resources for diff matching. + 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) + } + + // Build the full set of target resources for computing deletions. + target, err := r.cfg.KubeClient.Build(bytes.NewBufferString(targetRelease.Manifest), false) + if err != nil { + return targetRelease, fmt.Errorf("unable to build kubernetes objects from target release manifest: %w", err) + } + + // Parse the target manifest into sorted manifests for DAG ordering. + rawManifests := releaseutil.SplitManifests(targetRelease.Manifest) + _, sortedManifests, err := releaseutil.SortManifests(rawManifests, nil, releaseutil.InstallOrder) + if err != nil { + return targetRelease, fmt.Errorf("parsing target release manifest for sequenced rollback: %w", err) + } + + // pre-rollback hooks + if !r.DisableHooks { + if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.WaitStrategy, r.WaitOptions, r.Timeout, serverSideApply); err != nil { + return targetRelease, err + } + } else { + r.cfg.Logger().Debug("rollback hooks disabled", "name", targetRelease.Name) + } + + readinessTimeout := r.Timeout / 2 // sensible default: half the overall timeout + if readinessTimeout <= 0 { + readinessTimeout = time.Minute + } + + sd := &sequencedDeployment{ + cfg: r.cfg, + releaseName: targetRelease.Name, + releaseNamespace: targetRelease.Namespace, + serverSideApply: serverSideApply, + forceConflicts: r.ForceConflicts, + forceReplace: r.ForceReplace, + waitStrategy: r.WaitStrategy, + waitOptions: r.WaitOptions, + waitForJobs: r.WaitForJobs, + timeout: r.Timeout, + readinessTimeout: readinessTimeout, + deadline: time.Now().Add(r.Timeout), + upgradeMode: true, + currentResources: current, + // upgradeCSAFieldManager: always true for rollback (same as existing performRollback) + upgradeCSAFieldManager: true, + } + + chrt := targetRelease.Chart + + if err := sd.deployChartLevel(ctx, chrt, sortedManifests); err != nil { + targetRelease.SetStatus(common.StatusFailed, fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err)) + r.cfg.recordRelease(currentRelease) + r.cfg.recordRelease(targetRelease) + return targetRelease, err + } + + // Delete resources that were removed in the rollback target (in current but not in target). + allTargetKeys := make(map[string]bool, len(target)) + for _, res := range target { + allTargetKeys[objectKey(res)] = true + } + var toBeDeleted kube.ResourceList + for _, res := range current { + if !allTargetKeys[objectKey(res)] { + toBeDeleted = append(toBeDeleted, res) + } + } + if len(toBeDeleted) > 0 { + if _, errs := r.cfg.KubeClient.Delete(toBeDeleted, metav1.DeletePropagationBackground); errs != nil { + return targetRelease, fmt.Errorf("deleting removed resources during rollback: %w", joinErrors(errs, ", ")) + } + } + + // post-rollback hooks + if !r.DisableHooks { + if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.WaitStrategy, r.WaitOptions, r.Timeout, serverSideApply); err != nil { + return targetRelease, err + } + } + + deployed, err := r.cfg.Releases.DeployedAll(currentRelease.Name) + if err != nil && !errors.Is(err, driver.ErrNoDeployedReleases) { + return nil, err + } + for _, reli := range deployed { + rel, err := releaserToV1Release(reli) + if err != nil { + return nil, 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_test.go b/pkg/action/rollback_test.go index deb6c7c80..bc4ade435 100644 --- a/pkg/action/rollback_test.go +++ b/pkg/action/rollback_test.go @@ -21,12 +21,14 @@ import ( "errors" "io" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" + release "helm.sh/helm/v4/pkg/release/v1" ) func TestNewRollback(t *testing.T) { @@ -83,3 +85,44 @@ func TestRollback_WaitOptionsPassedDownstream(t *testing.T) { // Verify that WaitOptions were passed to GetWaiter is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") } + +// TestRollback_SequencingInfoPropagated verifies that a previous release's SequencingInfo +// is carried forward into the new rollback release record. +func TestRollback_SequencingInfoPropagated(t *testing.T) { + config := actionConfigFixture(t) + + // rel1 was originally deployed with --wait=ordered + rel1 := releaseStub() + rel1.Name = "seq-rollback-test" + rel1.Version = 1 + rel1.Info.Status = "deployed" + rel1.ApplyMethod = "csa" + rel1.SequencingInfo = &release.SequencingInfo{Enabled: true, Strategy: "ordered"} + require.NoError(t, config.Releases.Create(rel1)) + + // rel2 is the current (last) deployed version + rel2 := releaseStub() + rel2.Name = "seq-rollback-test" + rel2.Version = 2 + rel2.Info.Status = "deployed" + rel2.ApplyMethod = "csa" + require.NoError(t, config.Releases.Create(rel2)) + + client := NewRollback(config) + client.Version = 1 + client.WaitStrategy = kube.OrderedWaitStrategy + client.ServerSideApply = "auto" + client.Timeout = 5 * time.Minute + + err := client.Run(rel1.Name) + require.NoError(t, err) + + // The new rollback release (version 3) should have SequencingInfo from rel1 + newReli, err := config.Releases.Last(rel1.Name) + require.NoError(t, err) + newRel, err := releaserToV1Release(newReli) + require.NoError(t, err) + assert.Equal(t, 3, newRel.Version) + require.NotNil(t, newRel.SequencingInfo, "rollback release should carry SequencingInfo from previous version") + assert.True(t, newRel.SequencingInfo.Enabled) +} diff --git a/pkg/action/sequencing_test.go b/pkg/action/sequencing_test.go index e3e8b7af7..6ae335c11 100644 --- a/pkg/action/sequencing_test.go +++ b/pkg/action/sequencing_test.go @@ -148,6 +148,34 @@ func containsStr(s, substr string) bool { return false } +// TestInstallRelease_StoresSequencingInfo verifies that a sequenced install stores +// SequencingInfo in the release record. +func TestInstallRelease_StoresSequencingInfo(t *testing.T) { + config := actionConfigFixture(t) + instAction := NewInstall(config) + instAction.Namespace = "spaced" + instAction.ReleaseName = "seq-info-test" + instAction.WaitStrategy = kube.OrderedWaitStrategy + instAction.Timeout = 5 * time.Minute + instAction.ReadinessTimeout = time.Minute + + ch := buildChart(withSampleTemplates()) + reli, err := instAction.RunWithContext(context.Background(), ch, map[string]interface{}{}) + if err != nil { + t.Fatalf("install failed: %v", err) + } + rel, err := releaserToV1Release(reli) + if err != nil { + t.Fatalf("type assertion failed: %v", err) + } + if rel.SequencingInfo == nil { + t.Fatal("expected SequencingInfo to be set after ordered install, got nil") + } + if !rel.SequencingInfo.Enabled { + t.Error("expected SequencingInfo.Enabled to be true") + } +} + // TestInstallRelease_OrderedWaitStrategy verifies that --wait=ordered installs // succeed end-to-end using the fake kube client. func TestInstallRelease_OrderedWaitStrategy(t *testing.T) { diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 79156991c..c8d679035 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -273,6 +273,12 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name) } + // Use sequenced (reverse DAG order) deletion when the release was deployed with sequencing. + if rel.SequencingInfo != nil && rel.SequencingInfo.Enabled { + deleted, batchErrs := u.sequencedDeleteManifests(filesToDelete) + return deleted, kept.String(), batchErrs + } + var builder strings.Builder for _, file := range filesToDelete { builder.WriteString("\n---\n" + file.Content) @@ -288,6 +294,72 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri return resources, kept.String(), errs } +// sequencedDeleteManifests deletes manifests in reverse resource-group DAG order: +// dependents are deleted before the resources they depend on. +// Unsequenced resources (no resource-group annotation) are deleted first (they were installed last). +func (u *Uninstall) sequencedDeleteManifests(manifests []releaseutil.Manifest) (kube.ResourceList, []error) { + var allDeleted kube.ResourceList + var allErrs []error + cascade := parseCascadingFlag(u.DeletionPropagation) + + deleteManifestBatch := func(batch []releaseutil.Manifest) { + if len(batch) == 0 { + return + } + var builder strings.Builder + for _, m := range batch { + builder.WriteString("\n---\n" + m.Content) + } + resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("unable to build kubernetes objects for delete: %w", err)) + return + } + if len(resources) == 0 { + return + } + allDeleted = append(allDeleted, resources...) + if _, errs := u.cfg.KubeClient.Delete(resources, cascade); errs != nil { + allErrs = append(allErrs, errs...) + } + } + + result, warnings := releaseutil.ParseResourceGroups(manifests) + for _, w := range warnings { + slog.Warn("resource-group annotation warning during uninstall", "warning", w) + } + + // Delete unsequenced resources first (they were deployed last in install order). + deleteManifestBatch(result.Unsequenced) + + // For sequenced groups: get install-order batches and reverse them. + if len(result.Groups) > 0 { + dag, err := releaseutil.BuildResourceGroupDAG(result) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("building resource-group DAG for sequenced uninstall: %w", err)) + return allDeleted, allErrs + } + batches, err := dag.GetBatches() + if err != nil { + allErrs = append(allErrs, fmt.Errorf("getting resource-group batches for sequenced uninstall: %w", err)) + return allDeleted, allErrs + } + // Reverse the batches: delete dependents before their dependencies. + for i, j := 0, len(batches)-1; i < j; i, j = i+1, j-1 { + batches[i], batches[j] = batches[j], batches[i] + } + for _, groupBatch := range batches { + var batchManifests []releaseutil.Manifest + for _, groupName := range groupBatch { + batchManifests = append(batchManifests, result.Groups[groupName]...) + } + deleteManifestBatch(batchManifests) + } + } + + return allDeleted, allErrs +} + func parseCascadingFlag(cascadingFlag string) v1.DeletionPropagation { switch cascadingFlag { case "orphan": diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index b5a76d983..67a98ecb6 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -29,6 +29,7 @@ import ( "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" ) func uninstallAction(t *testing.T) *Uninstall { @@ -207,3 +208,18 @@ func TestUninstall_WaitOptionsPassedDownstream(t *testing.T) { // Verify that WaitOptions were passed to GetWaiter is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") } + +// TestUninstallRelease_SequencedDeleteSucceeds verifies that a sequenced release +// (SequencingInfo.Enabled) can be uninstalled without error. +func TestUninstallRelease_SequencedDeleteSucceeds(t *testing.T) { + unAction := uninstallAction(t) + unAction.DisableHooks = true + + rel := releaseStub() + rel.Name = "seq-uninstall-test" + rel.SequencingInfo = &release.SequencingInfo{Enabled: true, Strategy: "ordered"} + require.NoError(t, unAction.cfg.Releases.Create(rel)) + + _, err := unAction.Run(rel.Name) + require.NoError(t, err) +} diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index a9c3ffa34..209acc103 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -645,6 +645,10 @@ func (u *Upgrade) performSequencedUpgrade(ctx context.Context, chrt *chartv2.Cha currentRelease.Info.Status = rcommon.StatusSuperseded u.cfg.recordRelease(currentRelease) + upgradedRelease.SequencingInfo = &release.SequencingInfo{ + Enabled: true, + Strategy: string(u.WaitStrategy), + } upgradedRelease.Info.Status = rcommon.StatusDeployed if len(u.Description) > 0 { upgradedRelease.Info.Description = u.Description diff --git a/pkg/release/v1/release.go b/pkg/release/v1/release.go index 454ee6eb7..eeec5e861 100644 --- a/pkg/release/v1/release.go +++ b/pkg/release/v1/release.go @@ -25,6 +25,16 @@ type ApplyMethod string const ApplyMethodClientSideApply ApplyMethod = "csa" const ApplyMethodServerSideApply ApplyMethod = "ssa" +// SequencingInfo records whether and how this release was deployed with +// resource sequencing (--wait=ordered). It is consulted by rollback and +// uninstall to replicate or reverse the original deployment order. +type SequencingInfo struct { + // Enabled is true when --wait=ordered was used for this release. + Enabled bool `json:"enabled,omitempty"` + // Strategy is the WaitStrategy value used (e.g., "ordered"). + 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 +61,9 @@ 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" + // SequencingInfo stores metadata about sequenced deployment for use by rollback/uninstall. + // Nil means the release was not deployed with sequencing (backward-compatible default). + SequencingInfo *SequencingInfo `json:"sequencing_info,omitempty"` } // SetStatus is a helper for setting the status on a release.