feat: implement HIP-0025 resource creation sequencing

Implements HIP-0025 to give chart authors first-class control over
deployment ordering of chart resources and subcharts. Helm operators
opt in via --wait=ordered (or WaitStrategy=ordered in the SDK); default
behavior for Chart v2 is unchanged.

== Foundations ==

- DAG abstraction (pkg/chart/v2/util/dag.go) with topological batch
  output and cycle detection.
- Resource-group annotation parsing and dependency tracking
  (pkg/release/v1/util/resource_group.go) for helm.sh/resource-group
  and helm.sh/depends-on/resource-groups; resource IDs are
  apiVersion/Kind/Namespace/Name to disambiguate cross-namespace.
- Subchart DAG (pkg/chart/v2/util/subchart_dag.go) reading depends-on
  on Chart.yaml dependencies and the helm.sh/depends-on/subcharts
  annotation. BuildSubchartDAG inspects c.Dependencies() (post
  ProcessDependencies) so it correctly respects conditions, tags, and
  aliases - addresses joejulian's review feedback on metadata
  heuristics.
- DependsOn []string field on chart.Dependency (pkg/chart/v2/dependency.go).
- SequencingInfo metadata stored on the release object
  (pkg/release/v1/release.go) so rollback knows whether a revision
  was sequenced.
- Custom readiness via helm.sh/readiness-success and helm.sh/readiness-failure
  JSONPath expressions (pkg/kube/readiness.go); falls back to kstatus
  if either is missing. Failure conditions take precedence over success.

== Action integration ==

- pkg/action/sequencing.go: sequencedDeployment with per-batch deadline
  via min(), context.Done() honored at build/create/wait phases, and
  isolated/partial-readiness warnings emitted once per batch (not per
  poll tick).
- Install, upgrade, rollback, and uninstall actions consume
  WaitStrategy=ordered. Sequenced uninstall and rollback are gated on
  the release's stored SequencingInfo to enforce the HIP "reverse
  install order" semantic.
- ReadinessTimeout (default 1m) is capped by --timeout and applied
  per batch.
- Manifest path recovery for nested subcharts on rollback/uninstall.

== CLI ==

- --wait=ordered on install, upgrade, rollback, AND uninstall. The
  AddOrderedWaitFlag helper in pkg/cmd/flags.go is shared across all
  four commands.
- --readiness-timeout flag with docstring clarifying that "ready" is
  determined by kstatus signals or custom readiness annotations, and
  that vanilla Jobs need --wait-for-jobs.
- helm template emits "## START resource-group: <chart> <name>" /
  "## END resource-group: ..." delimiters when --wait=ordered. Falls
  back to flat manifest output with a warning if YAML parsing fails.

== Lint ==

- pkg/chart/v2/lint/rules/sequencing.go: ErrorSev for circular subchart
  deps, partial readiness annotations, and orphan
  helm.sh/depends-on/resource-groups references. Empty annotation
  values are treated as absent (matches runtime behavior).

== Tests + fixtures ==

- Unit tests for DAG, subchart DAG, lint rules, readiness JSONPath,
  resource-group parsing, sequencing action, ordered template output,
  and CLI flag wiring. Includes context-cancellation coverage for
  sequencedDeployment per joejulian's request.
- Integration testchart at pkg/cmd/testdata/testcharts/sequenced-chart/
  exercising parent->subchart and resource-group ordering.

== Backward compatibility ==

Sequencing is gated on WaitStrategy == OrderedWaitStrategy. Charts
without HIP-0025 annotations or --wait=ordered behave exactly as
before. The depends-on field on Chart.yaml dependencies is silently
accepted and unknown to upstream-stable lint (forward-compat fix
to be tracked separately once HIP is accepted).

Refs: HIP-0025
Addresses: joejulian and Copilot review feedback on PR #32038
Signed-off-by: Rohit Gudi <50377477+caretak3r@users.noreply.github.com>
Rohit Gudi 2 months ago
parent bdf2747c5f
commit cb339cd59c
No known key found for this signature in database
GPG Key ID: 4D9E5BA7BBE1EB29

@ -276,17 +276,24 @@ func splitAndDeannotate(postrendered, fallbackPrefix string) (map[string]string,
//
// This code has to do with writing files to disk.
func (cfg *Configuration) renderResources(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(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(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)
}
}
@ -299,7 +306,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values,
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
@ -315,7 +322,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values,
}
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,
@ -356,7 +363,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values,
}
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.
@ -407,17 +414,17 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values,
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 {
@ -439,22 +446,22 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values,
// 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)
}
}
@ -474,7 +481,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values,
}
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.
@ -487,7 +494,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values,
} 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
}
@ -512,13 +519,13 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values,
// 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

@ -0,0 +1,125 @@
/*
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])
require.Len(t, client.waitCalls, 3)
assert.Equal(t, client.createCalls[:2], client.waitCalls[:2])
assert.ElementsMatch(t, client.createCalls[2], client.waitCalls[2])
assert.Empty(t, buf.String())
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
}

@ -100,6 +100,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 +314,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 +382,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(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(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(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()
@ -469,7 +482,11 @@ 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, chrt, rel, sortedManifests)
} else {
rel, err = i.performInstallCtx(ctx, rel, toBeAdopted, resources)
}
if err != nil {
rel, err = i.failRelease(rel, err)
}
@ -498,6 +515,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, chrt *chart.Chart, rel *release.Release, manifests []releaseutil.Manifest) (*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, chrt, rel, manifests)
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, chrt *chart.Chart, rel *release.Release, manifests []releaseutil.Manifest) (*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)
}
}
readinessTimeout := i.ReadinessTimeout
if readinessTimeout <= 0 {
readinessTimeout = time.Minute
}
rel.SequencingInfo = &release.SequencingInfo{
Enabled: true,
Strategy: string(i.WaitStrategy),
}
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),
}
if err := sd.deployChartLevel(ctx, chrt, manifests); 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 +612,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.

@ -18,8 +18,10 @@ package action
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -28,6 +30,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"
)
@ -37,12 +40,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.
@ -76,6 +80,10 @@ func (r *Rollback) Run(name string) error {
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 +100,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 +192,27 @@ 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
}
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 +317,142 @@ 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: %v: %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))
}
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))
}
rawManifests := releaseutil.SplitManifests(targetRelease.Manifest)
_, sortedManifests, err := releaseutil.SortManifests(rawManifests, nil, releaseutil.InstallOrder)
if err != nil {
return fail(nil, fmt.Errorf("parsing target release manifest for sequenced rollback: %w", err))
}
sortedManifests = r.recoverManifestPathsForSequencedRollback(targetRelease, sortedManifests)
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
}
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: computeDeadline(r.Timeout),
upgradeMode: true,
currentResources: current,
upgradeCSAFieldManager: true,
}
if err := sd.deployChartLevel(ctx, targetRelease.Chart, sortedManifests); err != nil {
return fail(sd.createdResources, err)
}
allTargetKeys := make(map[string]bool, len(target))
for _, resource := range target {
allTargetKeys[objectKey(resource)] = true
}
var toBeDeleted kube.ResourceList
for _, resource := range current {
if !allTargetKeys[objectKey(resource)] {
toBeDeleted = append(toBeDeleted, resource)
}
}
if len(toBeDeleted) > 0 {
if _, errs := r.cfg.KubeClient.Delete(toBeDeleted, metav1.DeletePropagationBackground); errs != nil {
return fail(sd.createdResources, fmt.Errorf("deleting removed resources during rollback: %w", joinErrors(errs, ", ")))
}
}
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
}
func (r *Rollback) recoverManifestPathsForSequencedRollback(rel *release.Release, manifests []releaseutil.Manifest) []releaseutil.Manifest {
rendered, err := renderReleaseManifestsWithPaths(r.cfg, rel)
if err != nil {
r.cfg.Logger().Warn("unable to recover rendered chart paths for sequenced rollback; falling back to stored manifest order", slog.Any("error", err))
return manifests
}
return applyRenderedManifestPaths(manifests, rendered)
}

@ -0,0 +1,392 @@
/*
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)
require.NotNil(t, rel.SequencingInfo)
assert.Equal(t, *sequencingInfo, *rel.SequencingInfo)
}
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.Nil(t, latestRollbackRelease(t, rollback, "rollback-to-standard").SequencingInfo)
}
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.Nil(t, latestRollbackRelease(t, rollback, "rollback-transition").SequencingInfo)
}
func TestRollback_SequencingInfoPreserved(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")
require.NotNil(t, rel.SequencingInfo)
assert.Equal(t, *sequencingInfo, *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)
}

@ -0,0 +1,511 @@
/*
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"
"slices"
"strings"
"time"
chartv2 "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/kube"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"k8s.io/apimachinery/pkg/api/meta"
"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)
}
// GroupManifestsByDirectSubchart groups manifests by the direct subchart they belong to.
// The current chart's own manifests are returned under the empty string key "".
// Subcharts are keyed by their immediate name under the first `<chartName>/charts/<subchart>/`
// segment found in the manifest source path.
// Nested subcharts (e.g., `<chartName>/charts/sub/charts/nested/`) are grouped under
// the direct subchart name ("sub"), since nested sequencing is handled recursively.
func GroupManifestsByDirectSubchart(manifests []releaseutil.Manifest, chartName string) map[string][]releaseutil.Manifest {
result := make(map[string][]releaseutil.Manifest)
if chartName == "" {
// Fallback: assign everything to parent
result[""] = append(result[""], manifests...)
return result
}
chartsPrefix := chartName + "/charts/"
for _, m := range manifests {
if !strings.HasPrefix(m.Name, chartsPrefix) {
// Parent chart manifest
result[""] = append(result[""], m)
continue
}
// Extract the direct subchart name (first segment after "<chartName>/charts/")
rest := m.Name[len(chartsPrefix):]
// rest is like "subchart1/templates/deploy.yaml" or "subchart1/charts/nested/..."
subchartName, _, ok := strings.Cut(rest, "/")
if !ok {
// Unlikely: a file directly under charts/ with no subdirectory
result[""] = append(result[""], m)
continue
}
result[subchartName] = append(result[subchartName], m)
}
return result
}
// 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 performs ordered installation or upgrade of chart resources.
// It handles the two-level DAG: first subchart ordering, then resource-group
// ordering within each chart level.
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
// Upgrade-specific fields. When upgradeMode is true, createAndWait delegates
// to updateAndWait which calls KubeClient.Update() instead of Create().
upgradeMode bool
currentResources kube.ResourceList // full set of old (current) resources
upgradeCSAFieldManager bool // upgrade client-side apply field manager
createdResources kube.ResourceList
}
// deployChartLevel deploys all resources for a single chart level in sequenced order.
// It first handles subcharts in dependency order (recursively), then deploys the
// parent chart's own resource-group batches.
func (s *sequencedDeployment) deployChartLevel(ctx context.Context, chrt *chartv2.Chart, manifests []releaseutil.Manifest) error {
// Group manifests by direct subchart
grouped := GroupManifestsByDirectSubchart(manifests, chrt.Name())
// Build subchart DAG and deploy in topological order
dag, err := chartutil.BuildSubchartDAG(chrt)
if err != nil {
return fmt.Errorf("building subchart DAG for %s: %w", chrt.Name(), err)
}
batches, err := dag.GetBatches()
if err != nil {
return fmt.Errorf("getting subchart batches for %s: %w", chrt.Name(), err)
}
// Deploy each subchart batch in order
for batchIdx, batch := range batches {
for _, subchartName := range batch {
subManifests := grouped[subchartName]
if len(subManifests) == 0 {
continue
}
// Find the subchart chart object for recursive nested sequencing
subChart := findSubchart(chrt, subchartName)
if subChart == nil {
// Subchart not found in chart object (may have been disabled or aliased differently)
// Fall back to flat resource-group deployment for these manifests
s.cfg.Logger().Warn("subchart not found in chart dependencies; deploying without subchart sequencing",
"subchart", subchartName,
"batch", batchIdx,
)
if err := s.deployResourceGroupBatches(ctx, subManifests); err != nil {
return fmt.Errorf("deploying subchart %s resources: %w", subchartName, err)
}
continue
}
// Recursively deploy the subchart (handles its own nested subcharts and resource-groups)
if err := s.deployChartLevel(ctx, subChart, subManifests); err != nil {
return fmt.Errorf("deploying subchart %s: %w", subchartName, err)
}
}
}
// Deploy parent chart's own resources (after all subchart batches complete)
parentManifests := grouped[""]
if len(parentManifests) > 0 {
if err := s.deployResourceGroupBatches(ctx, parentManifests); err != nil {
return fmt.Errorf("deploying %s own resources: %w", chrt.Name(), err)
}
}
return nil
}
// warnIfPartialReadinessAnnotations logs a warning for each resource that has
// only one of the helm.sh/readiness-success / helm.sh/readiness-failure annotations.
// Both annotations must be present for custom readiness evaluation to work; a resource
// with only one will fall back to kstatus.
func warnIfPartialReadinessAnnotations(logger *slog.Logger, manifests []releaseutil.Manifest) {
for _, m := range manifests {
if m.Head == nil || m.Head.Metadata == nil {
continue
}
ann := m.Head.Metadata.Annotations
_, hasSuccess := ann[kube.AnnotationReadinessSuccess]
_, hasFailure := ann[kube.AnnotationReadinessFailure]
if hasSuccess != hasFailure {
logger.Warn("resource has only one readiness annotation; both helm.sh/readiness-success and helm.sh/readiness-failure must be present for custom readiness evaluation; falling back to kstatus",
"resource", m.Head.Metadata.Name,
)
}
}
}
// warnIfIsolatedGroups logs a warning for each resource-group that has no
// connections to other groups (no depends-on edges and not depended on by any
// other group). Isolated groups are valid but may indicate a misconfiguration
// when multiple groups are present.
func warnIfIsolatedGroups(logger *slog.Logger, result releaseutil.ResourceGroupResult) {
if len(result.Groups) <= 1 {
return // A single group is not isolated in a meaningful sense
}
for groupName := range result.Groups {
deps := result.GroupDeps[groupName]
isDependedOn := false
for _, otherDeps := range result.GroupDeps {
if slices.Contains(otherDeps, groupName) {
isDependedOn = true
break
}
}
if len(deps) == 0 && !isDependedOn {
logger.Warn("resource-group is isolated with no connections to other groups; if sequencing is intended, add helm.sh/depends-on/resource-groups annotation to related groups",
"group", groupName,
)
}
}
}
func batchHasCustomReadiness(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[kube.AnnotationReadinessSuccess] != "" || annotations[kube.AnnotationReadinessFailure] != "" {
return true
}
}
return false
}
// deployResourceGroupBatches deploys manifests for a single chart level using
// resource-group annotation DAG ordering. Resources without group annotations
// (or with invalid ones) are deployed last.
func (s *sequencedDeployment) deployResourceGroupBatches(ctx context.Context, manifests []releaseutil.Manifest) error {
if len(manifests) == 0 {
return nil
}
// Emit warnings for misconfigured annotations before deploying.
warnIfPartialReadinessAnnotations(s.cfg.Logger(), manifests)
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
if err != nil {
return fmt.Errorf("parsing resource-group annotations: %w", err)
}
for _, w := range warnings {
s.cfg.Logger().Warn("resource-group annotation warning", "warning", w)
}
// If there are sequenced groups, build their DAG and deploy in order
if len(result.Groups) > 0 {
// Warn about groups that have no connections to other groups.
warnIfIsolatedGroups(s.cfg.Logger(), result)
dag, err := releaseutil.BuildResourceGroupDAG(result)
if err != nil {
return fmt.Errorf("building resource-group DAG: %w", err)
}
batches, err := dag.GetBatches()
if err != nil {
return fmt.Errorf("getting resource-group batches: %w", err)
}
for _, groupBatch := range batches {
var batchManifests []releaseutil.Manifest
for _, groupName := range groupBatch {
batchManifests = append(batchManifests, result.Groups[groupName]...)
}
if err := s.createAndWait(ctx, batchManifests); err != nil {
return err
}
}
}
// Deploy unsequenced resources last
if len(result.Unsequenced) > 0 {
if err := s.createAndWait(ctx, result.Unsequenced); err != nil {
return err
}
}
return nil
}
// helmSequencingAnnotations lists annotation keys used internally by Helm for
// resource sequencing. These are stripped from resources before applying to
// Kubernetes because some (e.g. helm.sh/depends-on/resource-groups) contain
// multiple slashes which is invalid per the K8s annotation key format.
var helmSequencingAnnotations = []string{
releaseutil.AnnotationDependsOnResourceGroups,
}
// 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 {
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 helmSequencingAnnotations {
if _, exists := annotations[key]; exists {
delete(annotations, key)
changed = true
}
}
if changed {
acc.SetAnnotations(annotations)
}
return nil
})
}
// createAndWait creates (or updates, in upgrade mode) a set of manifest resources
// and waits for them to be ready. It respects both the per-batch readiness timeout
// and the overall operation deadline.
func (s *sequencedDeployment) createAndWait(ctx context.Context, manifests []releaseutil.Manifest) error {
if s.upgradeMode {
return s.updateAndWait(ctx, manifests)
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if len(manifests) == 0 {
return nil
}
yaml := buildManifestYAML(manifests)
resources, err := s.cfg.KubeClient.Build(bytes.NewBufferString(yaml), !s.disableOpenAPI)
if err != nil {
return fmt.Errorf("building resource batch: %w", err)
}
if len(resources) == 0 {
return nil
}
if err := resources.Visit(setMetadataVisitor(s.releaseName, s.releaseNamespace, true)); err != nil {
return fmt.Errorf("setting metadata for resource batch: %w", err)
}
if err := stripSequencingAnnotations(resources); err != nil {
return fmt.Errorf("stripping sequencing annotations: %w", err)
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
result, err := s.cfg.KubeClient.Create(resources, kube.ClientCreateOptionServerSideApply(s.serverSideApply, false))
if err != nil {
return fmt.Errorf("creating resource batch: %w", err)
}
s.createdResources = append(s.createdResources, result.Created...)
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return s.waitForResources(resources, manifests)
}
// updateAndWait applies an upgrade batch using KubeClient.Update() and waits for readiness.
// It matches current (old) resources by objectKey to compute the per-batch diff.
func (s *sequencedDeployment) updateAndWait(_ context.Context, manifests []releaseutil.Manifest) error {
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)
}
// Find the subset of current (old) resources that are represented in this batch.
// Update() will handle creates (target resources not in matchingCurrent) and
// updates (resources in both). Deletions are handled separately after all batches.
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)
}
}
result, err := s.cfg.KubeClient.Update(
matchingCurrent,
target,
kube.ClientUpdateOptionForceReplace(s.forceReplace),
kube.ClientUpdateOptionServerSideApply(s.serverSideApply, s.forceConflicts),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(s.upgradeCSAFieldManager),
)
if err != nil {
return fmt.Errorf("updating resource batch: %w", err)
}
s.createdResources = append(s.createdResources, result.Created...)
return s.waitForResources(target, manifests)
}
// waitForResources waits for the given resources to become ready,
// applying the per-batch and overall deadline constraints.
func (s *sequencedDeployment) waitForResources(resources kube.ResourceList, manifests []releaseutil.Manifest) error {
// Determine effective wait timeout: min(readinessTimeout, remaining time to overall deadline)
waitTimeout := s.readinessTimeout
if !s.deadline.IsZero() {
remaining := time.Until(s.deadline)
if remaining <= 0 {
return 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
}
var err error
var waiter kube.Waiter
waitOptions := append([]kube.WaitOption(nil), s.waitOptions...)
if batchHasCustomReadiness(manifests) {
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)
}
// findSubchart finds the subchart chart object within chrt's direct dependencies by name or alias.
// 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 *chartv2.Chart, nameOrAlias string) *chartv2.Chart {
// Build a map from subchart chart name → alias from the parent's dependency declarations.
aliasMap := make(map[string]string) // chart name → effective name (alias or name)
if chrt.Metadata != nil {
for _, dep := range chrt.Metadata.Dependencies {
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
}

@ -0,0 +1,724 @@
/*
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"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"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/schema"
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/kubernetes/scheme"
restfake "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"
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
operations []string
waitCallCount int
waitErrorOnCall int
waitError error
onBuild func()
onCreate func()
}
type updateCall struct {
current []string
target []string
created []string
}
type recordingKubeWaiter struct {
client *recordingKubeClient
}
func newRecordingKubeClient() *recordingKubeClient {
return &recordingKubeClient{
PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: io.Discard},
}
}
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)
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) {
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,
})
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,
},
Client: newNotFoundRESTClient(u.GetName(), gvk),
}
resources.Append(info)
}
return resources, nil
}
func (c *recordingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) {
return c.GetWaiterWithOptions(ws)
}
func (c *recordingKubeClient) GetWaiterWithOptions(_ kube.WaitStrategy, _ ...kube.WaitOption) (kube.Waiter, error) {
return &recordingKubeWaiter{client: c}, nil
}
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 newNotFoundRESTClient(name string, gvk schema.GroupVersionKind) *restfake.RESTClient {
body, _ := json.Marshal(metav1.Status{
Status: metav1.StatusFailure,
Reason: metav1.StatusReasonNotFound,
Code: http.StatusNotFound,
Details: &metav1.StatusDetails{
Name: name,
Group: gvk.Group,
Kind: gvk.Kind,
},
})
return &restfake.RESTClient{
GroupVersion: gvk.GroupVersion(),
NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
Client: restfake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNotFound,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(body)),
}, 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_GroupManifestsByDirectSubchart(t *testing.T) {
manifests := []releaseutil.Manifest{
makeTestManifest("parent", "parent/templates/one.yaml", nil),
makeTestManifest("db", "parent/charts/database/templates/one.yaml", nil),
makeTestManifest("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)
}
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.NotNil(t, rel.SequencingInfo)
assert.True(t, rel.SequencingInfo.Enabled)
}
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)
}
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()
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)
}
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)
}
func TestInstall_Sequenced_SequencingInfoStored(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))
require.NotNil(t, rel.SequencingInfo)
assert.True(t, rel.SequencingInfo.Enabled)
assert.Equal(t, string(kube.OrderedWaitStrategy), rel.SequencingInfo.Strategy)
stored, err := install.cfg.Releases.Get(rel.Name, rel.Version)
require.NoError(t, err)
assert.Equal(t, string(kube.OrderedWaitStrategy), mustRelease(t, stored).SequencingInfo.Strategy)
}
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 TestSequencing_WarnIfPartialReadinessAnnotations(t *testing.T) {
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) })
warnIfPartialReadinessAnnotations(slog.Default(), []releaseutil.Manifest{
makeTestManifest("cm", "chart/templates/cm.yaml", map[string]string{
kube.AnnotationReadinessSuccess: `["{.ready} == true"]`,
}),
})
assert.Contains(t, buf.String(), "readiness")
}
func TestSequencedDeployment_CreateAndWait_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()
sd := newSequencedDeploymentForTest(client)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := sd.createAndWait(ctx, []releaseutil.Manifest{
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 build before create", func(t *testing.T) {
client := newRecordingKubeClient()
ctx, cancel := context.WithCancel(context.Background())
client.onBuild = cancel
sd := newSequencedDeploymentForTest(client)
err := sd.createAndWait(ctx, []releaseutil.Manifest{
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.createAndWait(ctx, []releaseutil.Manifest{
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 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
}

@ -25,10 +25,13 @@ import (
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
chartcommon "helm.sh/helm/v4/pkg/chart/common"
renderutil "helm.sh/helm/v4/pkg/chart/common/util"
chart "helm.sh/helm/v4/pkg/chart/v2"
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"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"helm.sh/helm/v4/pkg/storage/driver"
@ -116,7 +119,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)
@ -127,7 +130,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error)
}
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}
@ -147,7 +150,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, waiter)
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)
@ -158,8 +161,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 !isSequencedRelease(rel) {
if err := waiter.WaitForDelete(deletedResources, u.Timeout); err != nil {
errs = append(errs, err)
}
}
if !u.DisableHooks {
@ -169,7 +174,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 {
@ -208,7 +213,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))
}
@ -220,6 +225,10 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error)
return res, nil
}
func isSequencedRelease(rel *release.Release) bool {
return rel.SequencingInfo != nil && rel.SequencingInfo.Enabled
}
func (u *Uninstall) purgeReleases(rels ...*release.Release) error {
for _, rel := range rels {
if _, err := u.cfg.Releases.Delete(rel.Name, rel.Version); err != nil {
@ -253,10 +262,8 @@ 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) {
var errs []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) {
manifests := releaseutil.SplitManifests(rel.Manifest)
_, files, err := releaseutil.SortManifests(manifests, nil, releaseutil.UninstallOrder)
if err != nil {
@ -273,19 +280,273 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri
fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name)
}
if isSequencedRelease(rel) {
filesToDelete = u.recoverManifestPathsForSequencedUninstall(rel, filesToDelete)
deleted, errs := u.sequencedDeleteManifests(rel.Chart, filesToDelete, waiter)
return deleted, kept.String(), errs
}
resources, err := u.buildDeleteResources(filesToDelete)
if err != nil {
return nil, "", []error{err}
}
if len(resources) > 0 {
_, errs := u.cfg.KubeClient.Delete(resources, parseCascadingFlag(u.DeletionPropagation))
return resources, kept.String(), errs
}
return resources, kept.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)
}
if len(resources) > 0 {
_, errs = u.cfg.KubeClient.Delete(resources, parseCascadingFlag(u.DeletionPropagation))
return resources, nil
}
func (u *Uninstall) deleteManifestBatch(manifests []releaseutil.Manifest, waiter kube.Waiter, deadline time.Time) (kube.ResourceList, []error) {
resources, err := u.buildDeleteResources(manifests)
if err != nil || len(resources) == 0 {
if err != nil {
return nil, []error{err}
}
return nil, nil
}
_, errs := u.cfg.KubeClient.Delete(resources, parseCascadingFlag(u.DeletionPropagation))
if len(errs) > 0 {
return resources, errs
}
timeout := u.Timeout
if !deadline.IsZero() {
if remaining := time.Until(deadline); remaining < timeout {
timeout = remaining
}
}
if err := waiter.WaitForDelete(resources, timeout); err != nil {
errs = append(errs, err)
}
return resources, errs
}
func (u *Uninstall) sequencedDeleteManifests(chrt *chart.Chart, manifests []releaseutil.Manifest, waiter kube.Waiter) (kube.ResourceList, []error) {
deadline := computeDeadline(u.Timeout)
if chrt == nil {
return u.deleteResourceGroupBatchesReverse(manifests, waiter, deadline)
}
return u.deleteChartLevelReverse(chrt, manifests, waiter, deadline)
}
func (u *Uninstall) deleteChartLevelReverse(chrt *chart.Chart, manifests []releaseutil.Manifest, waiter kube.Waiter, deadline time.Time) (kube.ResourceList, []error) {
grouped := GroupManifestsByDirectSubchart(manifests, chrt.Name())
allDeleted, errs := u.deleteResourceGroupBatchesReverse(grouped[""], waiter, deadline)
if len(errs) > 0 {
return allDeleted, errs
}
dag, err := chartutil.BuildSubchartDAG(chrt)
if err != nil {
return allDeleted, []error{fmt.Errorf("building subchart DAG for sequenced uninstall: %w", err)}
}
batches, err := dag.GetBatches()
if err != nil {
return allDeleted, []error{fmt.Errorf("getting subchart batches for sequenced uninstall: %w", err)}
}
for i, j := 0, len(batches)-1; i < j; i, j = i+1, j-1 {
batches[i], batches[j] = batches[j], batches[i]
}
for _, batch := range batches {
for _, subchartName := range batch {
subchartManifests := grouped[subchartName]
if len(subchartManifests) == 0 {
continue
}
subchart := findSubchart(chrt, subchartName)
var deleted kube.ResourceList
if subchart == nil {
u.cfg.Logger().Warn("subchart not found in chart dependencies during sequenced uninstall; falling back to flat resource-group deletion", "subchart", subchartName)
deleted, errs = u.deleteResourceGroupBatchesReverse(subchartManifests, waiter, deadline)
} else {
deleted, errs = u.deleteChartLevelReverse(subchart, subchartManifests, waiter, deadline)
}
allDeleted = append(allDeleted, deleted...)
if len(errs) > 0 {
return allDeleted, errs
}
}
}
return allDeleted, nil
}
func (u *Uninstall) deleteResourceGroupBatchesReverse(manifests []releaseutil.Manifest, waiter kube.Waiter, deadline time.Time) (kube.ResourceList, []error) {
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
if err != nil {
return nil, []error{fmt.Errorf("parsing resource-group annotations for sequenced uninstall: %w", err)}
}
for _, warning := range warnings {
u.cfg.Logger().Warn("resource-group annotation warning during uninstall", "warning", warning)
}
var allDeleted kube.ResourceList
if len(result.Unsequenced) > 0 {
deleted, errs := u.deleteManifestBatch(result.Unsequenced, waiter, deadline)
allDeleted = append(allDeleted, deleted...)
if len(errs) > 0 {
return allDeleted, errs
}
}
return resources, kept.String(), errs
if len(result.Groups) == 0 {
return allDeleted, nil
}
dag, err := releaseutil.BuildResourceGroupDAG(result)
if err != nil {
return allDeleted, []error{fmt.Errorf("building resource-group DAG for sequenced uninstall: %w", err)}
}
batches, err := dag.GetBatches()
if err != nil {
return allDeleted, []error{fmt.Errorf("getting resource-group batches for sequenced uninstall: %w", err)}
}
for i, j := 0, len(batches)-1; i < j; i, j = i+1, j-1 {
batches[i], batches[j] = batches[j], batches[i]
}
for _, batch := range batches {
var batchManifests []releaseutil.Manifest
for _, groupName := range batch {
batchManifests = append(batchManifests, result.Groups[groupName]...)
}
deleted, errs := u.deleteManifestBatch(batchManifests, waiter, deadline)
allDeleted = append(allDeleted, deleted...)
if len(errs) > 0 {
return allDeleted, errs
}
}
return allDeleted, nil
}
func (u *Uninstall) recoverManifestPathsForSequencedUninstall(rel *release.Release, manifests []releaseutil.Manifest) []releaseutil.Manifest {
rendered, err := renderReleaseManifestsWithPaths(u.cfg, rel)
if err != nil {
u.cfg.Logger().Warn("unable to recover rendered chart paths for sequenced uninstall; falling back to stored manifest order", slog.Any("error", err))
return manifests
}
return applyRenderedManifestPaths(manifests, rendered)
}
// renderReleaseManifestsWithPaths re-renders the release chart+config to produce
// manifests keyed by source template paths (e.g., "mychart/templates/deploy.yaml").
func renderReleaseManifestsWithPaths(cfg *Configuration, rel *release.Release) ([]releaseutil.Manifest, error) {
if rel.Chart == nil {
return nil, errors.New("release chart is missing")
}
if err := chartutil.ProcessDependencies(rel.Chart, rel.Config); err != nil {
return nil, fmt.Errorf("processing chart dependencies: %w", err)
}
caps, err := cfg.getCapabilities()
if err != nil {
return nil, fmt.Errorf("getting capabilities: %w", err)
}
valuesToRender, err := renderutil.ToRenderValuesWithSchemaValidation(
rel.Chart,
rel.Config,
chartcommon.ReleaseOptions{
Name: rel.Name,
Namespace: rel.Namespace,
Revision: rel.Version,
IsInstall: rel.Version <= 1,
IsUpgrade: rel.Version > 1,
},
caps,
true,
)
if err != nil {
return nil, fmt.Errorf("building render values: %w", err)
}
_, _, _, manifests, err := cfg.renderResourcesWithFiles(rel.Chart, valuesToRender, rel.Name, "", false, false, false, nil, false, false, false, PostRenderStrategyCombined)
if err != nil {
return nil, fmt.Errorf("rendering chart manifests: %w", err)
}
return manifests, nil
}
func applyRenderedManifestPaths(stored, rendered []releaseutil.Manifest) []releaseutil.Manifest {
byContent := make(map[string][]string, len(rendered))
byIdentity := make(map[string][]string, len(rendered))
for _, manifest := range rendered {
contentKey := normalizedManifestContent(manifest.Content)
byContent[contentKey] = append(byContent[contentKey], manifest.Name)
identityKey := manifestIdentity(manifest)
byIdentity[identityKey] = append(byIdentity[identityKey], manifest.Name)
}
out := make([]releaseutil.Manifest, len(stored))
copy(out, stored)
for i, manifest := range out {
contentKey := normalizedManifestContent(manifest.Content)
if names := byContent[contentKey]; len(names) > 0 {
out[i].Name = names[0]
byContent[contentKey] = names[1:]
continue
}
identityKey := manifestIdentity(manifest)
if names := byIdentity[identityKey]; len(names) > 0 {
out[i].Name = names[0]
byIdentity[identityKey] = names[1:]
}
}
return out
}
func manifestIdentity(manifest releaseutil.Manifest) string {
if manifest.Head == nil || manifest.Head.Metadata == nil {
return normalizedManifestContent(manifest.Content)
}
return fmt.Sprintf("%s/%s/%s/%s", manifest.Head.Version, manifest.Head.Kind, manifest.Head.Metadata.Namespace, manifest.Head.Metadata.Name)
}
func normalizedManifestContent(content string) string {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return ""
}
lines := strings.Split(trimmed, "\n")
start := 0
for start < len(lines) {
line := strings.TrimSpace(lines[start])
if line == "" || strings.HasPrefix(line, "#") {
start++
continue
}
break
}
return strings.TrimSpace(strings.Join(lines[start:], "\n"))
}
func parseCascadingFlag(cascadingFlag string) v1.DeletionPropagation {

@ -0,0 +1,413 @@
/*
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 (
"fmt"
"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 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,
}
}
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()
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()
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()
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(
configMapManifest("nginx", nil),
configMapManifest("bar", nil),
configMapManifest("parent", nil),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
// Nested subcharts within a subchart's subtree are flattened into one delete
// batch because GroupManifestsByDirectSubchart receives full-path manifest
// names ("parent/charts/bar/charts/nginx/...") that don't match the nested
// chart prefix ("bar/charts/"). This is a known limitation.
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/nginx", "ConfigMap/bar"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
func TestUninstall_Sequenced_KeepPolicy(t *testing.T) {
client := newRecordingKubeClient()
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()
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()
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()
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)
}

@ -70,6 +70,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 +192,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(name, chrt, vals)
currentRelease, upgradedRelease, serverSideApply, sortedManifests, err := u.prepareUpgrade(name, chrt, vals)
if err != nil {
return nil, err
}
@ -201,7 +209,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 +231,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(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(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 +248,19 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str
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 +273,14 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str
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
}
}
@ -274,11 +289,11 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str
// 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
@ -294,25 +309,33 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str
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(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(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(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))
@ -340,7 +363,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str
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) {
@ -467,6 +490,12 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
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(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,
@ -525,6 +554,123 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
u.reportToPerformUpgrade(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)
}
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
}
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(upgradedRelease, nil, fmt.Errorf("pre-upgrade hooks failed: %s", 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,
}
// Set SequencingInfo before deployment so that failure recovery can use sequenced order.
upgradedRelease.SequencingInfo = &release.SequencingInfo{
Enabled: true,
Strategy: string(u.WaitStrategy),
}
if err := sd.deployChartLevel(ctx, chrt, manifests); err != nil {
return u.failRelease(upgradedRelease, sd.createdResources, err)
}
// Delete resources that were removed in the new release (in old but not in new).
allNewKeys := make(map[string]bool, len(target))
for _, r := range target {
allNewKeys[objectKey(r)] = true
}
var toBeDeleted kube.ResourceList
for _, r := range current {
if !allNewKeys[objectKey(r)] {
toBeDeleted = append(toBeDeleted, r)
}
}
if len(toBeDeleted) > 0 {
if _, errs := u.cfg.KubeClient.Delete(toBeDeleted, metav1.DeletePropagationBackground); errs != nil {
return u.failRelease(upgradedRelease, sd.createdResources, fmt.Errorf("deleting removed resources: %w", joinErrors(errs, ", ")))
}
}
// 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(upgradedRelease, sd.createdResources, fmt.Errorf("post-upgrade hooks failed: %s", 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) failRelease(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(

@ -0,0 +1,459 @@
/*
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)
require.NotNil(t, rel.SequencingInfo)
assert.True(t, rel.SequencingInfo.Enabled)
assert.Equal(t, string(kube.OrderedWaitStrategy), rel.SequencingInfo.Strategy)
}
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",
}, client.operations)
}
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))
require.NotNil(t, rel.SequencingInfo)
assert.True(t, rel.SequencingInfo.Enabled)
}
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
}

@ -0,0 +1,113 @@
/*
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, "only one readiness annotation")
assert.Contains(t, output, "resource=app")
}
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 is isolated")
assert.Contains(t, output, "group=database")
assert.Contains(t, output, "group=cache")
}
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, "resource-group annotation warning")
assert.Contains(t, output, "group")
assert.Contains(t, output, "app")
assert.Contains(t, output, "non-existent group")
assert.Contains(t, output, "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)
}

@ -103,8 +103,10 @@ func ValidateAgainstSchema(ch chart.Charter, values map[string]any) error {
subchartValues, ok := raw.(map[string]any)
if !ok {
fmt.Fprintf(&sb, "%s:\ninvalid type for values: expected object (map), got %T\n",
sub.Name(), raw)
fmt.Fprintf(&sb,
"%s:\ninvalid type for values: expected object (map), got %T\n",
sub.Name(), raw,
)
continue
}

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

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

@ -66,6 +66,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
}

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

@ -0,0 +1,210 @@
/*
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 (
"fmt"
"path"
"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"
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 {
// Chart load errors are already reported by other lint rules.
return
}
// ProcessDependencies must run before validateSubchartSequencing:
// it prunes disabled subcharts from c.Dependencies() and applies
// alias renames, which BuildSubchartDAG relies on.
if err := chartutil.ProcessDependencies(c, values); err != nil {
return
}
linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateSubchartSequencing(c))
validateRenderedSequencingAnnotations(linter, c, namespace, values)
}
func validateSubchartSequencing(c *chart.Chart) error {
if c.Metadata == nil || len(c.Metadata.Dependencies) == 0 {
return nil
}
dag, err := chartutil.BuildSubchartDAG(c)
if err != nil {
return err
}
if _, err := dag.GetBatches(); err != nil {
return fmt.Errorf("subchart circular dependency detected: %w", err)
}
return nil
}
func validateRenderedSequencingAnnotations(linter *support.Linter, c *chart.Chart, namespace string, values map[string]any) {
if err := chartutil.ProcessDependencies(c, values); err != nil {
return
}
options := common.ReleaseOptions{
Name: "test-release",
Namespace: namespace,
}
caps := common.DefaultCapabilities.Copy()
coalescedValues, err := commonutil.CoalesceValues(c, values)
if err != nil {
return
}
valuesToRender, err := commonutil.ToRenderValues(c, coalescedValues, options, caps)
if err != nil {
return
}
var renderEngine engine.Engine
renderEngine.LintMode = true
renderedContentMap, err := renderEngine.Render(c, valuesToRender)
if err != nil {
// Template rendering errors are already reported by the Templates lint rule.
return
}
manifestsByChart := make(map[string][]releaseutil.Manifest)
for templatePath, content := range renderedContentMap {
if strings.TrimSpace(content) == "" {
continue
}
chartPath := renderedTemplateChartPath(templatePath)
for _, manifest := range parseRenderedManifests(content) {
validateReadinessAnnotations(linter, templatePath, manifest)
manifestsByChart[chartPath] = append(manifestsByChart[chartPath], manifest)
}
}
for chartPath, manifests := range manifestsByChart {
validateResourceGroupAnnotations(linter, chartPath, manifests)
}
}
func renderedTemplateChartPath(templatePath string) string {
if chartPath, _, ok := strings.Cut(templatePath, "/templates/"); ok {
return chartPath
}
return path.Dir(templatePath)
}
func parseRenderedManifests(content string) []releaseutil.Manifest {
rawManifests := releaseutil.SplitManifests(content)
manifests := make([]releaseutil.Manifest, 0, len(rawManifests))
for manifestName, raw := range rawManifests {
if strings.TrimSpace(raw) == "" {
continue
}
var head releaseutil.SimpleHead
if err := yaml.Unmarshal([]byte(raw), &head); err != nil {
continue
}
if head.Metadata != nil && head.Metadata.Name != "" {
manifestName = head.Metadata.Name
}
manifests = append(manifests, releaseutil.Manifest{
Name: manifestName,
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
hasSuccess := strings.TrimSpace(annotations[kube.AnnotationReadinessSuccess]) != ""
hasFailure := strings.TrimSpace(annotations[kube.AnnotationReadinessFailure]) != ""
if hasSuccess == hasFailure {
return
}
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,
))
}
func validateResourceGroupAnnotations(linter *support.Linter, chartPath string, manifests []releaseutil.Manifest) {
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
// HIP-0025: lint must fail on orphan resource-group dependencies and
// malformed annotation JSON. Runtime falls back to the unsequenced batch
// for graceful recovery, but the chart author should fix these at lint time.
for _, warning := range warnings {
linter.RunLinterRule(support.ErrorSev, chartPath, fmt.Errorf("%s", warning))
}
if err != nil {
linter.RunLinterRule(support.ErrorSev, chartPath, err)
return
}
dag, err := releaseutil.BuildResourceGroupDAG(result)
if err != nil {
linter.RunLinterRule(support.ErrorSev, chartPath, err)
return
}
if _, err := dag.GetBatches(); err != nil {
linter.RunLinterRule(support.ErrorSev, chartPath, fmt.Errorf("resource-group circular dependency detected: %w", 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)
}

@ -0,0 +1,238 @@
/*
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_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_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",
},
}
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_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())
}

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

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

@ -0,0 +1,129 @@
/*
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 so that
// c.Dependencies() reflects the post-processed state (disabled subcharts
// pruned, aliases applied).
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
}
byName := make(map[string]bool, len(c.Metadata.Dependencies))
for _, dep := range c.Metadata.Dependencies {
if dep == nil {
continue
}
name := effectiveDependencyName(dep)
if loaded[name] {
byName[name] = true
dag.AddNode(name)
}
}
for _, dep := range c.Metadata.Dependencies {
if dep == nil {
continue
}
name := effectiveDependencyName(dep)
if !byName[name] {
continue
}
for _, prerequisite := range dep.DependsOn {
if err := addSubchartEdge(dag, byName, name, prerequisite); err != nil {
return nil, err
}
}
}
if err := validateParentSubchartDependencies(c.Metadata.Annotations[AnnotationDependsOnSubcharts], byName); err != nil {
return nil, err
}
return dag, nil
}
func validateParentSubchartDependencies(annotation string, byName 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 !byName[prerequisite] {
return fmt.Errorf("annotation %s references unknown or disabled subchart %q", AnnotationDependsOnSubcharts, prerequisite)
}
}
return nil
}
func addSubchartEdge(dag *DAG, byName map[string]bool, subchartName, prerequisite string) error {
if !byName[prerequisite] {
return fmt.Errorf("subchart %q depends-on unknown or disabled subchart %q", subchartName, prerequisite)
}
if err := dag.AddEdge(prerequisite, subchartName); err != nil {
return fmt.Errorf("adding sequencing edge %s→%s: %w", prerequisite, subchartName, err)
}
return nil
}
func effectiveDependencyName(dep *chart.Dependency) string {
if dep.Alias != "" {
return dep.Alias
}
return dep.Name
}

@ -0,0 +1,332 @@
/*
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()
c := newChart("parent",
aliasedDependency("postgres", "primary-db"),
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_MetadataOnlyNoLoadedDeps locks in the post-rewrite
// contract: when c.Metadata.Dependencies is non-empty but c.Dependencies()
// is empty (e.g., chart loaded but ProcessDependencies disabled everything),
// the DAG should have no nodes and produce no error.
func TestBuildSubchartDAG_MetadataOnlyNoLoadedDeps(t *testing.T) {
t.Parallel()
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "parent",
Dependencies: []*chart.Dependency{
{Name: "ghost", Enabled: true},
},
},
}
// Note: no AddDependency call — c.Dependencies() is empty.
batches := batchesForChart(t, c)
assert.Empty(t, batches, "no loaded 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,
}
}
func aliasedDependency(name, alias string, dependsOn ...string) *chart.Dependency {
return &chart.Dependency{
Name: name,
Alias: alias,
Enabled: true,
DependsOn: dependsOn,
}
}

@ -25,6 +25,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@ -65,42 +66,95 @@ 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) {
f.DurationVar(readinessTimeout, "readiness-timeout", time.Minute, "per-batch timeout when --wait=ordered is used; each resource batch must become ready within this duration (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")

@ -18,6 +18,7 @@ package cmd
import (
"fmt"
"io"
"testing"
"time"
@ -99,6 +100,94 @@ 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 with one minute default", func(t *testing.T) {
cmd := newInstallCmd(&action.Configuration{}, io.Discard)
flag := cmd.Flags().Lookup("readiness-timeout")
require.NotNil(t, flag)
require.Equal(t, "1m0s", flag.DefValue)
require.NoError(t, cmd.ParseFlags([]string{"--readiness-timeout=30s"}))
require.Equal(t, "30s", flag.Value.String())
})
t.Run("upgrade registers readiness-timeout with one minute default", func(t *testing.T) {
cmd := newUpgradeCmd(&action.Configuration{}, io.Discard)
flag := cmd.Flags().Lookup("readiness-timeout")
require.NotNil(t, flag)
require.Equal(t, "1m0s", 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 with one minute default", func(t *testing.T) {
cmd := newRollbackCmd(&action.Configuration{}, io.Discard)
flag := cmd.Flags().Lookup("readiness-timeout")
require.NotNil(t, flag)
require.Equal(t, "1m0s", 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("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)
})
}
func TestPostRendererFlagSetOnce(t *testing.T) {
cfg := action.Configuration{}
client := action.NewInstall(&cfg)

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

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

@ -22,7 +22,9 @@ import (
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"regexp"
"slices"
@ -32,11 +34,15 @@ import (
release "helm.sh/helm/v4/pkg/release/v1"
"github.com/spf13/cobra"
"sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/cli/values"
"helm.sh/helm/v4/pkg/cmd/require"
"helm.sh/helm/v4/pkg/kube"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
@ -105,6 +111,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 {
@ -117,84 +124,97 @@ 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)
if orderedTemplateOutput {
if err := renderOrderedTemplate(rel.Chart, strings.TrimSpace(rel.Manifest), out); err != nil {
return err
}
if !client.DisableHooks {
for _, m := range rel.Hooks {
if skipTests && isTestHook(m) {
continue
}
_, err := os.Stat(filepath.Join(newDir, m.Path))
if err == nil {
fileWritten[m.Path] = true
fmt.Fprintf(out, "---\n# Source: %s\n%s\n", m.Path, m.Manifest)
}
}
} else {
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
}
err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path])
if err != nil {
return err
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
}
}
}
}
}
// 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
// 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
}
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 missing {
return fmt.Errorf("could not find template %s in chart", f)
}
manifestsToRender = append(manifestsToRender, manifest)
missing = false
}
if missing {
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())
}
}
@ -225,6 +245,233 @@ 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
}
sortedManifests, err := parseTemplateManifests(manifest)
if err != nil {
slog.Warn("Unable to render ordered template output; falling back to flat manifest output", "chart", chrt.Name(), "error", err)
if _, writeErr := io.WriteString(out, manifest); writeErr != nil {
return fmt.Errorf("writing fallback manifest output: %w", writeErr)
}
return nil
}
return renderOrderedChartLevel(chrt, sortedManifests, chrt.Name(), out)
}
func parseTemplateManifests(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 ""
}
func renderOrderedChartLevel(chrt *chart.Chart, manifests []releaseutil.Manifest, chartPath string, out io.Writer) error {
if len(manifests) == 0 {
return nil
}
grouped := groupManifestsByChartPath(manifests, chartPath)
renderedSubcharts := make(map[string]struct{})
dag, err := chartutil.BuildSubchartDAG(chrt)
if err != nil {
return fmt.Errorf("building subchart DAG for %s: %w", chartPath, err)
}
batches, err := dag.GetBatches()
if err != nil {
return fmt.Errorf("getting subchart batches for %s: %w", chartPath, err)
}
for _, batch := range batches {
for _, subchartName := range batch {
if err := renderOrderedSubchart(chrt, chartPath, subchartName, grouped[subchartName], out); err != nil {
return err
}
renderedSubcharts[subchartName] = struct{}{}
}
}
var remainingSubcharts []string
for subchartName := range grouped {
if subchartName == "" {
continue
}
if _, ok := renderedSubcharts[subchartName]; ok {
continue
}
remainingSubcharts = append(remainingSubcharts, subchartName)
}
sort.Strings(remainingSubcharts)
for _, subchartName := range remainingSubcharts {
if err := renderOrderedSubchart(chrt, chartPath, subchartName, grouped[subchartName], out); err != nil {
return err
}
}
return renderOrderedResourceGroups(grouped[""], chartPath, out)
}
func renderOrderedSubchart(chrt *chart.Chart, chartPath, subchartName string, manifests []releaseutil.Manifest, out io.Writer) error {
if len(manifests) == 0 {
return nil
}
subchartPath := path.Join(chartPath, subchartName)
subChart := findTemplateSubchart(chrt, subchartName)
if subChart == nil {
slog.Warn("subchart not found in chart dependencies during template rendering; falling back to flat ordered output", "subchart", subchartName)
return renderOrderedResourceGroups(manifests, subchartPath, out)
}
return renderOrderedChartLevel(subChart, manifests, subchartPath, out)
}
func renderOrderedResourceGroups(manifests []releaseutil.Manifest, chartPath string, out io.Writer) error {
if len(manifests) == 0 {
return nil
}
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
if err != nil {
return fmt.Errorf("parsing resource groups for %s: %w", chartPath, err)
}
for _, warning := range warnings {
slog.Warn("resource-group annotation warning during template rendering", "chartPath", chartPath, "warning", warning)
}
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)
}
batches, err := dag.GetBatches()
if err != nil {
return fmt.Errorf("getting resource-group batches for %s: %w", chartPath, err)
}
for _, batch := range batches {
for _, groupName := range batch {
fmt.Fprintf(out, "## START resource-group: %s %s\n", chartPath, groupName)
for _, manifest := range result.Groups[groupName] {
fmt.Fprintf(out, "---\n%s\n", manifest.Content)
}
fmt.Fprintf(out, "## END resource-group: %s %s\n", chartPath, groupName)
}
}
}
for _, manifest := range result.Unsequenced {
fmt.Fprintf(out, "---\n%s\n", manifest.Content)
}
return nil
}
func groupManifestsByChartPath(manifests []releaseutil.Manifest, chartPath string) map[string][]releaseutil.Manifest {
result := make(map[string][]releaseutil.Manifest)
chartsPrefix := chartManifestPrefix(chartPath) + "/charts/"
for _, manifest := range manifests {
if !strings.HasPrefix(manifest.Name, chartsPrefix) {
result[""] = append(result[""], manifest)
continue
}
rest := manifest.Name[len(chartsPrefix):]
subchartName, _, ok := strings.Cut(rest, "/")
if !ok {
result[""] = append(result[""], manifest)
continue
}
result[subchartName] = append(result[subchartName], manifest)
}
return result
}
func chartManifestPrefix(chartPath string) string {
parts := strings.Split(chartPath, "/")
if len(parts) == 0 {
return chartPath
}
prefix := parts[0]
for _, part := range parts[1:] {
prefix = path.Join(prefix, "charts", part)
}
return prefix
}
func findTemplateSubchart(chrt *chart.Chart, nameOrAlias string) *chart.Chart {
if chrt == nil || chrt.Metadata == nil {
return nil
}
aliases := make(map[string]string, len(chrt.Metadata.Dependencies))
for _, dep := range chrt.Metadata.Dependencies {
effectiveName := dep.Name
if dep.Alias != "" {
effectiveName = dep.Alias
}
aliases[dep.Name] = effectiveName
}
for _, dep := range chrt.Dependencies() {
effectiveName := dep.Name()
if alias, ok := aliases[dep.Name()]; ok {
effectiveName = alias
}
if effectiveName == nameOrAlias || dep.Name() == nameOrAlias {
return dep
}
}
return nil
}
func isTestHook(h *release.Hook) bool {
return slices.Contains(h.Events, release.HookTest)
}

@ -20,6 +20,8 @@ import (
"fmt"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
var chartPath = "testdata/testcharts/subchart"
@ -166,10 +168,23 @@ 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",
},
}
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")
}
func TestTemplateVersionCompletion(t *testing.T) {
repoFile := "testdata/helmhome/helm/repositories.yaml"
repoCache := "testdata/helmhome/helm/repository"

@ -0,0 +1,45 @@
## 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
helm.sh/depends-on/resource-groups: '["databases"]'
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

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

@ -0,0 +1,5 @@
apiVersion: v2
name: worker
description: A subchart used to test ordered template output
type: application
version: 0.1.0

@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: worker-bootstrap
annotations:
helm.sh/resource-group: bootstrap
data:
worker: ready

@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: database-config
annotations:
helm.sh/resource-group: databases
data:
host: localhost

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

@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: unsequenced-config
data:
key: value

@ -82,7 +82,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
}

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

@ -305,7 +305,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")

@ -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,10 @@ func (c *Client) newStatusWatcher(opts ...WaitOption) (*statusWaiter, error) {
if waitContext == nil {
waitContext = c.WaitContext
}
readers := append([]engine.StatusReader(nil), o.statusReaders...)
if o.enableCustomReadinessStatusReader {
readers = append([]engine.StatusReader{newCustomReadinessStatusReader()}, readers...)
}
sw := &statusWaiter{
restMapper: restMapper,
client: dynamicClient,
@ -174,7 +183,7 @@ func (c *Client) newStatusWatcher(opts ...WaitOption) (*statusWaiter, error) {
waitCtx: o.waitCtx,
waitWithJobsCtx: o.waitWithJobsCtx,
waitForDeleteCtx: o.waitForDeleteCtx,
readers: o.statusReaders,
readers: readers,
}
sw.SetLogger(c.Logger().Handler())
return sw, nil
@ -200,10 +209,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))
}
}

@ -0,0 +1,41 @@
/*
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))
}

@ -0,0 +1,114 @@
/*
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"
"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{}
func newCustomReadinessStatusReader() engine.StatusReader {
return &customReadinessStatusReader{}
}
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(resource, u)
}
func (r *customReadinessStatusReader) ReadStatusForObject(_ context.Context, _ engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
identifier, err := object.RuntimeToObjMeta(resource)
if err != nil {
return nil, err
}
return r.readStatus(identifier, resource)
}
func (r *customReadinessStatusReader) readStatus(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, err := EvaluateCustomReadiness(resource, successExprs, failureExprs)
if err != nil {
return nil, err
}
if useKstatus {
return computeKstatusStatus(identifier, resource)
}
switch result {
case ReadinessReady:
return &event.ResourceStatus{
Identifier: identifier,
Status: status.CurrentStatus,
Message: "custom readiness conditions met",
}, nil
case ReadinessFailed:
return &event.ResourceStatus{
Identifier: identifier,
Status: status.FailedStatus,
Message: "custom readiness failure condition met",
}, nil
default:
return &event.ResourceStatus{
Identifier: identifier,
Status: status.InProgressStatus,
Message: "waiting for custom readiness conditions",
}, nil
}
}
func computeKstatusStatus(identifier object.ObjMetadata, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
result, err := status.Compute(resource)
if err != nil {
return nil, err
}
return &event.ResourceStatus{
Identifier: identifier,
Status: result.Status,
Message: result.Message,
}, nil
}

@ -0,0 +1,142 @@
/*
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 (
"context"
"testing"
"time"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
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
}{
{
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
`,
},
}
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,
readers: []engine.StatusReader{newCustomReadinessStatusReader()},
}
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)
}
})
}
}
func TestWithCustomReadinessStatusReader(t *testing.T) {
opts := &waitOptions{}
WithCustomReadinessStatusReader()(opts)
assert.True(t, opts.enableCustomReadinessStatusReader)
}
func TestCustomReadinessStatusReaderReadStatusForObject(t *testing.T) {
reader := newCustomReadinessStatusReader()
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)
}

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

@ -0,0 +1,291 @@
/*
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"
)
const (
// AnnotationReadinessSuccess declares custom readiness success conditions.
// Its value is a JSON array of expressions in the form:
// "{.fieldPath} <operator> <value>"
// If any success condition evaluates to true, the resource is considered ready.
AnnotationReadinessSuccess = "helm.sh/readiness-success"
// AnnotationReadinessFailure declares custom readiness failure conditions.
// Its value is a JSON array of expressions in the form:
// "{.fieldPath} <operator> <value>"
// If any failure condition evaluates to true, the resource is considered failed.
// Failure conditions take precedence over success conditions.
AnnotationReadinessFailure = "helm.sh/readiness-failure"
)
// 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"
}
}
// 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.
func EvaluateCustomReadiness(obj *unstructured.Unstructured, successExprs, failureExprs []string) (ReadinessStatus, bool, 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 per batch by warnIfPartialReadinessAnnotations
// in the sequencing layer, not here (which runs on every poll tick).
return ReadinessPending, true, nil
}
statusObj, found, err := unstructured.NestedMap(obj.Object, "status")
if err != nil || !found {
return ReadinessPending, false, nil
}
statusWrapper := map[string]any{"status": statusObj}
for _, expr := range failureExprs {
met, err := evaluateExpression(statusWrapper, expr)
if err != nil {
return ReadinessPending, false, fmt.Errorf("evaluating failure expression %q: %w", expr, err)
}
if met {
return ReadinessFailed, false, nil
}
}
for _, expr := range successExprs {
met, err := evaluateExpression(statusWrapper, expr)
if err != nil {
return ReadinessPending, false, fmt.Errorf("evaluating success expression %q: %w", expr, err)
}
if met {
return ReadinessReady, false, nil
}
}
return ReadinessPending, false, 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
}
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 "{<jsonpath>} <operator> <value>" 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 {<jsonpath>}: %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("operator %q not supported for boolean values", op)
}
}
}
switch op {
case "==":
return actual == expected, nil
case "!=":
return actual != expected, nil
default:
return false, fmt.Errorf("operator %q not supported for string values", op)
}
}
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)
}
}

@ -0,0 +1,350 @@
/*
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"
"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, err := EvaluateCustomReadiness(
obj,
[]string{tt.expr},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
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, err := EvaluateCustomReadiness(
obj,
[]string{`{.phase} == "Running"`},
[]string{`{.phase} == "Failed"`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessReady, result)
result, useKstatus, err = EvaluateCustomReadiness(
obj,
[]string{`{.phase} != "Failed"`},
[]string{`{.phase} == "Failed"`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
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 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")
})
}

@ -25,6 +25,12 @@ type ApplyMethod string
const ApplyMethodClientSideApply ApplyMethod = "csa"
const ApplyMethodServerSideApply ApplyMethod = "ssa"
// SequencingInfo records whether and how this release used ordered sequencing.
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 +57,8 @@ 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 ordered deployment metadata for rollback/uninstall.
SequencingInfo *SequencingInfo `json:"sequencing_info,omitempty"`
}
// SetStatus is a helper for setting the status on a release.

@ -0,0 +1,59 @@
/*
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)
}

@ -30,6 +30,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"`
}

@ -0,0 +1,221 @@
/*
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"
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"
)
// 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)
}

@ -0,0 +1,356 @@
/*
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")
}
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
}

@ -20,6 +20,7 @@ find_files() {
find . -not \( \
\( \
-wholename './.git' \
-o -wholename './.mission' \
-o -wholename '*testdata*' \
-o -wholename '*third_party*' \
\) -prune \

Loading…
Cancel
Save