feat(spec): Task 8 — SequencingInfo in Release enables ordered rollback/uninstall

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.
pull/31992/head
caretak3r 8 months ago
parent 9b5bd5e410
commit 1a821763a7

@ -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 {

@ -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
}

@ -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)
}

@ -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) {

@ -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":

@ -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)
}

@ -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

@ -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.

Loading…
Cancel
Save