From 1a821763a7c002efd3485b249591258cbfd7f69c Mon Sep 17 00:00:00 2001 From: caretak3r <50377477+caretak3r@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:14:39 -0500 Subject: [PATCH] =?UTF-8?q?feat(spec):=20Task=208=20=E2=80=94=20Sequencing?= =?UTF-8?q?Info=20in=20Release=20enables=20ordered=20rollback/uninstall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SequencingInfo struct to Release for storing whether --wait=ordered was used. Install and upgrade with OrderedWaitStrategy now persist SequencingInfo. Rollback propagates it from the target revision and uses deployChartLevel when the target was originally sequenced. Uninstall deletes in reverse resource-group DAG order (dependents deleted before dependencies) when SequencingInfo.Enabled. --- pkg/action/install.go | 5 ++ pkg/action/rollback.go | 128 ++++++++++++++++++++++++++++++++-- pkg/action/rollback_test.go | 43 ++++++++++++ pkg/action/sequencing_test.go | 28 ++++++++ pkg/action/uninstall.go | 72 +++++++++++++++++++ pkg/action/uninstall_test.go | 16 +++++ pkg/action/upgrade.go | 4 ++ pkg/release/v1/release.go | 13 ++++ 8 files changed, 302 insertions(+), 7 deletions(-) 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.