pull/32038/merge
Rohit Gudi 24 hours ago committed by GitHub
commit 457d96913d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -277,17 +277,24 @@ func splitAndDeannotate(postrendered, fallbackPrefix string) (map[string]string,
//
// This code has to do with writing files to disk.
func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool, postRenderStrategy PostRenderStrategy) ([]*release.Hook, *bytes.Buffer, string, error) {
hs, b, notes, _, err := cfg.renderResourcesWithFiles(ctx, ch, values, releaseName, outputDir, subNotes, useReleaseName, includeCrds, pr, interactWithRemote, enableDNS, hideSecret, postRenderStrategy)
return hs, b, notes, err
}
// renderResourcesWithFiles is the canonical render implementation, also returning the
// sorted manifest slice for sequencing-aware actions.
func (cfg *Configuration) renderResourcesWithFiles(ctx context.Context, ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool, postRenderStrategy PostRenderStrategy) ([]*release.Hook, *bytes.Buffer, string, []releaseutil.Manifest, error) {
var hs []*release.Hook
b := bytes.NewBuffer(nil)
caps, err := cfg.getCapabilities()
if err != nil {
return hs, b, "", err
return hs, b, "", nil, err
}
if ch.Metadata.KubeVersion != "" {
if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) {
return hs, b, "", fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.Version)
return hs, b, "", nil, fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.Version)
}
}
@ -300,7 +307,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart,
if interactWithRemote && cfg.RESTClientGetter != nil {
restConfig, err := cfg.RESTClientGetter.ToRESTConfig()
if err != nil {
return hs, b, "", err
return hs, b, "", nil, err
}
e := engine.New(restConfig)
e.EnableDNS = enableDNS
@ -316,7 +323,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart,
}
if err2 != nil {
return hs, b, "", err2
return hs, b, "", nil, err2
}
// NOTES.txt gets rendered like all the other files, but because it's not a hook nor a resource,
@ -357,7 +364,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart,
}
fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content)
}
return hs, b, "", err
return hs, b, "", nil, err
}
// Build separate files maps for hooks and manifests.
@ -408,17 +415,17 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart,
merged, err := annotateAndMerge(group.files)
if err != nil {
return hs, b, notes, fmt.Errorf("error merging %s: %w", group.name, err)
return hs, b, notes, nil, fmt.Errorf("error merging %s: %w", group.name, err)
}
postRendered, err := pr.Run(bytes.NewBufferString(merged))
if err != nil {
return hs, b, notes, fmt.Errorf("error while running post render on %s: %w", group.name, err)
return hs, b, notes, nil, fmt.Errorf("error while running post render on %s: %w", group.name, err)
}
rendered, err := splitAndDeannotate(postRendered.String(), group.name)
if err != nil {
return hs, b, notes, fmt.Errorf("error while parsing post rendered output for %s: %w", group.name, err)
return hs, b, notes, nil, fmt.Errorf("error while parsing post rendered output for %s: %w", group.name, err)
}
for k, v := range rendered {
@ -440,22 +447,22 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart,
// Merge files as stream of documents for sending to post renderer
merged, err := annotateAndMerge(files)
if err != nil {
return hs, b, notes, fmt.Errorf("error merging manifests: %w", err)
return hs, b, notes, nil, fmt.Errorf("error merging manifests: %w", err)
}
// Run the post renderer
postRendered, err := pr.Run(bytes.NewBufferString(merged))
if err != nil {
return hs, b, notes, fmt.Errorf("error while running post render on files: %w", err)
return hs, b, notes, nil, fmt.Errorf("error while running post render on files: %w", err)
}
// Use the file list and contents received from the post renderer
files, err = splitAndDeannotate(postRendered.String(), "")
if err != nil {
return hs, b, notes, fmt.Errorf("error while parsing post rendered output: %w", err)
return hs, b, notes, nil, fmt.Errorf("error while parsing post rendered output: %w", err)
}
default:
return hs, b, notes, fmt.Errorf("unknown post-render strategy: '%s'", postRenderStrategy)
return hs, b, notes, nil, fmt.Errorf("unknown post-render strategy: '%s'", postRenderStrategy)
}
}
@ -475,7 +482,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart,
}
fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content)
}
return hs, b, "", err
return hs, b, "", nil, err
}
// Aggregate all valid manifests into one big doc.
@ -488,7 +495,7 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart,
} else {
err = writeToFile(outputDir, crd.Filename, string(crd.File.Data), fileWritten[crd.Filename])
if err != nil {
return hs, b, "", err
return hs, b, "", nil, err
}
fileWritten[crd.Filename] = true
}
@ -513,13 +520,13 @@ func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart,
// used by install or upgrade
err = writeToFile(newDir, m.Name, m.Content, fileWritten[m.Name])
if err != nil {
return hs, b, "", err
return hs, b, "", nil, err
}
fileWritten[m.Name] = true
}
}
return hs, b, notes, nil
return hs, b, notes, manifests, nil
}
// RESTClientGetter gets the rest client

@ -0,0 +1,127 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/kube"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
func TestBackwardCompat_OrderedWaitWithoutAnnotations(t *testing.T) {
client := newRecordingKubeClient()
install := newSequencedInstallAction(t, client)
buf := captureWarningOutput(t)
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/first.yaml", "first", nil),
makeConfigMapTemplate("templates/second.yaml", "second", nil),
}, withName("backward-compat-no-annotations"))
rel := mustRelease(t, mustRunInstall(t, install, ch))
require.Len(t, client.createCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/first", "ConfigMap/second"}, client.createCalls[0])
require.Len(t, client.waitCalls, 1)
assert.ElementsMatch(t, client.createCalls[0], client.waitCalls[0])
assert.Empty(t, buf.String())
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
}
func TestBackwardCompat_RollbackWithoutSequencingInfo(t *testing.T) {
client := newRecordingKubeClient()
rollback := newRollbackAction(t, client)
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("backward-compat-rollback"))
seedRollbackRelease(t, rollback, "backward-compat-rollback", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("app", nil),
), nil)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("backward-compat-rollback"))
seedRollbackRelease(t, rollback, "backward-compat-rollback", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
rollback.Version = 1
var runErr error
assert.NotPanics(t, func() {
runErr = rollback.Run("backward-compat-rollback")
})
require.NoError(t, runErr)
require.Len(t, client.updateCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target)
require.Len(t, client.waitCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0])
assert.Nil(t, latestRollbackRelease(t, rollback, "backward-compat-rollback").SequencingInfo)
}
func TestBackwardCompat_MixedAnnotationsUnsequencedLast(t *testing.T) {
client := newRecordingKubeClient()
install := newSequencedInstallAction(t, client)
buf := captureWarningOutput(t)
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeConfigMapTemplate("templates/plain-one.yaml", "plain-one", nil),
makeConfigMapTemplate("templates/plain-two.yaml", "plain-two", nil),
}, withName("backward-compat-mixed"))
rel := mustRelease(t, mustRunInstall(t, install, ch))
require.Len(t, client.createCalls, 3)
assert.Equal(t, []string{"ConfigMap/database"}, client.createCalls[0])
assert.Equal(t, []string{"ConfigMap/app"}, client.createCalls[1])
assert.ElementsMatch(t, []string{"ConfigMap/plain-one", "ConfigMap/plain-two"}, client.createCalls[2])
// Every batch, including terminal groups, is waited on.
require.Len(t, client.waitCalls, 3)
assert.Equal(t, []string{"ConfigMap/database"}, client.waitCalls[0])
assert.Equal(t, []string{"ConfigMap/app"}, client.waitCalls[1])
assert.ElementsMatch(t, client.createCalls[2], client.waitCalls[2])
assert.Empty(t, buf.String())
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
}

@ -84,6 +84,15 @@ func (cfg *Configuration) execHookWithDelayedShutdown(rl *release.Release, hook
return shutdownNoOp, fmt.Errorf("unable to build kubernetes object for %s hook %s: %w", hook, h.Path, err)
}
// HIP-0025 sequencing annotations are ignored on hooks (spec: "Annotations
// added to resources in hooks will be ignored"). Strip them before apply so
// the multi-slash keys (e.g. helm.sh/depends-on/resource-groups), which are
// not valid Kubernetes annotation keys, do not cause the API server to reject
// the hook.
if err := stripSequencingAnnotations(resources); err != nil {
return shutdownNoOp, fmt.Errorf("unable to strip sequencing annotations from %s hook %s: %w", hook, h.Path, err)
}
// Record the time at which the hook was applied to the cluster
h.LastRun = release.HookExecution{
StartedAt: time.Now(),

@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"reflect"
"strings"
"testing"
@ -29,7 +30,9 @@ import (
"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/cli-runtime/pkg/resource"
@ -445,6 +448,97 @@ func TestConfiguration_hookSetDeletePolicy(t *testing.T) {
}
}
// annotationRecordingKubeClient captures the annotations present on each
// resource at Create time, so a test can assert what actually reached the API.
type annotationRecordingKubeClient struct {
kubefake.PrintingKubeClient
createdAnnotations []map[string]string
}
func (c *annotationRecordingKubeClient) Build(reader io.Reader, _ bool) (kube.ResourceList, error) {
u := &unstructured.Unstructured{}
if err := yaml.NewYAMLOrJSONDecoder(reader, 4096).Decode(u); err != nil {
return kube.ResourceList{}, err
}
return kube.ResourceList{{
Name: u.GetName(),
Namespace: u.GetNamespace(),
Object: u,
}}, nil
}
func (c *annotationRecordingKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) {
for _, info := range resources {
acc, err := meta.Accessor(info.Object)
if err != nil {
continue
}
anns := maps.Clone(acc.GetAnnotations())
if anns == nil {
anns = map[string]string{}
}
c.createdAnnotations = append(c.createdAnnotations, anns)
}
return &kube.Result{Created: resources}, nil
}
// TestExecHook_StripsSequencingAnnotations verifies HIP-0025 sequencing
// annotations are removed from hook resources before apply (spec: annotations
// on hooks are ignored). The multi-slash keys are not valid K8s annotation
// keys, so leaving them would make the API server reject the hook.
func TestExecHook_StripsSequencingAnnotations(t *testing.T) {
is := assert.New(t)
client := &annotationRecordingKubeClient{
PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard},
}
configuration := &Configuration{
Releases: storage.Init(driver.NewMemory()),
KubeClient: client,
Capabilities: common.DefaultCapabilities,
}
rel := &release.Release{
Name: "test-release",
Namespace: "test",
Hooks: []*release.Hook{
{
Name: "test-hook",
Kind: "ConfigMap",
Path: "templates/hook.yaml",
Manifest: `apiVersion: v1
kind: ConfigMap
metadata:
name: test-hook
namespace: test
annotations:
helm.sh/resource-group: "grp"
helm.sh/depends-on/resource-groups: '["other"]'
keep-me: "yes"
data:
foo: bar
`,
Weight: 0,
Events: []release.HookEvent{release.HookPreInstall},
},
},
}
err := configuration.execHook(rel, release.HookPreInstall, kube.StatusWatcherStrategy, nil, 600, false)
is.NoError(err)
if is.Len(client.createdAnnotations, 1) {
anns := client.createdAnnotations[0]
// The multi-slash key is not a valid K8s annotation key and would be
// rejected at apply — it must be stripped from hooks (this is the bug).
is.NotContains(anns, "helm.sh/depends-on/resource-groups", "multi-slash sequencing annotation must be stripped from hooks")
// helm.sh/resource-group is a valid single-slash key; like the
// install/upgrade paths, it is left in place (harmlessly ignored on hooks).
is.Contains(anns, "helm.sh/resource-group", "valid single-slash annotation is retained, matching install/upgrade")
is.Equal("yes", anns["keep-me"], "non-sequencing annotations must survive")
}
}
func TestExecHook_WaitOptionsPassedDownstream(t *testing.T) {
is := assert.New(t)

@ -56,6 +56,7 @@ import (
ri "helm.sh/helm/v4/pkg/release"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/release/v1/sequence"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"helm.sh/helm/v4/pkg/repo/v1"
"helm.sh/helm/v4/pkg/storage"
@ -100,6 +101,10 @@ type Install struct {
Devel bool
DependencyUpdate bool
Timeout time.Duration
// ReadinessTimeout is the per-batch timeout when --wait=ordered is used.
// Each ordered batch waits at most this long for resources to become ready.
// Must not exceed Timeout. Defaults to 1 minute when zero.
ReadinessTimeout time.Duration
Namespace string
ReleaseName string
GenerateName bool
@ -310,6 +315,10 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st
return nil, fmt.Errorf("release name check failed: %w", err)
}
if i.ReadinessTimeout > 0 && i.Timeout > 0 && i.ReadinessTimeout > i.Timeout {
return nil, fmt.Errorf("--readiness-timeout (%s) must not exceed --timeout (%s)", i.ReadinessTimeout, i.Timeout)
}
if err := chartutil.ProcessDependencies(chrt, vals); err != nil {
i.cfg.Logger().Error("chart dependencies processing failed", slog.Any("error", err))
return nil, fmt.Errorf("chart dependencies processing failed: %w", err)
@ -374,8 +383,13 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st
rel := i.createRelease(chrt, vals, i.Labels)
var sortedManifests []releaseutil.Manifest
var manifestDoc *bytes.Buffer
rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(ctx, chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret, i.PostRenderStrategy)
if i.WaitStrategy == kube.OrderedWaitStrategy {
rel.Hooks, manifestDoc, rel.Info.Notes, sortedManifests, err = i.cfg.renderResourcesWithFiles(ctx, chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret, i.PostRenderStrategy)
} else {
rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(ctx, chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret, i.PostRenderStrategy)
}
// Even for errors, attach this if available
if manifestDoc != nil {
rel.Manifest = manifestDoc.String()
@ -425,6 +439,18 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st
return rel, nil
}
var plan *sequence.Plan
if i.WaitStrategy == kube.OrderedWaitStrategy {
// Build the deployment plan before any cluster or storage side effect:
// DAG errors (cycles, duplicate group membership) now fail cleanly with
// nothing stored and nothing applied.
plan, err = sequence.Build(chrt, sortedManifests)
if err != nil {
rel.SetStatus(rcommon.StatusFailed, "failed to build sequencing plan: "+err.Error())
return rel, err
}
}
if i.CreateNamespace {
ns := &v1.Namespace{
TypeMeta: metav1.TypeMeta{
@ -469,8 +495,15 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st
return rel, err
}
rel, err = i.performInstallCtx(ctx, rel, toBeAdopted, resources)
if i.WaitStrategy == kube.OrderedWaitStrategy {
rel, err = i.performSequencedInstallCtx(ctx, rel, plan, toBeAdopted)
} else {
rel, err = i.performInstallCtx(ctx, rel, toBeAdopted, resources)
}
if err != nil {
// failRelease cleans up a failed install via the context-free Uninstall.Run
// public API (upstream keeps Uninstall context-free); threading ctx through the
// whole uninstall subsystem is out of scope for this best-effort recovery path.
rel, err = i.failRelease(rel, err)
}
return rel, err
@ -498,6 +531,89 @@ func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, t
}
}
// performSequencedInstallCtx runs performSequencedInstall in a goroutine and
// respects context cancellation, mirroring performInstallCtx.
func (i *Install) performSequencedInstallCtx(ctx context.Context, rel *release.Release, plan *sequence.Plan, toBeAdopted kube.ResourceList) (*release.Release, error) {
type Msg struct {
r *release.Release
e error
}
resultChan := make(chan Msg, 1)
go func() {
i.goroutineCount.Add(1)
rel, err := i.performSequencedInstall(ctx, rel, plan, toBeAdopted)
resultChan <- Msg{rel, err}
i.goroutineCount.Add(-1)
}()
select {
case <-ctx.Done():
return rel, ctx.Err()
case msg := <-resultChan:
return msg.r, msg.e
}
}
// performSequencedInstall deploys chart resources in DAG-ordered batches when
// --wait=ordered is active.
func (i *Install) performSequencedInstall(ctx context.Context, rel *release.Release, plan *sequence.Plan, toBeAdopted kube.ResourceList) (*release.Release, error) {
if !i.DisableHooks {
if err := i.cfg.execHook(rel, release.HookPreInstall, i.WaitStrategy, i.WaitOptions, i.Timeout, i.ServerSideApply); err != nil {
return rel, fmt.Errorf("failed pre-install: %w", err)
}
}
logPlanWarnings(i.cfg.Logger(), plan)
readinessTimeout := i.ReadinessTimeout
if readinessTimeout <= 0 {
readinessTimeout = time.Minute
}
sd := &sequencedDeployment{
cfg: i.cfg,
releaseName: rel.Name,
releaseNamespace: rel.Namespace,
disableOpenAPI: i.DisableOpenAPIValidation,
serverSideApply: i.ServerSideApply,
forceConflicts: i.ForceConflicts,
forceReplace: i.ForceReplace,
waitStrategy: i.WaitStrategy,
waitOptions: i.WaitOptions,
waitForJobs: i.WaitForJobs,
timeout: i.Timeout,
readinessTimeout: readinessTimeout,
deadline: computeDeadline(i.Timeout),
currentResources: toBeAdopted,
upgradeCSAFieldManager: true,
threeWayMergeForUnstructured: i.TakeOwnership && !i.ServerSideApply,
}
if err := sd.apply(ctx, plan); err != nil {
return rel, err
}
if !i.DisableHooks {
if err := i.cfg.execHook(rel, release.HookPostInstall, i.WaitStrategy, i.WaitOptions, i.Timeout, i.ServerSideApply); err != nil {
return rel, fmt.Errorf("failed post-install: %w", err)
}
}
if len(i.Description) > 0 {
rel.SetStatus(rcommon.StatusDeployed, i.Description)
} else {
rel.SetStatus(rcommon.StatusDeployed, "Install complete")
}
if err := i.recordRelease(rel); err != nil {
i.cfg.Logger().Error("failed to record the release", slog.Any("error", err))
}
return rel, nil
}
// getGoroutineCount return the number of running routines
func (i *Install) getGoroutineCount() int32 {
return i.goroutineCount.Load()
@ -512,6 +628,10 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
}
}
if err := stripSequencingAnnotations(resources); err != nil {
return rel, fmt.Errorf("stripping sequencing annotations: %w", err)
}
// At this point, we can do the install. Note that before we were detecting whether to
// do an update, but it's not clear whether we WANT to do an update if the reuse is set
// to true, since that is basically an upgrade operation.
@ -674,6 +794,10 @@ func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]any, label
Version: 1,
Labels: labels,
ApplyMethod: string(determineReleaseSSApplyMethod(i.ServerSideApply)),
// Sequenced is set before the release record is first persisted so any
// failure-cleanup path (uninstall via RollbackOnFailure) sees the
// ordering intent even when the install dies mid-deployment (ll8/0vr).
Sequenced: i.WaitStrategy == kube.OrderedWaitStrategy,
}
return r

@ -18,6 +18,7 @@ package action
import (
"bytes"
"context"
"errors"
"fmt"
"time"
@ -28,6 +29,7 @@ import (
"helm.sh/helm/v4/pkg/kube"
"helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/release/v1/sequence"
"helm.sh/helm/v4/pkg/storage/driver"
)
@ -37,12 +39,13 @@ import (
type Rollback struct {
cfg *Configuration
Version int
Timeout time.Duration
WaitStrategy kube.WaitStrategy
WaitOptions []kube.WaitOption
WaitForJobs bool
DisableHooks bool
Version int
Timeout time.Duration
ReadinessTimeout time.Duration
WaitStrategy kube.WaitStrategy
WaitOptions []kube.WaitOption
WaitForJobs bool
DisableHooks bool
// DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster
DryRunStrategy DryRunStrategy
// ForceReplace will, if set to `true`, ignore certain warnings and perform the rollback anyway.
@ -72,10 +75,22 @@ func NewRollback(cfg *Configuration) *Rollback {
// Run executes 'helm rollback' against the given release.
func (r *Rollback) Run(name string) error {
return r.RunWithContext(context.Background(), name)
}
// RunWithContext executes 'helm rollback' against the given release, propagating
// ctx to the sequenced-wait path. It mirrors the Run/RunWithContext split used by
// Install and Upgrade so callers that already hold a context (e.g. upgrade's
// rollback-on-failure) can thread it through instead of starting a fresh one.
func (r *Rollback) RunWithContext(ctx context.Context, name string) error {
if err := r.cfg.KubeClient.IsReachable(); err != nil {
return err
}
if r.ReadinessTimeout > 0 && r.Timeout > 0 && r.ReadinessTimeout > r.Timeout {
return fmt.Errorf("--readiness-timeout (%s) must not exceed --timeout (%s)", r.ReadinessTimeout, r.Timeout)
}
r.cfg.Releases.MaxHistory = r.MaxHistory
r.cfg.Logger().Debug("preparing rollback", "name", name)
@ -92,7 +107,7 @@ func (r *Rollback) Run(name string) error {
}
r.cfg.Logger().Debug("performing rollback", "name", name)
if _, err := r.performRollback(currentRelease, targetRelease, serverSideApply); err != nil {
if _, err := r.performRollback(ctx, currentRelease, targetRelease, serverSideApply); err != nil {
return err
}
@ -190,17 +205,22 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
Manifest: previousRelease.Manifest,
Hooks: previousRelease.Hooks,
ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)),
Sequenced: previousRelease.IsSequenced(),
}
return currentRelease, targetRelease, serverSideApply, nil
}
func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) {
func (r *Rollback) performRollback(ctx context.Context, currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) {
if isDryRun(r.DryRunStrategy) {
r.cfg.Logger().Debug("dry run", "name", targetRelease.Name)
return targetRelease, nil
}
if targetRelease.IsSequenced() {
return r.performSequencedRollback(ctx, currentRelease, targetRelease, serverSideApply)
}
current, err := r.cfg.KubeClient.Build(bytes.NewBufferString(currentRelease.Manifest), false)
if err != nil {
return targetRelease, fmt.Errorf("unable to build kubernetes objects from current release manifest: %w", err)
@ -225,6 +245,19 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
if err != nil {
return targetRelease, fmt.Errorf("unable to set metadata visitor from target release: %w", err)
}
// Strip Helm-internal sequencing annotations from both current and target
// before applying to K8s. The stored Manifest in a release secret retains
// the raw annotations even though they were stripped before the original
// apply; without this strip, SSA on rollback rejects the multi-slash
// annotation key (e.g. helm.sh/depends-on/resource-groups) as invalid.
if err := stripSequencingAnnotations(current); err != nil {
return targetRelease, fmt.Errorf("stripping sequencing annotations from current release: %w", err)
}
if err := stripSequencingAnnotations(target); err != nil {
return targetRelease, fmt.Errorf("stripping sequencing annotations from target release: %w", err)
}
results, err := r.cfg.KubeClient.Update(
current,
target,
@ -305,3 +338,151 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
return targetRelease, nil
}
func (r *Rollback) failRollback(currentRelease, targetRelease *release.Release, created kube.ResourceList, err error) (*release.Release, error) {
msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err)
r.cfg.Logger().Warn(msg)
currentRelease.Info.Status = common.StatusSuperseded
targetRelease.Info.Status = common.StatusFailed
targetRelease.Info.Description = msg
r.cfg.recordRelease(currentRelease)
r.cfg.recordRelease(targetRelease)
if r.CleanupOnFail && len(created) > 0 {
r.cfg.Logger().Debug("cleanup on fail set, cleaning up resources", "count", len(created))
_, errs := r.cfg.KubeClient.Delete(created, metav1.DeletePropagationBackground)
if errs != nil {
return targetRelease, fmt.Errorf(
"an error occurred while cleaning up resources after rollback failure: %w: %w",
err,
joinErrors(errs, ", "),
)
}
r.cfg.Logger().Debug("resource cleanup complete")
}
return targetRelease, err
}
// performSequencedRollback deploys the target revision's resources in DAG-ordered batches
// when the target revision was originally deployed with --wait=ordered.
func (r *Rollback) performSequencedRollback(ctx context.Context, currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) {
fail := func(created kube.ResourceList, err error) (*release.Release, error) {
return r.failRollback(currentRelease, targetRelease, created, err)
}
current, err := r.cfg.KubeClient.Build(bytes.NewBufferString(currentRelease.Manifest), false)
if err != nil {
return fail(nil, fmt.Errorf("unable to build kubernetes objects from current release manifest: %w", err))
}
if err := stripSequencingAnnotations(current); err != nil {
return fail(nil, fmt.Errorf("stripping sequencing annotations from current release: %w", err))
}
target, err := r.cfg.KubeClient.Build(bytes.NewBufferString(targetRelease.Manifest), false)
if err != nil {
return fail(nil, fmt.Errorf("unable to build kubernetes objects from target release manifest: %w", err))
}
sortedManifests, err := sequence.ParseStoredManifests(targetRelease.Manifest)
if err != nil {
return fail(nil, fmt.Errorf("parsing target release manifest for sequenced rollback: %w", err))
}
plan, err := sequence.Build(targetRelease.Chart, sortedManifests)
if err != nil {
return fail(nil, fmt.Errorf("building sequencing plan for rollback: %w", err))
}
logPlanWarnings(r.cfg.Logger(), plan)
if !r.DisableHooks {
if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.WaitStrategy, r.WaitOptions, r.Timeout, serverSideApply); err != nil {
return fail(nil, err)
}
} else {
r.cfg.Logger().Debug("rollback hooks disabled", "name", targetRelease.Name)
}
readinessTimeout := r.ReadinessTimeout
if readinessTimeout <= 0 {
readinessTimeout = time.Minute
}
gatingStrategy := r.WaitStrategy
if gatingStrategy == kube.HookOnlyStrategy {
r.cfg.Logger().Info("release was deployed with ordered sequencing; using the status watcher to gate rollback batches (hooks keep the hook-only strategy)", "release", targetRelease.Name)
gatingStrategy = kube.StatusWatcherStrategy
}
sd := &sequencedDeployment{
cfg: r.cfg,
releaseName: targetRelease.Name,
releaseNamespace: targetRelease.Namespace,
serverSideApply: serverSideApply,
forceConflicts: r.ForceConflicts,
forceReplace: r.ForceReplace,
waitStrategy: gatingStrategy,
waitOptions: r.WaitOptions,
waitForJobs: r.WaitForJobs,
timeout: r.Timeout,
readinessTimeout: readinessTimeout,
deadline: computeDeadline(r.Timeout),
upgradeMode: true,
currentResources: current,
upgradeCSAFieldManager: true,
}
if err := sd.apply(ctx, plan); err != nil {
return fail(sd.createdResources, err)
}
allTargetKeys := make(map[string]bool, len(target))
for _, resource := range target {
allTargetKeys[objectKey(resource)] = true
}
removedKeys := make(map[string]bool)
var toBeDeleted kube.ResourceList
for _, resource := range current {
if !allTargetKeys[objectKey(resource)] {
removedKeys[objectKey(resource)] = true
toBeDeleted = append(toBeDeleted, resource)
}
}
if len(toBeDeleted) > 0 {
waiter, err := getWaiterFor(r.cfg.KubeClient, gatingStrategy, r.WaitOptions...)
if err != nil {
return fail(sd.createdResources, fmt.Errorf("getting waiter for removed-resource deletion: %w", err))
}
if err := sd.deleteRemovedFromOldRelease(currentRelease, removedKeys, toBeDeleted, waiter); err != nil {
return fail(sd.createdResources, fmt.Errorf("deleting removed resources during rollback: %w", err))
}
}
if !r.DisableHooks {
if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.WaitStrategy, r.WaitOptions, r.Timeout, serverSideApply); err != nil {
return fail(sd.createdResources, err)
}
}
deployed, err := r.cfg.Releases.DeployedAll(currentRelease.Name)
if err != nil && !errors.Is(err, driver.ErrNoDeployedReleases) {
return fail(sd.createdResources, err)
}
for _, reli := range deployed {
rel, err := releaserToV1Release(reli)
if err != nil {
return fail(sd.createdResources, err)
}
r.cfg.Logger().Debug("superseding previous deployment", "version", rel.Version)
rel.Info.Status = common.StatusSuperseded
r.cfg.recordRelease(rel)
}
targetRelease.Info.Status = common.StatusDeployed
return targetRelease, nil
}

@ -0,0 +1,538 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/kube"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
func newRollbackAction(t *testing.T, kubeClient kube.Interface) *Rollback {
t.Helper()
cfg := actionConfigFixture(t)
cfg.KubeClient = kubeClient
rollback := NewRollback(cfg)
rollback.Timeout = 5 * time.Minute
rollback.WaitStrategy = kube.OrderedWaitStrategy
rollback.ServerSideApply = "auto"
return rollback
}
func seedRollbackRelease(t *testing.T, rollback *Rollback, name string, version int, status rcommon.Status, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo) {
t.Helper()
now := time.Now()
rel := &release.Release{
Name: name,
Namespace: "spaced",
Chart: ch,
Config: map[string]any{},
Manifest: manifest,
Info: &release.Info{
FirstDeployed: now,
LastDeployed: now,
Status: status,
Description: "Seeded release",
},
Version: version,
ApplyMethod: "csa",
SequencingInfo: sequencingInfo,
}
require.NoError(t, rollback.cfg.Releases.Create(rel))
}
func storedRollbackRelease(t *testing.T, rollback *Rollback, name string, version int) *release.Release {
t.Helper()
reli, err := rollback.cfg.Releases.Get(name, version)
require.NoError(t, err)
rel, err := releaserToV1Release(reli)
require.NoError(t, err)
return rel
}
func latestRollbackRelease(t *testing.T, rollback *Rollback, name string) *release.Release {
t.Helper()
reli, err := rollback.cfg.Releases.Last(name)
require.NoError(t, err)
rel, err := releaserToV1Release(reli)
require.NoError(t, err)
return rel
}
func TestRollback_Sequenced_Basic(t *testing.T) {
client := newRecordingKubeClient()
rollback := newRollbackAction(t, client)
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("rollback-basic"))
sequencingInfo := &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}
seedRollbackRelease(t, rollback, "rollback-basic", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), sequencingInfo)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-basic"))
seedRollbackRelease(t, rollback, "rollback-basic", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("app", nil),
), nil)
rollback.Version = 1
require.NoError(t, rollback.Run("rollback-basic"))
require.Len(t, client.updateCalls, 2)
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls)
assert.Equal(t, []string{"ConfigMap/database"}, client.updateCalls[0].current)
assert.Equal(t, []string{"ConfigMap/app"}, client.updateCalls[1].current)
rel := latestRollbackRelease(t, rollback, "rollback-basic")
assert.Equal(t, 3, rel.Version)
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
assert.True(t, rel.IsSequenced())
assert.True(t, rel.Sequenced)
assert.Nil(t, rel.SequencingInfo)
}
// TestRollback_Sequenced_RemovedGroupsDeletedInReverseOrder locks bead
// 7yi-rollback: resources present in the from-revision but absent from the
// rollback target must be deleted batch-by-batch in exact reverse deployment
// order of the FROM revision's plan, with a delete-wait gating each batch,
// not in one unordered bulk call.
func TestRollback_Sequenced_RemovedGroupsDeletedInReverseOrder(t *testing.T) {
client := newRecordingKubeClient()
rollback := newRollbackAction(t, client)
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-removed"))
seedRollbackRelease(t, rollback, "rollback-removed", 1, rcommon.StatusSuperseded, targetChart, configMapManifest("app", nil), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-removed"))
seedRollbackRelease(t, rollback, "rollback-removed", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
configMapManifest("app", nil),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
rollback.Version = 1
require.NoError(t, rollback.Run("rollback-removed"))
assert.Equal(t, [][]string{{"ConfigMap/queue"}, {"ConfigMap/database"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
// TestRollback_Sequenced_DefaultWaitStrategyGatesBatches locks bead 4sj for
// rollback: a rollback to a sequenced revision under the DEFAULT hookOnly
// strategy must gate each re-deploy batch on real readiness waits.
func TestRollback_Sequenced_DefaultWaitStrategyGatesBatches(t *testing.T) {
client := newRecordingKubeClient()
cfg := actionConfigFixture(t)
cfg.KubeClient = client
rollback := NewRollback(cfg)
rollback.Timeout = 5 * time.Minute
rollback.WaitStrategy = kube.HookOnlyStrategy
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("rollback-default-wait"))
now := time.Now()
require.NoError(t, rollback.cfg.Releases.Create(&release.Release{
Name: "rollback-default-wait",
Namespace: "spaced",
Chart: targetChart,
Config: map[string]any{},
Manifest: joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
),
Info: &release.Info{
FirstDeployed: now,
LastDeployed: now,
Status: rcommon.StatusSuperseded,
Description: "Seeded release",
},
Version: 1,
ApplyMethod: "csa",
Sequenced: true,
}))
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-default-wait"))
seedRollbackRelease(t, rollback, "rollback-default-wait", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("app", nil),
), nil)
rollback.Version = 1
require.NoError(t, rollback.Run("rollback-default-wait"))
require.Len(t, client.updateCalls, 2)
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls)
assert.Contains(t, client.waiterStrategies, kube.StatusWatcherStrategy)
}
func TestRollback_ToNonSequenced(t *testing.T) {
client := newRecordingKubeClient()
rollback := newRollbackAction(t, client)
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("rollback-to-standard"))
seedRollbackRelease(t, rollback, "rollback-to-standard", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), nil)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-to-standard"))
seedRollbackRelease(t, rollback, "rollback-to-standard", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("app", nil),
), nil)
rollback.Version = 1
require.NoError(t, rollback.Run("rollback-to-standard"))
require.Len(t, client.updateCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target)
require.Len(t, client.waitCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0])
assert.False(t, latestRollbackRelease(t, rollback, "rollback-to-standard").IsSequenced())
}
func TestRollback_FromSequencedToNonSequenced(t *testing.T) {
client := newRecordingKubeClient()
rollback := newRollbackAction(t, client)
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-transition"))
seedRollbackRelease(t, rollback, "rollback-transition", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("app", nil),
), nil)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("rollback-transition"))
seedRollbackRelease(t, rollback, "rollback-transition", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
rollback.Version = 1
require.NoError(t, rollback.Run("rollback-transition"))
require.Len(t, client.updateCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target)
assert.False(t, latestRollbackRelease(t, rollback, "rollback-transition").IsSequenced())
}
func TestRollback_SequencedFlagPreservedFromLegacyRelease(t *testing.T) {
client := newRecordingKubeClient()
rollback := newRollbackAction(t, client)
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
}, withName("rollback-sequencing-info"))
sequencingInfo := &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)}
seedRollbackRelease(t, rollback, "rollback-sequencing-info", 1, rcommon.StatusSuperseded, targetChart, configMapManifest("database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}), sequencingInfo)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
}, withName("rollback-sequencing-info"))
seedRollbackRelease(t, rollback, "rollback-sequencing-info", 2, rcommon.StatusDeployed, currentChart, configMapManifest("database", nil), nil)
rollback.Version = 1
require.NoError(t, rollback.Run("rollback-sequencing-info"))
rel := latestRollbackRelease(t, rollback, "rollback-sequencing-info")
assert.True(t, rel.IsSequenced())
assert.True(t, rel.Sequenced)
assert.Nil(t, rel.SequencingInfo)
}
func TestRollback_Sequenced_Failure(t *testing.T) {
client := newRecordingKubeClient()
client.waitErrorOnCall = 2
client.waitError = errors.New("timed out waiting for batch")
rollback := newRollbackAction(t, client)
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
}, withName("rollback-failure"))
seedRollbackRelease(t, rollback, "rollback-failure", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-failure"))
seedRollbackRelease(t, rollback, "rollback-failure", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("app", nil),
), nil)
rollback.Version = 1
err := rollback.Run("rollback-failure")
require.Error(t, err)
assert.Contains(t, err.Error(), "timed out waiting for batch")
require.Len(t, client.updateCalls, 2)
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}}, updateTargets(client.updateCalls))
assert.Empty(t, client.deleteCalls)
assert.Equal(t, rcommon.StatusFailed, latestRollbackRelease(t, rollback, "rollback-failure").Info.Status)
assert.Equal(t, rcommon.StatusSuperseded, storedRollbackRelease(t, rollback, "rollback-failure", 2).Info.Status)
}
func TestRollback_Sequenced_CleanupOnFail(t *testing.T) {
client := newRecordingKubeClient()
client.waitErrorOnCall = 2
client.waitError = errors.New("timed out waiting for batch")
rollback := newRollbackAction(t, client)
rollback.CleanupOnFail = true
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
}, withName("rollback-cleanup"))
seedRollbackRelease(t, rollback, "rollback-cleanup", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-cleanup"))
seedRollbackRelease(t, rollback, "rollback-cleanup", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("app", nil),
), nil)
rollback.Version = 1
err := rollback.Run("rollback-cleanup")
require.Error(t, err)
assert.Contains(t, err.Error(), "timed out waiting for batch")
assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls)
assert.Equal(t, rcommon.StatusFailed, latestRollbackRelease(t, rollback, "rollback-cleanup").Info.Status)
}
func TestRollback_Sequenced_DryRun(t *testing.T) {
client := newRecordingKubeClient()
rollback := newRollbackAction(t, client)
rollback.DryRunStrategy = DryRunClient
targetChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("rollback-dry-run"))
seedRollbackRelease(t, rollback, "rollback-dry-run", 1, rcommon.StatusSuperseded, targetChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
makeConfigMapTemplate("templates/app.yaml", "app", nil),
}, withName("rollback-dry-run"))
seedRollbackRelease(t, rollback, "rollback-dry-run", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("app", nil),
), nil)
rollback.Version = 1
require.NoError(t, rollback.Run("rollback-dry-run"))
assert.Empty(t, client.updateCalls)
assert.Empty(t, client.waitCalls)
assert.Empty(t, client.deleteCalls)
assert.Equal(t, 2, latestRollbackRelease(t, rollback, "rollback-dry-run").Version)
}
// TestRollback_Sequenced_StorageDecodedChart_Subcharts guards the rollback
// leg of bead xmn: the target revision's chart comes from release storage,
// which drops the loaded dependency tree, so the forward plan must be
// rebuilt from the stored metadata and manifest paths: subchart resources
// first, then the parent's own.
func TestRollback_Sequenced_StorageDecodedChart_Subcharts(t *testing.T) {
client := newRecordingKubeClient()
rollback := newRollbackAction(t, client)
parent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
bar := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/bar.yaml", "bar", nil),
}, withName("bar"))
parent.AddDependency(bar)
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar", Enabled: true}}
parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["bar"]`}
seedRollbackRelease(t, rollback, "decoded-rollback", 1, rcommon.StatusSuperseded, storageDecodedChart(t, parent), joinManifestDocs(
sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)),
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
seedRollbackRelease(t, rollback, "decoded-rollback", 2, rcommon.StatusDeployed, currentChart, joinManifestDocs(
configMapManifest("parent", nil),
configMapManifest("bar", nil),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
rollback.Version = 1
require.NoError(t, rollback.Run("decoded-rollback"))
// Forward deployment order: the subchart's batch first, then the parent's.
assert.Equal(t, [][]string{{"ConfigMap/bar"}, {"ConfigMap/parent"}}, updateTargets(client.updateCalls))
assert.Equal(t, [][]string{{"ConfigMap/bar"}, {"ConfigMap/parent"}}, client.waitCalls)
}

@ -27,6 +27,9 @@ import (
"helm.sh/helm/v4/pkg/kube"
kubefake "helm.sh/helm/v4/pkg/kube/fake"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
func TestNewRollback(t *testing.T) {
@ -131,3 +134,85 @@ func TestRollbackRevisionZeroForNonRollback(t *testing.T) {
assert.Equal(t, 0, r.Info.RollbackRevision)
}
// TestRollback_StripsSequencingAnnotationsOnPlainPath locks the fix for cap-21
// (hip-0025-r5y). Scenario: a release was installed plain at rev-1, upgraded
// with sequencing at rev-2 (manifest stored in the secret retains the raw
// helm.sh/depends-on/resource-groups annotation), then rolled back to rev-1.
// Because targetRelease.SequencingInfo is unset, performRollback falls through
// to the plain (non-sequenced) UPDATE path. Without stripping, SSA on the
// rollback rejects the multi-slash annotation key as invalid. This test
// verifies that BOTH current (rev-2 manifest) and target (rev-1 manifest) are
// passed through stripSequencingAnnotations before KubeClient.Update is called.
func TestRollback_StripsSequencingAnnotationsOnPlainPath(t *testing.T) {
const annotation = releaseutil.AnnotationDependsOnResourceGroups
// rev-2's manifest carries the helm-internal annotation because the
// rendered template kept it; the live K8s objects had it stripped by the
// sequenced upgrade path, but the secret-stored Manifest still has it.
sequencedManifest := `apiVersion: v1
kind: ConfigMap
metadata:
name: cap21-cm
namespace: spaced
annotations:
"` + annotation + `": "[other-group]"
data:
k: v
`
// rev-1's manifest is plain.
plainManifest := `apiVersion: v1
kind: ConfigMap
metadata:
name: cap21-cm
namespace: spaced
data:
k: v
`
rev1 := releaseStub()
rev1.Name = "cap21"
rev1.Version = 1
rev1.Info.Status = rcommon.StatusSuperseded
rev1.Manifest = plainManifest
rev1.SequencingInfo = nil // plain install
rev2 := releaseStub()
rev2.Name = "cap21"
rev2.Version = 2
rev2.Info.Status = rcommon.StatusDeployed
rev2.Manifest = sequencedManifest
rev2.SequencingInfo = &release.SequencingInfo{Enabled: true, Strategy: "ordered"}
cfg := actionConfigFixture(t)
require.NoError(t, cfg.Releases.Create(rev1))
require.NoError(t, cfg.Releases.Create(rev2))
recorder := newRecordingKubeClient()
cfg.KubeClient = recorder
client := NewRollback(cfg)
client.Version = 1
client.DisableHooks = true
require.NoError(t, client.Run("cap21"))
require.Len(t, recorder.updateCalls, 1, "exactly one KubeClient.Update call expected on plain rollback path")
call := recorder.updateCalls[0]
assertNoSequencingAnnotation(t, "current", call.currentResources, annotation)
assertNoSequencingAnnotation(t, "target", call.targetResources, annotation)
}
func assertNoSequencingAnnotation(t *testing.T, label string, resources kube.ResourceList, key string) {
t.Helper()
for _, info := range resources {
acc := info.Object.(interface {
GetAnnotations() map[string]string
})
anns := acc.GetAnnotations()
if _, present := anns[key]; present {
t.Fatalf("%s resource %q still carries stripped annotation %q after rollback", label, info.Name, key)
}
}
}

@ -0,0 +1,344 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"helm.sh/helm/v4/pkg/kube"
release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/release/v1/sequence"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/cli-runtime/pkg/resource"
)
// computeDeadline returns a deadline based on the given timeout duration.
// If timeout is zero or negative, it returns the zero time (no deadline).
func computeDeadline(timeout time.Duration) time.Time {
if timeout <= 0 {
return time.Time{}
}
return time.Now().Add(timeout)
}
// buildManifestYAML concatenates the Content fields of the given manifests into a single
// YAML stream suitable for passing to KubeClient.Build().
func buildManifestYAML(manifests []releaseutil.Manifest) string {
if len(manifests) == 0 {
return ""
}
var buf strings.Builder
for i, m := range manifests {
if i > 0 {
buf.WriteString("---\n")
}
buf.WriteString(m.Content)
buf.WriteString("\n")
}
return buf.String()
}
// sequencedDeployment executes a sequence.Plan: ordered installation, upgrade,
// or rollback of chart resources, one batch at a time, gating each batch on
// readiness before the next starts.
type sequencedDeployment struct {
cfg *Configuration
releaseName string
releaseNamespace string
disableOpenAPI bool
serverSideApply bool
forceConflicts bool
forceReplace bool
waitStrategy kube.WaitStrategy
waitOptions []kube.WaitOption
waitForJobs bool
timeout time.Duration
readinessTimeout time.Duration
deadline time.Time // overall operation deadline
// upgradeMode forces every batch through KubeClient.Update. When false
// (install), a batch is still applied via Update when it intersects
// currentResources (resources adopted with --take-ownership).
upgradeMode bool
// currentResources are the objects a batch may be an update of: the full
// old-release resource list on upgrade/rollback, the toBeAdopted set on
// install. Matched per batch by objectKey.
currentResources kube.ResourceList
upgradeCSAFieldManager bool // upgrade client-side apply field manager
// threeWayMergeForUnstructured mirrors performInstall's adoption Update
// (TakeOwnership && !ServerSideApply); always false on upgrade/rollback.
threeWayMergeForUnstructured bool
createdResources kube.ResourceList
}
// logPlanWarnings surfaces non-fatal sequencing-plan warnings to the user.
func logPlanWarnings(logger *slog.Logger, plan *sequence.Plan) {
for _, w := range plan.Warnings {
logger.Warn("sequencing: "+w.Message, "chart", w.ChartPath)
}
}
// getWaiterFor returns a waiter for the given strategy, preferring the
// options-aware interface when the client supports it.
func getWaiterFor(client kube.Interface, ws kube.WaitStrategy, opts ...kube.WaitOption) (kube.Waiter, error) {
if c, ok := client.(kube.InterfaceWaitOptions); ok {
return c.GetWaiterWithOptions(ws, opts...)
}
return client.GetWaiter(ws)
}
// apply deploys the plan batch by batch, in order.
func (s *sequencedDeployment) apply(ctx context.Context, plan *sequence.Plan) error {
for _, batch := range plan.Batches {
if err := s.applyBatch(ctx, batch); err != nil {
return err
}
}
return nil
}
// applyBatch builds one batch's manifests, sets release metadata, strips
// Helm-internal sequencing annotations, creates or updates the resources, and
// waits for readiness. Context cancellation is honored at each stage boundary:
// before Build, before Create/Update, and before Wait.
func (s *sequencedDeployment) applyBatch(ctx context.Context, batch sequence.Batch) error {
if err := ctx.Err(); err != nil {
return err
}
manifests := batch.Manifests()
if len(manifests) == 0 {
return nil
}
yaml := buildManifestYAML(manifests)
target, err := s.cfg.KubeClient.Build(bytes.NewBufferString(yaml), !s.disableOpenAPI)
if err != nil {
return fmt.Errorf("building resource batch: %w", err)
}
if len(target) == 0 {
return nil
}
if err := target.Visit(setMetadataVisitor(s.releaseName, s.releaseNamespace, true)); err != nil {
return fmt.Errorf("setting metadata for resource batch: %w", err)
}
if err := stripSequencingAnnotations(target); err != nil {
return fmt.Errorf("stripping sequencing annotations: %w", err)
}
if err := ctx.Err(); err != nil {
return err
}
targetKeys := make(map[string]bool, len(target))
for _, r := range target {
targetKeys[objectKey(r)] = true
}
var matchingCurrent kube.ResourceList
for _, r := range s.currentResources {
if targetKeys[objectKey(r)] {
matchingCurrent = append(matchingCurrent, r)
}
}
var result *kube.Result
if s.upgradeMode || len(matchingCurrent) > 0 {
result, err = s.cfg.KubeClient.Update(
matchingCurrent,
target,
kube.ClientUpdateOptionForceReplace(s.forceReplace),
kube.ClientUpdateOptionServerSideApply(s.serverSideApply, s.forceConflicts),
kube.ClientUpdateOptionThreeWayMergeForUnstructured(s.threeWayMergeForUnstructured),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(s.upgradeCSAFieldManager),
)
if err != nil {
return fmt.Errorf("updating resource batch: %w", err)
}
} else {
result, err = s.cfg.KubeClient.Create(
target,
kube.ClientCreateOptionServerSideApply(s.serverSideApply, false))
if err != nil {
return fmt.Errorf("creating resource batch: %w", err)
}
}
s.createdResources = append(s.createdResources, result.Created...)
if err := ctx.Err(); err != nil {
return err
}
if !batch.Wait {
return nil
}
return s.waitForResources(target, batch.HasCustomReadiness)
}
// stripSequencingAnnotations removes Helm-internal sequencing annotations from
// resources before they are applied to Kubernetes. This prevents K8s API
// validation errors for annotation keys that are not valid K8s label keys.
func stripSequencingAnnotations(resources kube.ResourceList) error {
sequencingKeys := releaseutil.HelmInternalSequencingAnnotations()
return resources.Visit(func(info *resource.Info, err error) error {
if err != nil {
return err
}
acc, accErr := meta.Accessor(info.Object)
if accErr != nil {
return nil // skip non-accessible objects
}
annotations := acc.GetAnnotations()
if len(annotations) == 0 {
return nil
}
changed := false
for _, key := range sequencingKeys {
if _, exists := annotations[key]; exists {
delete(annotations, key)
changed = true
}
}
if changed {
acc.SetAnnotations(annotations)
}
return nil
})
}
// deleteRemoved deletes resources of the previous release that are absent from
// the new one, walking an already-reversed plan of the OLD release so removal
// happens in exact reverse deployment order, gating each batch on WaitForDelete
// (HIP-0025 §Sequencing order, bead 7yi). removedKeys holds the objectKey()s
// of resources to delete; batch resources outside the set are skipped.
func (s *sequencedDeployment) deleteRemoved(reversedPlan *sequence.Plan, removedKeys map[string]bool, waiter kube.Waiter) error {
for _, batch := range reversedPlan.Batches {
manifests := batch.Manifests()
if len(manifests) == 0 {
continue
}
resources, err := s.cfg.KubeClient.Build(bytes.NewBufferString(buildManifestYAML(manifests)), false)
if err != nil {
return fmt.Errorf("building removed-resource batch: %w", err)
}
var toDelete kube.ResourceList
for _, r := range resources {
if removedKeys[objectKey(r)] {
toDelete = append(toDelete, r)
}
}
if len(toDelete) == 0 {
continue
}
if _, errs := s.cfg.KubeClient.Delete(toDelete, metav1.DeletePropagationBackground); errs != nil {
return fmt.Errorf("deleting removed resources: %w", joinErrors(errs, ", "))
}
if !batch.Wait {
continue
}
waitTimeout, err := s.batchWaitTimeout()
if err != nil {
return err
}
if err := waiter.WaitForDelete(toDelete, waitTimeout); err != nil {
return fmt.Errorf("waiting for removed resources to be deleted: %w", err)
}
}
return nil
}
// deleteRemovedFromOldRelease builds the OLD release's deployment plan from its
// stored manifest and deletes the removed resources batch-by-batch in exact
// reverse deployment order (HIP-0025 §Sequencing order, bead 7yi). When no plan
// can be built from the stored release (pre-sequencing record, unparsable
// manifest), it falls back to one unordered bulk delete with a warning.
func (s *sequencedDeployment) deleteRemovedFromOldRelease(oldRelease *release.Release, removedKeys map[string]bool, toBeDeleted kube.ResourceList, waiter kube.Waiter) error {
oldManifests, err := sequence.ParseStoredManifests(oldRelease.Manifest)
var oldPlan *sequence.Plan
if err == nil {
oldPlan, err = sequence.Build(oldRelease.Chart, oldManifests)
}
if err != nil {
s.cfg.Logger().Warn("unable to build sequencing plan for previous release; deleting removed resources unordered", slog.Any("error", err))
if _, errs := s.cfg.KubeClient.Delete(toBeDeleted, metav1.DeletePropagationBackground); errs != nil {
return fmt.Errorf("deleting removed resources: %w", joinErrors(errs, ", "))
}
return nil
}
return s.deleteRemoved(oldPlan.Reverse(), removedKeys, waiter)
}
// batchWaitTimeout returns the effective per-batch wait timeout:
// min(readinessTimeout, time remaining until the overall deadline).
func (s *sequencedDeployment) batchWaitTimeout() (time.Duration, error) {
waitTimeout := s.readinessTimeout
if !s.deadline.IsZero() {
remaining := time.Until(s.deadline)
if remaining <= 0 {
return 0, errors.New("overall timeout exceeded before waiting for resource batch")
}
if waitTimeout <= 0 || remaining < waitTimeout {
waitTimeout = remaining
}
}
if waitTimeout <= 0 {
waitTimeout = time.Minute // safe default
}
return waitTimeout, nil
}
// waitForResources waits for the given resources to become ready, applying the
// per-batch and overall deadline constraints. Every batch is waited on (D1):
// leaf-group suppression was removed because it keyed on bare resource names
// (beads xnf/p1m) and made --wait=ordered weaker than --wait for terminal
// groups; Batch.LeafGroups is retained by the builder for display only.
func (s *sequencedDeployment) waitForResources(resources kube.ResourceList, hasCustomReadiness bool) error {
if len(resources) == 0 {
return nil
}
waitTimeout, err := s.batchWaitTimeout()
if err != nil {
return err
}
var waiter kube.Waiter
waitOptions := append([]kube.WaitOption(nil), s.waitOptions...)
if hasCustomReadiness {
waitOptions = append(waitOptions, kube.WithCustomReadinessStatusReader())
}
if c, ok := s.cfg.KubeClient.(kube.InterfaceWaitOptions); ok {
waiter, err = c.GetWaiterWithOptions(s.waitStrategy, waitOptions...)
} else {
waiter, err = s.cfg.KubeClient.GetWaiter(s.waitStrategy)
}
if err != nil {
return fmt.Errorf("getting waiter for resource batch: %w", err)
}
if s.waitForJobs {
return waiter.WaitWithJobs(resources, waitTimeout)
}
return waiter.Wait(resources, waitTimeout)
}

File diff suppressed because it is too large Load Diff

@ -28,8 +28,9 @@ import (
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/kube"
releasei "helm.sh/helm/v4/pkg/release"
"helm.sh/helm/v4/pkg/release/common"
releasecommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/release/v1/sequence"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"helm.sh/helm/v4/pkg/storage/driver"
)
@ -183,7 +184,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error)
// TODO: Are there any cases where we want to force a delete even if it's
// already marked deleted?
if rel.Info.Status == common.StatusUninstalled {
if rel.Info.Status == releasecommon.StatusUninstalled {
if !u.KeepHistory {
if err := u.purgeReleases(rels...); err != nil {
return nil, fmt.Errorf("uninstall: Failed to purge the release: %w", err)
@ -193,8 +194,17 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error)
return nil, fmt.Errorf("the release named %q is already deleted", name)
}
gatingWaiter := waiter
if rel.IsSequenced() && u.WaitStrategy == kube.HookOnlyStrategy {
u.cfg.Logger().Info("release was installed with ordered sequencing; using the status watcher to gate deletion batches (hooks keep the hook-only strategy)", "release", rel.Name)
gatingWaiter, err = getWaiterFor(u.cfg.KubeClient, kube.StatusWatcherStrategy, u.WaitOptions...)
if err != nil {
return nil, err
}
}
u.cfg.Logger().Debug("uninstall: deleting release", "name", name)
rel.Info.Status = common.StatusUninstalling
rel.Info.Status = releasecommon.StatusUninstalling
rel.Info.Deleted = time.Now()
rel.Info.Description = "Deletion in progress (or silently failed)"
res := &releasei.UninstallReleaseResponse{Release: rel}
@ -214,7 +224,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error)
u.cfg.Logger().Debug("uninstall: Failed to store updated release", slog.Any("error", err))
}
deletedResources, kept, errs := u.deleteRelease(rel)
deletedResources, kept, errs := u.deleteRelease(rel, gatingWaiter)
if errs != nil {
u.cfg.Logger().Debug("uninstall: Failed to delete release", slog.Any("error", errs))
return nil, fmt.Errorf("failed to delete release: %s", name)
@ -222,8 +232,10 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error)
res.Info = kept
if err := waiter.WaitForDelete(deletedResources, u.Timeout); err != nil {
errs = append(errs, err)
if !rel.IsSequenced() {
if err := waiter.WaitForDelete(deletedResources, u.Timeout); err != nil {
errs = append(errs, err)
}
}
if !u.DisableHooks {
@ -233,7 +245,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error)
}
}
rel.Info.Status = common.StatusUninstalled
rel.Info.Status = releasecommon.StatusUninstalled
if len(u.Description) > 0 {
rel.Info.Description = u.Description
} else {
@ -272,7 +284,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error)
}
u.cfg.Logger().Debug("superseding previous deployment", "version", rel.Version)
rel.Info.Status = common.StatusSuperseded
rel.Info.Status = releasecommon.StatusSuperseded
if err := u.cfg.Releases.Update(rel); err != nil {
u.cfg.Logger().Debug("uninstall: Failed to store updated release", slog.Any("error", err))
}
@ -317,8 +329,21 @@ func (e *joinedErrors) Unwrap() []error {
return e.errs
}
// deleteRelease deletes the release and returns list of delete resources and manifests that were kept in the deletion process
func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, string, []error) {
// deleteRelease deletes the release and returns list of delete resources and manifests that were kept in the deletion process.
func (u *Uninstall) deleteRelease(rel *release.Release, waiter kube.Waiter) (kube.ResourceList, string, []error) {
if rel.IsSequenced() {
resources, kept, errs, planned := u.deleteReleaseSequenced(rel, waiter)
if planned {
return resources, kept, errs
}
// The ordered plan could not be rebuilt from the stored release (e.g.
// a corrupted record). Never leave the release stuck in uninstalling
// over ordering: deletion completeness is what matters, so degrade to
// the standard unsequenced deletion below with a warning, exactly as
// upgrade does for removed-resource deletion (deleteRemovedFromOldRelease).
u.cfg.Logger().Warn("unable to rebuild the ordered uninstall plan from the stored release; deleting resources without sequencing", slog.Any("error", joinErrors(errs, "; ")))
}
var errs []error
manifests := releaseutil.SplitManifests(rel.Manifest)
@ -340,75 +365,185 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri
}
}
resources, err := u.buildDeleteResources(filesToDelete)
if err != nil {
return nil, "", []error{err}
}
ownedResources, skipped, err := u.verifyOwnedForDelete(resources, rel.Name, rel.Namespace)
if err != nil {
return nil, "", []error{err}
}
if skipped != "" {
if kept.Len() > 0 {
kept.WriteString("\n")
}
kept.WriteString(skipped)
}
// Delete only owned resources
if len(ownedResources) > 0 {
_, errs = u.cfg.KubeClient.Delete(ownedResources, parseCascadingFlag(u.DeletionPropagation, u.cfg.Logger()))
}
return ownedResources, kept.String(), errs
}
// deleteReleaseSequenced deletes a sequenced release's resources in exact
// reverse deployment order. The forward plan is rebuilt from the STORED
// manifest via its "# Source:" comments (no chart re-render), so it covers
// every stored resource, including vendored subcharts absent from Chart.yaml
// dependencies, which the DAG-walking deleter used to leak (bead i42).
// Keep-policy filtering applies to the parsed stream before the plan is built,
// so kept resources are never part of the plan.
//
// The final return value reports whether a plan was built and ordered deletion
// ran (regardless of deletion errors). false means planning failed before any
// deletion was attempted; the caller falls back to unsequenced deletion so a
// planning failure can never leave the release stuck in uninstalling.
func (u *Uninstall) deleteReleaseSequenced(rel *release.Release, waiter kube.Waiter) (kube.ResourceList, string, []error, bool) {
manifests, err := sequence.ParseStoredManifests(rel.Manifest)
if err != nil {
return nil, "", []error{fmt.Errorf("parsing stored release manifest: %w", err)}, false
}
filesToKeep, filesToDelete := filterManifestsToKeep(manifests)
var kept strings.Builder
if len(filesToKeep) > 0 {
kept.WriteString("These resources were kept due to the resource policy:\n")
for _, f := range filesToKeep {
fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name)
}
}
plan, err := sequence.Build(rel.Chart, filesToDelete)
if err != nil {
return nil, "", []error{fmt.Errorf("building sequencing plan for uninstall: %w", err)}, false
}
logPlanWarnings(u.cfg.Logger(), plan)
deadline := computeDeadline(u.Timeout)
var allDeleted kube.ResourceList
for _, batch := range plan.Reverse().Batches {
deleted, errs := u.deleteManifestBatch(batch.Manifests(), waiter, deadline, rel.Name, rel.Namespace)
allDeleted = append(allDeleted, deleted...)
if len(errs) > 0 {
return allDeleted, kept.String(), errs, true
}
}
return allDeleted, kept.String(), nil, true
}
// verifyOwnedForDelete verifies which of the built resources are owned by the
// release. It logs (and thereby skips) resources that are not owned by the
// release or whose ownership cannot be verified, and returns the owned subset
// together with a human-readable summary of what was skipped (empty when nothing
// was skipped). It performs no deletion; callers delete the returned owned
// resources. Both the sequenced and non-sequenced uninstall paths route through
// it so neither deletes resources that do not belong to the release.
func (u *Uninstall) verifyOwnedForDelete(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, string, error) {
if len(resources) == 0 {
return nil, "", nil
}
ownedResources, unownedResources, unverifiableResources, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace)
if err != nil {
return nil, "", fmt.Errorf("unable to verify resource ownership: %w", err)
}
var skipped strings.Builder
// Log and report resources that are not owned by this release.
if len(unownedResources) > 0 {
for _, info := range unownedResources {
u.cfg.Logger().Warn("skipping delete of resource not owned by this release",
"kind", info.Mapping.GroupVersionKind.Kind,
"name", info.Name,
"namespace", info.Namespace,
"release", releaseName)
}
fmt.Fprintf(&skipped, "%d resource(s) were not deleted because they are not owned by this release:\n", len(unownedResources))
for _, info := range unownedResources {
fmt.Fprintf(&skipped, "[%s] %s\n", info.Mapping.GroupVersionKind.Kind, info.Name)
}
}
// Log and report resources whose ownership could not be verified.
if len(unverifiableResources) > 0 {
for _, ur := range unverifiableResources {
u.cfg.Logger().Warn("skipping delete of resource because ownership could not be verified",
"kind", ur.Info.Mapping.GroupVersionKind.Kind,
"name", ur.Info.Name,
"namespace", ur.Info.Namespace,
"release", releaseName,
"error", ur.Err)
}
if skipped.Len() > 0 {
skipped.WriteString("\n")
}
fmt.Fprintf(&skipped, "%d resource(s) were not deleted because their ownership could not be verified:\n", len(unverifiableResources))
for _, ur := range unverifiableResources {
fmt.Fprintf(&skipped, "[%s] %s: %s\n", ur.Info.Mapping.GroupVersionKind.Kind, ur.Info.Name, ur.Err)
}
}
for _, info := range ownedResources {
u.cfg.Logger().Debug("deleting resource owned by this release",
"kind", info.Mapping.GroupVersionKind.Kind,
"name", info.Name,
"namespace", info.Namespace,
"release", releaseName)
}
return ownedResources, skipped.String(), nil
}
func (u *Uninstall) buildDeleteResources(manifests []releaseutil.Manifest) (kube.ResourceList, error) {
var builder strings.Builder
for _, file := range filesToDelete {
for _, file := range manifests {
builder.WriteString("\n---\n" + file.Content)
}
resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false)
if err != nil {
return nil, "", []error{fmt.Errorf("unable to build kubernetes objects for delete: %w", err)}
return nil, fmt.Errorf("unable to build kubernetes objects for delete: %w", err)
}
return resources, nil
}
// Verify ownership before deleting resources
var ownedResources, unownedResources kube.ResourceList
var unverifiableResources []unverifiableResource
if len(resources) > 0 {
ownedResources, unownedResources, unverifiableResources, err = verifyOwnershipBeforeDelete(resources, rel.Name, rel.Namespace)
func (u *Uninstall) deleteManifestBatch(manifests []releaseutil.Manifest, waiter kube.Waiter, deadline time.Time, releaseName, releaseNamespace string) (kube.ResourceList, []error) {
resources, err := u.buildDeleteResources(manifests)
if err != nil || len(resources) == 0 {
if err != nil {
return nil, "", []error{fmt.Errorf("unable to verify resource ownership: %w", err)}
return nil, []error{err}
}
return nil, nil
}
// Log warnings for unowned resources
if len(unownedResources) > 0 {
for _, info := range unownedResources {
u.cfg.Logger().Warn("skipping delete of resource not owned by this release",
"kind", info.Mapping.GroupVersionKind.Kind,
"name", info.Name,
"namespace", info.Namespace,
"release", rel.Name)
}
if kept.Len() > 0 {
kept.WriteString("\n")
}
fmt.Fprintf(&kept, "%d resource(s) were not deleted because they are not owned by this release:\n", len(unownedResources))
for _, info := range unownedResources {
fmt.Fprintf(&kept, "[%s] %s\n", info.Mapping.GroupVersionKind.Kind, info.Name)
}
}
// Verify ownership before deleting so a sequenced uninstall, like the
// non-sequenced path, never deletes resources it does not own.
ownedResources, _, err := u.verifyOwnedForDelete(resources, releaseName, releaseNamespace)
if err != nil {
return nil, []error{err}
}
if len(ownedResources) == 0 {
return nil, nil
}
// Log warnings for resources whose ownership could not be verified
if len(unverifiableResources) > 0 {
for _, ur := range unverifiableResources {
u.cfg.Logger().Warn("skipping delete of resource because ownership could not be verified",
"kind", ur.Info.Mapping.GroupVersionKind.Kind,
"name", ur.Info.Name,
"namespace", ur.Info.Namespace,
"release", rel.Name,
"error", ur.Err)
}
if kept.Len() > 0 {
kept.WriteString("\n")
}
fmt.Fprintf(&kept, "%d resource(s) were not deleted because their ownership could not be verified:\n", len(unverifiableResources))
for _, ur := range unverifiableResources {
fmt.Fprintf(&kept, "[%s] %s: %s\n", ur.Info.Mapping.GroupVersionKind.Kind, ur.Info.Name, ur.Err)
}
}
_, errs := u.cfg.KubeClient.Delete(ownedResources, parseCascadingFlag(u.DeletionPropagation, u.cfg.Logger()))
if len(errs) > 0 {
return ownedResources, errs
}
// Delete only owned resources
if len(ownedResources) > 0 {
for _, info := range ownedResources {
u.cfg.Logger().Debug("deleting resource owned by this release",
"kind", info.Mapping.GroupVersionKind.Kind,
"name", info.Name,
"namespace", info.Namespace,
"release", rel.Name)
}
_, errs = u.cfg.KubeClient.Delete(ownedResources, parseCascadingFlag(u.DeletionPropagation, u.cfg.Logger()))
timeout := u.Timeout
if !deadline.IsZero() {
if remaining := time.Until(deadline); remaining < timeout {
timeout = remaining
}
}
return ownedResources, kept.String(), errs
if err := waiter.WaitForDelete(ownedResources, timeout); err != nil {
errs = append(errs, err)
}
return ownedResources, errs
}
func parseCascadingFlag(cascadingFlag string, logger *slog.Logger) v1.DeletionPropagation {

@ -0,0 +1,798 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/kube"
kubefake "helm.sh/helm/v4/pkg/kube/fake"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
func newSequencedUninstallAction(t *testing.T, kubeClient kube.Interface) *Uninstall {
t.Helper()
cfg := actionConfigFixture(t)
cfg.KubeClient = kubeClient
uninstall := NewUninstall(cfg)
uninstall.DisableHooks = true
uninstall.Timeout = 5 * time.Minute
uninstall.WaitStrategy = kube.OrderedWaitStrategy
return uninstall
}
func seedUninstallRelease(t *testing.T, uninstall *Uninstall, rel *release.Release) {
t.Helper()
require.NoError(t, uninstall.cfg.Releases.Create(rel))
}
func newUninstallRelease(name string, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo, hooks ...*release.Hook) *release.Release {
now := time.Now()
return &release.Release{
Name: name,
Namespace: "spaced",
Chart: ch,
Config: map[string]any{},
Manifest: manifest,
Info: &release.Info{
FirstDeployed: now,
LastDeployed: now,
Status: rcommon.StatusDeployed,
Description: "Seeded release",
},
Version: 1,
Hooks: hooks,
SequencingInfo: sequencingInfo,
}
}
// sourcedManifest prefixes a manifest document with the "# Source:" comment the
// real render pipeline stores in rel.Manifest (action.go renderResourcesWithFiles).
func sourcedManifest(path, content string) string {
return "# Source: " + path + "\n" + content
}
type commentStrippingPostRenderer struct{}
func (commentStrippingPostRenderer) Run(in *bytes.Buffer) (*bytes.Buffer, error) {
var out bytes.Buffer
for line := range strings.SplitSeq(in.String(), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
out.WriteString(line + "\n")
}
return &out, nil
}
func secretManifest(name string) string {
return fmt.Sprintf(`apiVersion: v1
kind: Secret
metadata:
name: %s
type: Opaque
data:
password: cGFzc3dvcmQ=
`, name)
}
func serviceAccountManifest(name string) string {
return fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount
metadata:
name: %s
`, name)
}
func expectedUninstallIDs(t *testing.T, manifest string) []string {
t.Helper()
_, files, err := releaseutil.SortManifests(releaseutil.SplitManifests(manifest), nil, releaseutil.UninstallOrder)
require.NoError(t, err)
ids := make([]string, 0, len(files))
for _, file := range files {
ids = append(ids, fmt.Sprintf("%s/%s", file.Head.Kind, file.Head.Metadata.Name))
}
return ids
}
func filterDeleteCalls(calls [][]string, wanted ...string) [][]string {
want := make(map[string]struct{}, len(wanted))
for _, id := range wanted {
want[id] = struct{}{}
}
var filtered [][]string
for _, call := range calls {
if len(call) != 1 {
continue
}
if _, ok := want[call[0]]; ok {
filtered = append(filtered, call)
}
}
return filtered
}
func operationIndex(operations []string, exact string) int {
for i, operation := range operations {
if operation == exact {
return i
}
}
return -1
}
func newDeleteHook(name string, event release.HookEvent) *release.Hook {
return &release.Hook{
Name: name,
Kind: "ConfigMap",
Path: name,
Manifest: configMapManifest(name, nil),
Events: []release.HookEvent{event},
DeletePolicies: []release.HookDeletePolicy{release.HookSucceeded},
}
}
func TestUninstall_Sequenced_ReverseOrder(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("sequenced-uninstall"))
rel := newUninstallRelease(
"sequenced-uninstall",
ch,
joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
func TestUninstall_NonSequenced_Unchanged(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
manifest := joinManifestDocs(
configMapManifest("config", nil),
secretManifest("secret"),
serviceAccountManifest("service-account"),
)
rel := newUninstallRelease("standard-uninstall", buildChartWithTemplates(nil, withName("standard-uninstall")), manifest, nil)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
require.Len(t, client.deleteCalls, 1)
assert.Equal(t, expectedUninstallIDs(t, manifest), client.deleteCalls[0])
require.Len(t, client.deleteWaitCalls, 1)
assert.Equal(t, client.deleteCalls[0], client.deleteWaitCalls[0])
}
func TestUninstall_Sequenced_WithSubcharts(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
parent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
bar := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/bar.yaml", "bar", nil),
}, withName("bar"))
nginx := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/nginx.yaml", "nginx", nil),
}, withName("nginx"))
bar.AddDependency(nginx)
bar.Metadata.Dependencies = []*chart.Dependency{{Name: "nginx"}}
parent.AddDependency(bar)
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}}
rel := newUninstallRelease(
"subchart-uninstall",
parent,
joinManifestDocs(
sourcedManifest("parent/charts/bar/charts/nginx/templates/nginx.yaml", configMapManifest("nginx", nil)),
sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)),
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
// Reverse-DAG uninstall: parent first, then bar (its own resources before
// recursing into bar's subchart batch), then nginx (innermost). With the
// chart-path threading fix, nested subcharts route through their proper
// subtree level instead of flattening.
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}, {"ConfigMap/nginx"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
// TestUninstall_Sequenced_DeletesVendoredSubchart locks bead i42: a subchart
// vendored into charts/ but absent from Chart.yaml dependencies deploys on
// install (TestInstall_Sequenced_UndeclaredVendoredSubchartDeployed) but the
// DAG-walking deleter never visited it. The plan's completeness invariant
// guarantees it is now deleted, after the parent's own resources and before
// declared subcharts (exact reverse of the forward plan).
func TestUninstall_Sequenced_DeletesVendoredSubchart(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
parent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
declared := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
}, withName("database"))
vendored := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/vendored.yaml", "vendored", nil),
}, withName("vendored"))
parent.AddDependency(declared, vendored)
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "database"}}
rel := newUninstallRelease(
"vendored-uninstall",
parent,
joinManifestDocs(
sourcedManifest("parent/charts/database/templates/database.yaml", configMapManifest("database", nil)),
sourcedManifest("parent/charts/vendored/templates/vendored.yaml", configMapManifest("vendored", nil)),
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/vendored"}, {"ConfigMap/database"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
// TestUninstall_Sequenced_DefaultWaitStrategyGatesBatches locks bead 4sj /
// master-plan D4: ordering intent is a property of the stored release, so a
// sequenced release uninstalled with the DEFAULT wait strategy (hookOnly, whose
// WaitForDelete is a no-op) must upgrade its batch-gating waiter to the status
// watcher. Hooks keep the user's strategy; this test disables hooks.
func TestUninstall_Sequenced_DefaultWaitStrategyGatesBatches(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
cfg := actionConfigFixture(t)
cfg.KubeClient = client
uninstall := NewUninstall(cfg)
uninstall.DisableHooks = true
uninstall.Timeout = 5 * time.Minute
uninstall.WaitStrategy = kube.HookOnlyStrategy
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("default-wait-uninstall"))
rel := newUninstallRelease(
"default-wait-uninstall",
ch,
joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
),
nil,
)
rel.Sequenced = true
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
assert.Contains(t, client.waiterStrategies, kube.StatusWatcherStrategy)
}
// TestUninstall_Sequenced_UsesStoredSourcePaths pins the D5 mechanism: the
// uninstall plan is built purely from the stored manifest's "# Source:"
// comments. The chart in this fixture would RE-RENDER to different resource
// names (values drift), which used to break the fuzzy content/identity
// matcher; the stored paths make re-rendering irrelevant.
func TestUninstall_Sequenced_UsesStoredSourcePaths(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
parent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent-renamed", nil),
}, withName("parent"))
bar := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/bar.yaml", "bar-renamed", nil),
}, withName("bar"))
parent.AddDependency(bar)
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}}
rel := newUninstallRelease(
"stored-source-uninstall",
parent,
joinManifestDocs(
sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)),
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
// TestUninstall_Sequenced_AfterPostRenderedInstall pins the epic's D5 premise
// end-to-end: "# Source:" comments are appended to the stored manifest AFTER
// post-rendering (from the filename annotation), so even a comment-stripping
// post-renderer (kustomize-style) leaves the stored record fully
// plan-recoverable. NOTE: this test also passes before the Stage-C rewrite
// (the old path recovered order by re-rendering); it exists so any future
// regression of the premise fails loudly here rather than in production.
func TestUninstall_Sequenced_AfterPostRenderedInstall(t *testing.T) {
client := newRecordingKubeClient()
client.buildRESTClient = statefulOwnedRESTClient(client, "test-install-release", "spaced")
install := newSequencedInstallAction(t, client)
install.PostRenderer = commentStrippingPostRenderer{}
parent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
bar := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/bar.yaml", "bar", nil),
}, withName("bar"))
parent.AddDependency(bar)
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar"}}
rel := mustRelease(t, mustRunInstall(t, install, parent))
assert.Contains(t, rel.Manifest, "# Source: parent/charts/bar/templates/")
assert.Contains(t, rel.Manifest, "# Source: parent/templates/")
uninstall := NewUninstall(install.cfg)
uninstall.DisableHooks = true
uninstall.Timeout = 5 * time.Minute
uninstall.WaitStrategy = kube.OrderedWaitStrategy
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
func TestUninstall_Sequenced_KeepPolicy(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database-live.yaml", "database-live", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/database-keep.yaml", "database-keep", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
kube.ResourcePolicyAnno: kube.KeepPolicy,
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("keep-policy"))
rel := newUninstallRelease(
"keep-policy",
ch,
joinManifestDocs(
configMapManifest("database-live", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("database-keep", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
kube.ResourcePolicyAnno: kube.KeepPolicy,
}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
res, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database-live"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
assert.NotContains(t, client.deleteCalls, []string{"ConfigMap/database-keep"})
assert.Contains(t, res.Info, "[ConfigMap] database-keep")
}
func TestUninstall_Sequenced_KeepHistory(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
uninstall.KeepHistory = true
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("keep-history"))
rel := newUninstallRelease(
"keep-history",
ch,
joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
reli, err := uninstall.cfg.Releases.Get(rel.Name, rel.Version)
require.NoError(t, err)
stored, err := releaserToV1Release(reli)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, client.deleteCalls)
assert.Equal(t, rcommon.StatusUninstalled, stored.Info.Status)
}
func TestUninstall_Sequenced_DryRun(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
uninstall.DryRun = true
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("dry-run-uninstall"))
rel := newUninstallRelease(
"dry-run-uninstall",
ch,
joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
res, err := uninstall.Run(rel.Name)
require.NoError(t, err)
require.NotNil(t, res)
assert.Empty(t, client.deleteCalls)
assert.Empty(t, client.deleteWaitCalls)
}
func TestUninstall_Sequenced_Hooks(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
uninstall.DisableHooks = false
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("hooked-uninstall"))
rel := newUninstallRelease(
"hooked-uninstall",
ch,
joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
newDeleteHook("pre-hook", release.HookPreDelete),
newDeleteHook("post-hook", release.HookPostDelete),
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.createCalls)
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.watchUntilReadyCalls)
assert.Equal(t, [][]string{{"ConfigMap/app"}, {"ConfigMap/database"}}, filterDeleteCalls(client.deleteCalls, "ConfigMap/app", "ConfigMap/database"))
preHookCreate := operationIndex(client.operations, "create:ConfigMap/pre-hook")
appDelete := operationIndex(client.operations, "delete:ConfigMap/app")
databaseWaitDelete := operationIndex(client.operations, "wait-delete:ConfigMap/database")
postHookCreate := operationIndex(client.operations, "create:ConfigMap/post-hook")
require.NotEqual(t, -1, preHookCreate)
require.NotEqual(t, -1, appDelete)
require.NotEqual(t, -1, databaseWaitDelete)
require.NotEqual(t, -1, postHookCreate)
assert.Less(t, preHookCreate, appDelete)
assert.Greater(t, postHookCreate, databaseWaitDelete)
}
// TestUninstall_Sequenced_SkipsUnownedResources locks the fix for the sequenced
// uninstall ownership gap: the sequenced delete path must verify ownership and
// skip resources it does not own, matching the non-sequenced path, rather than
// deleting them.
func TestUninstall_Sequenced_SkipsUnownedResources(t *testing.T) {
is := assert.New(t)
logBuffer := &bytes.Buffer{}
config := actionConfigFixture(t)
config.SetLogger(slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
uninstall := NewUninstall(config)
uninstall.DisableHooks = true
uninstall.KeepHistory = true
uninstall.WaitStrategy = kube.OrderedWaitStrategy
ch := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
}),
}, withName("sequenced-ownership"))
rel := newUninstallRelease(
"sequenced-ownership",
ch,
configMapManifest("app", map[string]string{releaseutil.AnnotationResourceGroup: "app"}),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
// Build resolves every manifest to a resource that is NOT owned by this
// release (no Helm ownership metadata). The sequenced path must skip it.
failer := config.KubeClient.(*kubefake.FailingKubeClient)
failer.DummyResources = kube.ResourceList{
newDeploymentWithOwner("unowned-deploy", rel.Namespace, nil, nil),
}
_, err := uninstall.Run(rel.Name)
is.NoError(err)
logOutput := logBuffer.String()
is.Contains(logOutput, "skipping delete of resource not owned by this release")
is.Contains(logOutput, "unowned-deploy")
}
// storageDecodedChart encodes and decodes a chart the way the release storage
// codec does (json.Marshal in pkg/storage/driver). The memory driver used by
// actionConfigFixture shares release pointers, so without this helper action
// tests never see the shape real (secrets/configmaps/sql) storage produces:
// a chart whose loaded dependency tree is gone.
func storageDecodedChart(t *testing.T, c *chart.Chart) *chart.Chart {
t.Helper()
encoded, err := json.Marshal(c)
require.NoError(t, err)
decoded := &chart.Chart{}
require.NoError(t, json.Unmarshal(encoded, decoded))
require.Empty(t, decoded.Dependencies(), "release codec is expected to drop the loaded dependency tree")
return decoded
}
// TestUninstall_Sequenced_StorageDecodedChart_NestedSubcharts reproduces the
// live bead-xmn failure: `helm uninstall` of a sequenced nested-subchart
// release read from REAL storage (which drops the chart's loaded dependency
// tree) must still delete in exact reverse deployment order instead of
// failing to build the subchart DAG and stranding the release in
// status=uninstalling.
func TestUninstall_Sequenced_StorageDecodedChart_NestedSubcharts(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
parent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
bar := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/bar.yaml", "bar", nil),
}, withName("bar"))
nginx := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/nginx.yaml", "nginx", nil),
}, withName("nginx"))
// Post-ProcessDependencies shape, as install stores it: enabled entries
// only, and the parent-resources annotation that made the live uninstall
// fail with `references unknown or disabled subchart "bar"`.
bar.AddDependency(nginx)
bar.Metadata.Dependencies = []*chart.Dependency{{Name: "nginx", Enabled: true}}
parent.AddDependency(bar)
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "bar", Enabled: true}}
parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["bar"]`}
rel := newUninstallRelease(
"decoded-nested-uninstall",
storageDecodedChart(t, parent),
joinManifestDocs(
sourcedManifest("parent/charts/bar/charts/nginx/templates/nginx.yaml", configMapManifest("nginx", nil)),
sourcedManifest("parent/charts/bar/templates/bar.yaml", configMapManifest("bar", nil)),
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/bar"}, {"ConfigMap/nginx"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
// TestUninstall_Sequenced_StorageDecodedChart_VendoredSubchart extends the
// bead-i42 guard across the storage round-trip: a vendored-but-undeclared
// subchart of a storage-decoded chart is still deleted, after the parent's
// own resources and before declared subcharts.
func TestUninstall_Sequenced_StorageDecodedChart_VendoredSubchart(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
uninstall := newSequencedUninstallAction(t, client)
parent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
parent.Metadata.Dependencies = []*chart.Dependency{{Name: "database", Enabled: true}}
parent.Metadata.Annotations = map[string]string{"helm.sh/depends-on/subcharts": `["database"]`}
rel := newUninstallRelease(
"decoded-vendored-uninstall",
storageDecodedChart(t, parent),
joinManifestDocs(
sourcedManifest("parent/charts/database/templates/database.yaml", configMapManifest("database", nil)),
sourcedManifest("parent/charts/vendored/templates/vendored.yaml", configMapManifest("vendored", nil)),
sourcedManifest("parent/templates/parent.yaml", configMapManifest("parent", nil)),
),
&release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)},
)
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Equal(t, [][]string{{"ConfigMap/parent"}, {"ConfigMap/vendored"}, {"ConfigMap/database"}}, client.deleteCalls)
assert.Equal(t, client.deleteCalls, client.deleteWaitCalls)
}
// TestUninstall_Sequenced_PlanFailure_FallsBackUnsequenced locks the
// stuck-release fix: when the ordered plan cannot be rebuilt from the stored
// release (here: a resource-group cycle, which Build treats as fatal), the
// uninstall must warn and degrade to the standard unsequenced deletion so the
// release reaches status=uninstalled instead of stranding in uninstalling.
func TestUninstall_Sequenced_PlanFailure_FallsBackUnsequenced(t *testing.T) {
client := newRecordingKubeClient().withoutBuildClients()
logBuffer := &bytes.Buffer{}
cfg := actionConfigFixture(t)
cfg.SetLogger(slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
cfg.KubeClient = client
uninstall := NewUninstall(cfg)
uninstall.DisableHooks = true
uninstall.KeepHistory = true
uninstall.Timeout = 5 * time.Minute
uninstall.WaitStrategy = kube.OrderedWaitStrategy
manifest := joinManifestDocs(
configMapManifest("alpha", map[string]string{
releaseutil.AnnotationResourceGroup: "a",
releaseutil.AnnotationDependsOnResourceGroups: `["b"]`,
}),
configMapManifest("beta", map[string]string{
releaseutil.AnnotationResourceGroup: "b",
releaseutil.AnnotationDependsOnResourceGroups: `["a"]`,
}),
)
rel := newUninstallRelease(
"cyclic-uninstall",
buildChartWithTemplates(nil, withName("cyclic-uninstall")),
manifest,
nil,
)
rel.Sequenced = true
seedUninstallRelease(t, uninstall, rel)
_, err := uninstall.Run(rel.Name)
require.NoError(t, err)
assert.Contains(t, logBuffer.String(), "deleting resources without sequencing")
require.Len(t, client.deleteCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/alpha", "ConfigMap/beta"}, client.deleteCalls[0])
reli, err := uninstall.cfg.Releases.Get(rel.Name, rel.Version)
require.NoError(t, err)
stored, err := releaserToV1Release(reli)
require.NoError(t, err)
assert.Equal(t, rcommon.StatusUninstalled, stored.Info.Status)
}

@ -40,6 +40,7 @@ import (
ri "helm.sh/helm/v4/pkg/release"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/release/v1/sequence"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"helm.sh/helm/v4/pkg/storage/driver"
)
@ -70,6 +71,10 @@ type Upgrade struct {
SkipCRDs bool
// Timeout is the timeout for this operation
Timeout time.Duration
// ReadinessTimeout is the per-batch timeout when --wait=ordered is used.
// Each ordered batch waits at most this long for resources to become ready.
// Must not exceed Timeout. Defaults to 1 minute when zero.
ReadinessTimeout time.Duration
// WaitStrategy determines what type of waiting should be done
WaitStrategy kube.WaitStrategy
// WaitOptions are additional options for waiting on resources
@ -188,12 +193,16 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char
u.WaitStrategy = kube.StatusWatcherStrategy
}
if u.ReadinessTimeout > 0 && u.Timeout > 0 && u.ReadinessTimeout > u.Timeout {
return nil, fmt.Errorf("--readiness-timeout (%s) must not exceed --timeout (%s)", u.ReadinessTimeout, u.Timeout)
}
if err := chartutil.ValidateReleaseName(name); err != nil {
return nil, fmt.Errorf("release name is invalid: %s", name)
}
u.cfg.Logger().Debug("preparing upgrade", "name", name)
currentRelease, upgradedRelease, serverSideApply, err := u.prepareUpgrade(ctx, name, chrt, vals)
currentRelease, upgradedRelease, serverSideApply, sortedManifests, err := u.prepareUpgrade(ctx, name, chrt, vals)
if err != nil {
return nil, err
}
@ -201,7 +210,12 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char
u.cfg.Releases.MaxHistory = u.MaxHistory
u.cfg.Logger().Debug("performing update", "name", name)
res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease, serverSideApply)
var res *release.Release
if u.WaitStrategy == kube.OrderedWaitStrategy {
res, err = u.performSequencedUpgrade(ctx, chrt, currentRelease, upgradedRelease, sortedManifests, serverSideApply)
} else {
res, err = u.performUpgrade(ctx, currentRelease, upgradedRelease, serverSideApply)
}
if err != nil {
return res, err
}
@ -218,14 +232,16 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char
}
// prepareUpgrade builds an upgraded release for an upgrade operation.
func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv2.Chart, vals map[string]any) (*release.Release, *release.Release, bool, error) {
// When WaitStrategy is OrderedWaitStrategy, sortedManifests contains the parsed
// manifests with annotation data needed for DAG sequencing; otherwise it is nil.
func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv2.Chart, vals map[string]any) (*release.Release, *release.Release, bool, []releaseutil.Manifest, error) {
if chart == nil {
return nil, nil, false, errMissingChart
return nil, nil, false, nil, errMissingChart
}
// HideSecret must be used with dry run. Otherwise, return an error.
if !isDryRun(u.DryRunStrategy) && u.HideSecret {
return nil, nil, false, errors.New("hiding Kubernetes secrets requires a dry-run mode")
return nil, nil, false, nil, errors.New("hiding Kubernetes secrets requires a dry-run mode")
}
// finds the last non-deleted release with the given name
@ -233,19 +249,19 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv
if err != nil {
// to keep existing behavior of returning the "%q has no deployed releases" error when an existing release does not exist
if errors.Is(err, driver.ErrReleaseNotFound) {
return nil, nil, false, driver.NewErrNoDeployedReleases(name)
return nil, nil, false, nil, driver.NewErrNoDeployedReleases(name)
}
return nil, nil, false, err
return nil, nil, false, nil, err
}
lastRelease, err := releaserToV1Release(lastReleasei)
if err != nil {
return nil, nil, false, err
return nil, nil, false, nil, err
}
// Concurrent `helm upgrade`s will either fail here with `errPending` or when creating the release with "already exists". This should act as a pessimistic lock.
if lastRelease.Info.Status.IsPending() {
return nil, nil, false, errPending
return nil, nil, false, nil, errPending
}
var currentRelease *release.Release
@ -258,14 +274,14 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv
var cerr error
currentRelease, cerr = releaserToV1Release(currentReleasei)
if cerr != nil {
return nil, nil, false, cerr
return nil, nil, false, nil, cerr
}
if err != nil {
if errors.Is(err, driver.ErrNoDeployedReleases) &&
(lastRelease.Info.Status == rcommon.StatusFailed || lastRelease.Info.Status == rcommon.StatusSuperseded) {
currentRelease = lastRelease
} else {
return nil, nil, false, err
return nil, nil, false, nil, err
}
}
}
@ -273,11 +289,11 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv
// determine if values will be reused
vals, err = u.reuseValues(chart, currentRelease, vals)
if err != nil {
return nil, nil, false, err
return nil, nil, false, nil, err
}
if err := chartutil.ProcessDependencies(chart, vals); err != nil {
return nil, nil, false, err
return nil, nil, false, nil, err
}
// Increment revision count. This is passed to templates, and also stored on
@ -293,25 +309,33 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv
caps, err := u.cfg.getCapabilities()
if err != nil {
return nil, nil, false, err
return nil, nil, false, nil, err
}
valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, vals, options, caps, u.SkipSchemaValidation)
if err != nil {
return nil, nil, false, err
return nil, nil, false, nil, err
}
hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(ctx, chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret, u.PostRenderStrategy)
var sortedManifests []releaseutil.Manifest
var hooks []*release.Hook
var manifestDoc *bytes.Buffer
var notesTxt string
if u.WaitStrategy == kube.OrderedWaitStrategy {
hooks, manifestDoc, notesTxt, sortedManifests, err = u.cfg.renderResourcesWithFiles(ctx, chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret, u.PostRenderStrategy)
} else {
hooks, manifestDoc, notesTxt, err = u.cfg.renderResources(ctx, chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret, u.PostRenderStrategy)
}
if err != nil {
return nil, nil, false, err
return nil, nil, false, nil, err
}
if driver.ContainsSystemLabels(u.Labels) {
return nil, nil, false, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels())
return nil, nil, false, nil, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels())
}
serverSideApply, err := getUpgradeServerSideValue(u.ServerSideApply, lastRelease.ApplyMethod)
if err != nil {
return nil, nil, false, err
return nil, nil, false, nil, err
}
u.cfg.Logger().Debug("determined release apply method", slog.Bool("server_side_apply", serverSideApply), slog.String("previous_release_apply_method", lastRelease.ApplyMethod))
@ -333,13 +357,14 @@ func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv
Hooks: hooks,
Labels: mergeCustomLabels(lastRelease.Labels, u.Labels),
ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)),
Sequenced: u.WaitStrategy == kube.OrderedWaitStrategy,
}
if len(notesTxt) > 0 {
upgradedRelease.Info.Notes = notesTxt
}
err = validateManifest(u.cfg.KubeClient, manifestDoc.Bytes(), !u.DisableOpenAPIValidation)
return currentRelease, upgradedRelease, serverSideApply, err
return currentRelease, upgradedRelease, serverSideApply, sortedManifests, err
}
func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedRelease *release.Release, serverSideApply bool) (*release.Release, error) {
@ -414,7 +439,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
ctxChan := make(chan resultMessage)
doneChan := make(chan any)
defer close(doneChan)
go u.releasingUpgrade(rChan, upgradedRelease, current, target, originalRelease, serverSideApply)
go u.releasingUpgrade(ctx, rChan, upgradedRelease, current, target, originalRelease, serverSideApply)
go u.handleContext(ctx, doneChan, ctxChan, upgradedRelease)
select {
@ -428,10 +453,10 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
// Function used to lock the Mutex, this is important for the case when RollbackOnFailure is set.
// In that case the upgrade will finish before the rollback is finished so it is necessary to wait for the rollback to finish.
// The rollback will be trigger by the function failRelease
func (u *Upgrade) reportToPerformUpgrade(c chan<- resultMessage, rel *release.Release, created kube.ResourceList, err error) {
func (u *Upgrade) reportToPerformUpgrade(ctx context.Context, c chan<- resultMessage, rel *release.Release, created kube.ResourceList, err error) {
u.Lock.Lock()
if err != nil {
rel, err = u.failRelease(rel, created, err)
rel, err = u.failRelease(ctx, rel, created, err)
}
c <- resultMessage{r: rel, e: err}
u.Lock.Unlock()
@ -444,7 +469,7 @@ func (u *Upgrade) handleContext(ctx context.Context, done chan any, c chan<- res
err := ctx.Err()
// when RollbackOnFailure is set, the ongoing release finish first and doesn't give time for the rollback happens.
u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, err)
u.reportToPerformUpgrade(ctx, c, upgradedRelease, kube.ResourceList{}, err)
case <-done:
return
}
@ -454,18 +479,24 @@ func isReleaseApplyMethodClientSideApply(applyMethod string) bool {
return applyMethod == "" || applyMethod == string(release.ApplyMethodClientSideApply)
}
func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release, serverSideApply bool) {
func (u *Upgrade) releasingUpgrade(ctx context.Context, c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release, serverSideApply bool) {
// pre-upgrade hooks
if !u.DisableHooks {
if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil {
u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %w", err))
u.reportToPerformUpgrade(ctx, c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %w", err))
return
}
} else {
u.cfg.Logger().Debug("upgrade hooks disabled", "name", upgradedRelease.Name)
}
// Strip Helm-internal sequencing annotations before applying to K8s.
if err := stripSequencingAnnotations(target); err != nil {
u.reportToPerformUpgrade(ctx, c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("stripping sequencing annotations: %w", err))
return
}
upgradeClientSideFieldManager := isReleaseApplyMethodClientSideApply(originalRelease.ApplyMethod) && serverSideApply // Update client-side field manager if transitioning from client-side to server-side apply
results, err := u.cfg.KubeClient.Update(
current,
@ -475,7 +506,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
kube.ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManager))
if err != nil {
u.cfg.recordRelease(originalRelease)
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, err)
return
}
@ -487,19 +518,19 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
}
if err != nil {
u.cfg.recordRelease(originalRelease)
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, err)
return
}
if u.WaitForJobs {
if err := waiter.WaitWithJobs(target, u.Timeout); err != nil {
u.cfg.recordRelease(originalRelease)
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, err)
return
}
} else {
if err := waiter.Wait(target, u.Timeout); err != nil {
u.cfg.recordRelease(originalRelease)
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, err)
return
}
}
@ -507,7 +538,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
// post-upgrade hooks
if !u.DisableHooks {
if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil {
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %w", err))
u.reportToPerformUpgrade(ctx, c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %w", err))
return
}
}
@ -521,10 +552,175 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
} else {
upgradedRelease.Info.Description = "Upgrade complete"
}
u.reportToPerformUpgrade(c, upgradedRelease, nil, nil)
u.reportToPerformUpgrade(ctx, c, upgradedRelease, nil, nil)
}
// performSequencedUpgrade deploys chart resources in DAG-ordered batches when
// --wait=ordered is used. It mirrors releasingUpgrade but uses sequencedDeployment
// to apply each batch and wait for readiness before proceeding to the next batch.
func (u *Upgrade) performSequencedUpgrade(ctx context.Context, chrt *chartv2.Chart, currentRelease, upgradedRelease *release.Release, manifests []releaseutil.Manifest, serverSideApply bool) (*release.Release, error) {
// Build the full set of current (old) resources for diff matching.
current, err := u.cfg.KubeClient.Build(bytes.NewBufferString(currentRelease.Manifest), false)
if err != nil {
if strings.Contains(err.Error(), "unable to recognize \"\": no matches for kind") {
return upgradedRelease, fmt.Errorf("current release manifest contains removed kubernetes api(s) for this "+
"kubernetes version and it is therefore unable to build the kubernetes "+
"objects for performing the diff. error from kubernetes: %w", err)
}
return upgradedRelease, fmt.Errorf("unable to build kubernetes objects from current release manifest: %w", err)
}
// Build target to compute the set of resources to delete (those removed in the new release).
target, err := u.cfg.KubeClient.Build(bytes.NewBufferString(upgradedRelease.Manifest), !u.DisableOpenAPIValidation)
if err != nil {
return upgradedRelease, fmt.Errorf("unable to build kubernetes objects from new release manifest: %w", err)
}
// It is safe to use force only on target because these are resources currently rendered by the chart.
if err := target.Visit(setMetadataVisitor(upgradedRelease.Name, upgradedRelease.Namespace, true)); err != nil {
return upgradedRelease, err
}
// Do a basic diff using gvk + name to figure out what new resources are being
// created so we can validate they don't already exist (or adopt them).
existingResources := make(map[string]bool, len(current))
for _, r := range current {
existingResources[objectKey(r)] = true
}
var toBeCreated kube.ResourceList
for _, r := range target {
if !existingResources[objectKey(r)] {
toBeCreated = append(toBeCreated, r)
}
}
var toBeUpdated kube.ResourceList
if u.TakeOwnership {
toBeUpdated, err = requireAdoption(toBeCreated)
} else {
toBeUpdated, err = existingResourceConflict(toBeCreated, upgradedRelease.Name, upgradedRelease.Namespace)
}
if err != nil {
return nil, fmt.Errorf("unable to continue with update: %w", err)
}
// Adopted resources join current so their plan batches update them in place.
toBeUpdated.Visit(func(r *resource.Info, err error) error {
if err != nil {
return err
}
current.Append(r)
return nil
})
if isDryRun(u.DryRunStrategy) {
u.cfg.Logger().Debug("dry run for release", "name", upgradedRelease.Name)
if len(u.Description) > 0 {
upgradedRelease.Info.Description = u.Description
} else {
upgradedRelease.Info.Description = "Dry run complete"
}
return upgradedRelease, nil
}
plan, err := sequence.Build(chrt, manifests)
if err != nil {
return upgradedRelease, fmt.Errorf("building sequencing plan: %w", err)
}
u.cfg.Logger().Debug("creating upgraded release", "name", upgradedRelease.Name)
if err := u.cfg.Releases.Create(upgradedRelease); err != nil {
return nil, err
}
// pre-upgrade hooks
if !u.DisableHooks {
if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil {
return u.failRelease(ctx, upgradedRelease, nil, fmt.Errorf("pre-upgrade hooks failed: %w", err))
}
} else {
u.cfg.Logger().Debug("upgrade hooks disabled", "name", upgradedRelease.Name)
}
upgradeCSAFieldManager := isReleaseApplyMethodClientSideApply(currentRelease.ApplyMethod) && serverSideApply
readinessTimeout := u.ReadinessTimeout
if readinessTimeout <= 0 {
readinessTimeout = time.Minute
}
sd := &sequencedDeployment{
cfg: u.cfg,
releaseName: upgradedRelease.Name,
releaseNamespace: upgradedRelease.Namespace,
disableOpenAPI: u.DisableOpenAPIValidation,
serverSideApply: serverSideApply,
forceConflicts: u.ForceConflicts,
forceReplace: u.ForceReplace,
waitStrategy: u.WaitStrategy,
waitOptions: u.WaitOptions,
waitForJobs: u.WaitForJobs,
timeout: u.Timeout,
readinessTimeout: readinessTimeout,
deadline: computeDeadline(u.Timeout),
upgradeMode: true,
currentResources: current,
upgradeCSAFieldManager: upgradeCSAFieldManager,
}
logPlanWarnings(u.cfg.Logger(), plan)
if err := sd.apply(ctx, plan); err != nil {
return u.failRelease(ctx, upgradedRelease, sd.createdResources, err)
}
// Delete resources present in the old release but absent from the new one,
// in exact reverse deployment order of the old release's plan (7yi).
allNewKeys := make(map[string]bool, len(target))
for _, r := range target {
allNewKeys[objectKey(r)] = true
}
removedKeys := make(map[string]bool)
var toBeDeleted kube.ResourceList
for _, r := range current {
if !allNewKeys[objectKey(r)] {
removedKeys[objectKey(r)] = true
toBeDeleted = append(toBeDeleted, r)
}
}
if len(toBeDeleted) > 0 {
if err := u.deleteRemovedSequenced(currentRelease, sd, removedKeys, toBeDeleted); err != nil {
return u.failRelease(ctx, upgradedRelease, sd.createdResources, err)
}
}
// post-upgrade hooks
if !u.DisableHooks {
if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil {
return u.failRelease(ctx, upgradedRelease, sd.createdResources, fmt.Errorf("post-upgrade hooks failed: %w", err))
}
}
currentRelease.Info.Status = rcommon.StatusSuperseded
u.cfg.recordRelease(currentRelease)
upgradedRelease.Info.Status = rcommon.StatusDeployed
if len(u.Description) > 0 {
upgradedRelease.Info.Description = u.Description
} else {
upgradedRelease.Info.Description = "Upgrade complete"
}
u.cfg.recordRelease(upgradedRelease)
return upgradedRelease, nil
}
func (u *Upgrade) deleteRemovedSequenced(currentRelease *release.Release, sd *sequencedDeployment, removedKeys map[string]bool, toBeDeleted kube.ResourceList) error {
waiter, err := getWaiterFor(u.cfg.KubeClient, u.WaitStrategy, u.WaitOptions...)
if err != nil {
return fmt.Errorf("getting waiter for removed-resource deletion: %w", err)
}
return sd.deleteRemovedFromOldRelease(currentRelease, removedKeys, toBeDeleted, waiter)
}
func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, err error) (*release.Release, error) {
func (u *Upgrade) failRelease(ctx context.Context, rel *release.Release, created kube.ResourceList, err error) (*release.Release, error) {
msg := fmt.Sprintf("Upgrade %q failed: %s", rel.Name, err)
u.cfg.Logger().Warn(
"upgrade failed",
@ -588,7 +784,7 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e
rollin.ForceConflicts = u.ForceConflicts
rollin.ServerSideApply = u.ServerSideApply
rollin.Timeout = u.Timeout
if rollErr := rollin.Run(rel.Name); rollErr != nil {
if rollErr := rollin.RunWithContext(ctx, rel.Name); rollErr != nil {
return rel, fmt.Errorf("an error occurred while rolling back the release. original upgrade error: %w: %w", err, rollErr)
}
return rel, fmt.Errorf("release %s failed, and has been rolled back due to rollback-on-failure being set: %w", rel.Name, err)

@ -0,0 +1,559 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/kube"
ri "helm.sh/helm/v4/pkg/release"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
func newSequencedUpgradeAction(t *testing.T, kubeClient kube.Interface) *Upgrade {
t.Helper()
cfg := actionConfigFixture(t)
cfg.KubeClient = kubeClient
upgrade := NewUpgrade(cfg)
upgrade.Namespace = "spaced"
upgrade.Timeout = 5 * time.Minute
upgrade.ReadinessTimeout = time.Minute
upgrade.WaitStrategy = kube.OrderedWaitStrategy
return upgrade
}
func seedDeployedRelease(t *testing.T, upgrade *Upgrade, name string, ch *chart.Chart, manifest string, sequencingInfo *release.SequencingInfo) {
t.Helper()
now := time.Now()
rel := &release.Release{
Name: name,
Namespace: "spaced",
Chart: ch,
Config: map[string]any{},
Manifest: manifest,
Info: &release.Info{
FirstDeployed: now,
LastDeployed: now,
Status: rcommon.StatusDeployed,
Description: "Seeded release",
},
Version: 1,
SequencingInfo: sequencingInfo,
}
require.NoError(t, upgrade.cfg.Releases.Create(rel))
}
func joinManifestDocs(docs ...string) string {
return strings.Join(docs, "\n---\n")
}
func updateTargets(calls []updateCall) [][]string {
targets := make([][]string, 0, len(calls))
for _, call := range calls {
targets = append(targets, call.target)
}
return targets
}
func TestUpgrade_Sequenced_Basic(t *testing.T) {
client := newRecordingKubeClient()
upgrade := newSequencedUpgradeAction(t, client)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("sequenced-upgrade"))
seedDeployedRelease(t, upgrade, "sequenced-upgrade", currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), nil)
upgradedChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("sequenced-upgrade"))
rel := mustRelease(t, mustRunUpgrade(t, upgrade, "sequenced-upgrade", upgradedChart))
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls)
require.Len(t, client.updateCalls, 2)
assert.Equal(t, []string{"ConfigMap/database"}, client.updateCalls[0].current)
assert.Equal(t, []string{"ConfigMap/app"}, client.updateCalls[1].current)
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
assert.True(t, rel.Sequenced)
assert.True(t, rel.IsSequenced())
assert.Nil(t, rel.SequencingInfo)
}
func TestUpgrade_Sequenced_NewResourceGroup(t *testing.T) {
client := newRecordingKubeClient()
upgrade := newSequencedUpgradeAction(t, client)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("new-group"))
seedDeployedRelease(t, upgrade, "new-group", currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), nil)
upgradedChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
}, withName("new-group"))
mustRunUpgrade(t, upgrade, "new-group", upgradedChart)
require.Len(t, client.updateCalls, 3)
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
assert.Empty(t, client.updateCalls[1].current)
assert.Equal(t, []string{"ConfigMap/queue"}, client.updateCalls[1].created)
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/queue"}, {"ConfigMap/app"}}, client.waitCalls)
}
func TestUpgrade_Sequenced_RemovedResourceGroup(t *testing.T) {
client := newRecordingKubeClient()
upgrade := newSequencedUpgradeAction(t, client)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
}, withName("removed-group"))
seedDeployedRelease(t, upgrade, "removed-group", currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
), nil)
upgradedChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("removed-group"))
mustRunUpgrade(t, upgrade, "removed-group", upgradedChart)
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls)
assert.Equal(t, []string{
"update:ConfigMap/database",
"wait:ConfigMap/database",
"update:ConfigMap/app",
"wait:ConfigMap/app",
"delete:ConfigMap/queue",
"wait-delete:ConfigMap/queue",
}, client.operations)
}
// TestUpgrade_Sequenced_NewResourceConflictAndAdoption locks bead 9ui
// (upgrade): a sequenced upgrade must run the same existing-resource
// conflict / adoption step over newly added resources as the default path
// (performUpgrade) does.
func TestUpgrade_Sequenced_NewResourceConflictAndAdoption(t *testing.T) {
currentChartFiles := []*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
}
currentManifest := configMapManifest("database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
})
upgradedChartFiles := []*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}
t.Run("unowned existing resource conflicts before anything is applied", func(t *testing.T) {
client := newRecordingKubeClient()
// queue already exists in the cluster, owned by another release.
client.buildRESTClient = statefulOwnedRESTClient(client, "some-other-release", "spaced")
client.createdObjects["ConfigMap/queue"] = true
upgrade := newSequencedUpgradeAction(t, client)
seedDeployedRelease(t, upgrade, "adopt-upgrade", buildChartWithTemplates(currentChartFiles, withName("adopt-upgrade")), currentManifest, nil)
_, err := upgrade.Run("adopt-upgrade", buildChartWithTemplates(upgradedChartFiles, withName("adopt-upgrade")), map[string]any{})
require.Error(t, err)
assert.Contains(t, err.Error(), "unable to continue with update")
assert.Empty(t, client.updateCalls, "no batch may be applied after a conflict")
})
t.Run("take-ownership adopts the existing resource in its batch", func(t *testing.T) {
client := newRecordingKubeClient()
client.buildRESTClient = statefulOwnedRESTClient(client, "some-other-release", "spaced")
client.createdObjects["ConfigMap/queue"] = true
upgrade := newSequencedUpgradeAction(t, client)
upgrade.TakeOwnership = true
seedDeployedRelease(t, upgrade, "adopt-upgrade-to", buildChartWithTemplates(currentChartFiles, withName("adopt-upgrade-to")), currentManifest, nil)
mustRunUpgrade(t, upgrade, "adopt-upgrade-to", buildChartWithTemplates(upgradedChartFiles, withName("adopt-upgrade-to")))
require.Len(t, client.updateCalls, 2)
assert.Equal(t, []string{"ConfigMap/queue"}, client.updateCalls[1].current,
"pre-existing queue must be adopted (updated against a current object), not treated as new")
assert.Empty(t, client.updateCalls[1].created)
})
}
// TestUpgrade_Sequenced_RemovedGroupsDeletedInReverseOrder locks bead 7yi
// (upgrade): resources removed by an upgrade are deleted in exact reverse
// deployment order of the OLD release's plan, with a delete-wait gating each
// batch, not as one unordered bulk delete.
func TestUpgrade_Sequenced_RemovedGroupsDeletedInReverseOrder(t *testing.T) {
client := newRecordingKubeClient()
upgrade := newSequencedUpgradeAction(t, client)
group := func(name string, deps string) map[string]string {
ann := map[string]string{releaseutil.AnnotationResourceGroup: name}
if deps != "" {
ann[releaseutil.AnnotationDependsOnResourceGroups] = deps
}
return ann
}
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", group("database", "")),
makeConfigMapTemplate("templates/queue.yaml", "queue", group("queue", `["database"]`)),
makeConfigMapTemplate("templates/worker.yaml", "worker", group("worker", `["queue"]`)),
makeConfigMapTemplate("templates/app.yaml", "app", group("app", `["worker"]`)),
}, withName("reverse-removed"))
seedDeployedRelease(t, upgrade, "reverse-removed", currentChart, joinManifestDocs(
configMapManifest("database", group("database", "")),
configMapManifest("queue", group("queue", `["database"]`)),
configMapManifest("worker", group("worker", `["queue"]`)),
configMapManifest("app", group("app", `["worker"]`)),
), nil)
upgradedChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", group("database", "")),
makeConfigMapTemplate("templates/app.yaml", "app", group("app", `["database"]`)),
}, withName("reverse-removed"))
mustRunUpgrade(t, upgrade, "reverse-removed", upgradedChart)
// Old plan order: database, queue, worker, app. Reverse: app, worker,
// queue, database. Removed set: {queue, worker} -> worker deleted (and
// delete-waited) strictly before queue.
assert.Equal(t, [][]string{{"ConfigMap/worker"}, {"ConfigMap/queue"}}, client.deleteCalls)
assert.Equal(t, [][]string{{"ConfigMap/worker"}, {"ConfigMap/queue"}}, client.deleteWaitCalls)
}
func TestUpgrade_NonSequencedToSequenced(t *testing.T) {
client := newRecordingKubeClient()
upgrade := newSequencedUpgradeAction(t, client)
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("transition-to-sequenced"))
seedDeployedRelease(t, upgrade, "transition-to-sequenced", currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), nil)
rel := mustRelease(t, mustRunUpgrade(t, upgrade, "transition-to-sequenced", currentChart))
require.Len(t, client.updateCalls, 2)
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, updateTargets(client.updateCalls))
assert.True(t, rel.Sequenced)
assert.True(t, rel.IsSequenced())
}
func TestUpgrade_SequencedToNonSequenced(t *testing.T) {
client := newRecordingKubeClient()
upgrade := newSequencedUpgradeAction(t, client)
upgrade.WaitStrategy = kube.StatusWatcherStrategy
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("transition-to-standard"))
seedDeployedRelease(t, upgrade, "transition-to-standard", currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), &release.SequencingInfo{Enabled: true, Strategy: string(kube.OrderedWaitStrategy)})
rel := mustRelease(t, mustRunUpgrade(t, upgrade, "transition-to-standard", currentChart))
require.Len(t, client.updateCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.updateCalls[0].target)
require.Len(t, client.waitCalls, 1)
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.waitCalls[0])
assert.Nil(t, rel.SequencingInfo)
}
func TestUpgrade_Sequenced_FailureRollback(t *testing.T) {
client := newRecordingKubeClient()
client.waitErrorOnCall = 2
client.waitError = assert.AnError
upgrade := newSequencedUpgradeAction(t, client)
upgrade.RollbackOnFailure = true
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("failure-rollback"))
seedDeployedRelease(t, upgrade, "failure-rollback", currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), nil)
upgradedChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
}, withName("failure-rollback"))
rel, err := upgrade.Run("failure-rollback", upgradedChart, map[string]any{})
require.Error(t, err)
assert.Contains(t, err.Error(), "rolled back due to rollback-on-failure")
failed := mustRelease(t, rel)
assert.Equal(t, rcommon.StatusFailed, failed.Info.Status)
last, getErr := upgrade.cfg.Releases.Last("failure-rollback")
require.NoError(t, getErr)
assert.Equal(t, rcommon.StatusDeployed, mustRelease(t, last).Info.Status)
}
func TestUpgrade_Sequenced_SubchartAdded(t *testing.T) {
client := newRecordingKubeClient()
upgrade := newSequencedUpgradeAction(t, client)
currentParent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
currentDatabase := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
}, withName("database"))
currentParent.AddDependency(currentDatabase)
currentParent.Metadata.Dependencies = []*chart.Dependency{
{Name: "database"},
}
seedDeployedRelease(t, upgrade, "subchart-added", currentParent, joinManifestDocs(
configMapManifest("database", nil),
configMapManifest("parent", nil),
), nil)
upgradedParent := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
}, withName("parent"))
upgradedDatabase := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", nil),
}, withName("database"))
upgradedAPI := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/api.yaml", "api", nil),
}, withName("api"))
upgradedParent.AddDependency(upgradedDatabase, upgradedAPI)
upgradedParent.Metadata.Dependencies = []*chart.Dependency{
{Name: "database"},
{Name: "api", DependsOn: []string{"database"}},
}
mustRunUpgrade(t, upgrade, "subchart-added", upgradedParent)
require.Len(t, client.updateCalls, 3)
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/api"}, {"ConfigMap/parent"}}, updateTargets(client.updateCalls))
assert.Empty(t, client.updateCalls[1].current)
assert.Equal(t, []string{"ConfigMap/api"}, client.updateCalls[1].created)
}
func TestUpgrade_Sequenced_DryRun(t *testing.T) {
client := newRecordingKubeClient()
upgrade := newSequencedUpgradeAction(t, client)
upgrade.DryRunStrategy = DryRunClient
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("dry-run"))
seedDeployedRelease(t, upgrade, "dry-run", currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), nil)
rel := mustRelease(t, mustRunUpgrade(t, upgrade, "dry-run", currentChart))
assert.Empty(t, client.updateCalls)
assert.Empty(t, client.waitCalls)
assert.Empty(t, client.deleteCalls)
assert.Equal(t, "Dry run complete", rel.Info.Description)
}
func TestUpgrade_Sequenced_CleanupOnFail(t *testing.T) {
client := newRecordingKubeClient()
client.waitErrorOnCall = 2
client.waitError = errors.New("timed out waiting for batch")
upgrade := newSequencedUpgradeAction(t, client)
upgrade.CleanupOnFail = true
currentChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
}, withName("cleanup-on-fail"))
seedDeployedRelease(t, upgrade, "cleanup-on-fail", currentChart, joinManifestDocs(
configMapManifest("database", map[string]string{releaseutil.AnnotationResourceGroup: "database"}),
configMapManifest("app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
), nil)
upgradedChart := buildChartWithTemplates([]*common.File{
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/queue.yaml", "queue", map[string]string{
releaseutil.AnnotationResourceGroup: "queue",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["queue"]`,
}),
}, withName("cleanup-on-fail"))
rel, err := upgrade.Run("cleanup-on-fail", upgradedChart, map[string]any{})
require.Error(t, err)
assert.Contains(t, err.Error(), "timed out waiting for batch")
assert.Equal(t, [][]string{{"ConfigMap/queue"}}, client.deleteCalls)
assert.Equal(t, rcommon.StatusFailed, mustRelease(t, rel).Info.Status)
}
func mustRunUpgrade(t *testing.T, upgrade *Upgrade, name string, ch *chart.Chart) ri.Releaser {
t.Helper()
rel, err := upgrade.Run(name, ch, map[string]any{})
require.NoError(t, err)
return rel
}

@ -82,6 +82,13 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN
return err
}
if info.Client == nil {
// Fail fast: without a REST client we cannot verify whether the
// resource already exists or is owned by another release, and
// assuming "no conflict" would skip adoption/ownership checks.
return fmt.Errorf("unable to verify whether resource %s already exists: no REST client available", resourceString(info))
}
helper := resource.NewHelper(info.Client, info.Mapping)
existing, err := helper.Get(info.Namespace, info.Name)
if err != nil {
@ -130,7 +137,8 @@ func verifyOwnershipBeforeDelete(resources kube.ResourceList, releaseName, relea
return err
}
// If client is not available, skip verification (test scenario or build failure)
// If client is not available, skip verification (test scenario or build failure).
// Deliberately unchanged by HIP-0025 Stage C: shared with the non-sequenced path; tightening it is tracked separately.
if info.Client == nil {
infoCopy := *info
owned.Append(&infoCopy)

@ -166,6 +166,18 @@ func TestExistingResourceConflict(t *testing.T) {
assert.Error(t, err)
}
// Regression for PR #32038 review (validate.go nil-client thread): a missing
// REST client must fail fast rather than silently skipping the conflict and
// adoption checks.
func TestExistingResourceConflictNilClientFailsFast(t *testing.T) {
info := newMissingDeployment("clientless", "ns-a")
info.Client = nil
_, err := existingResourceConflict(kube.ResourceList{info}, "rel-name", "rel-namespace")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no REST client available")
}
func TestCheckOwnership(t *testing.T) {
deployFoo := newDeploymentResource("foo", "ns-a", "")

@ -0,0 +1,109 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"bytes"
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/kube"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
func captureWarningOutput(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
oldLogger := slog.Default()
slog.SetDefault(slog.New(handler))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
return &buf
}
func runSequencedInstallWithWarnings(t *testing.T, templates ...*common.File) string {
t.Helper()
client := newRecordingKubeClient()
install := newSequencedInstallAction(t, client)
var buf bytes.Buffer
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
install.cfg.SetLogger(handler)
mustRunInstall(t, install, buildChartWithTemplates(templates))
return buf.String()
}
func TestWarning_PartialReadinessAnnotations(t *testing.T) {
output := runSequencedInstallWithWarnings(t,
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
kube.AnnotationReadinessSuccess: `["{.ready} == true"]`,
}),
)
assert.Contains(t, output, "sequencing: resource \\\"app\\\" has only one of")
assert.Contains(t, output, releaseutil.AnnotationReadinessSuccess)
assert.Contains(t, output, releaseutil.AnnotationReadinessFailure)
}
func TestWarning_IsolatedResourceGroups(t *testing.T) {
output := runSequencedInstallWithWarnings(t,
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
}),
makeConfigMapTemplate("templates/cache.yaml", "cache", map[string]string{
releaseutil.AnnotationResourceGroup: "cache",
}),
)
assert.Contains(t, output, "resource-group \\\"database\\\" is isolated")
assert.Contains(t, output, "resource-group \\\"cache\\\" is isolated")
}
func TestWarning_NonExistentGroupReference(t *testing.T) {
output := runSequencedInstallWithWarnings(t,
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["missing"]`,
}),
)
assert.Contains(t, output, "sequencing: group \\\"app\\\" depends-on non-existent group \\\"missing\\\"")
}
func TestWarning_WellFormedSequencing(t *testing.T) {
output := runSequencedInstallWithWarnings(t,
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
releaseutil.AnnotationResourceGroup: "database",
kube.AnnotationReadinessSuccess: `["{.ready} == true"]`,
kube.AnnotationReadinessFailure: `["{.failed} == true"]`,
}),
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
}),
)
assert.Empty(t, output)
}

@ -47,6 +47,9 @@ type Dependency struct {
ImportValues []any `json:"import-values,omitempty" yaml:"import-values,omitempty"`
// Alias usable alias to be used for the chart
Alias string `json:"alias,omitempty" yaml:"alias,omitempty"`
// DependsOn is a list of subchart names or aliases that must be deployed
// before this subchart.
DependsOn []string `json:"depends-on,omitempty" yaml:"depends-on,omitempty"`
}
// Validate checks for common problems with the dependency datastructure in
@ -63,6 +66,9 @@ func (d *Dependency) Validate() error {
for i := range d.Tags {
d.Tags[i] = sanitizeString(d.Tags[i])
}
for i := range d.DependsOn {
d.DependsOn[i] = sanitizeString(d.DependsOn[i])
}
if d.Alias != "" && !aliasNameFormat.MatchString(d.Alias) {
return ValidationErrorf("dependency %q has disallowed characters in the alias", d.Name)
}

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

@ -42,3 +42,22 @@ func TestValidateDependency(t *testing.T) {
}
}
}
func TestValidateDependency_SanitizesDependsOn(t *testing.T) {
dep := &Dependency{
Name: "example",
DependsOn: []string{"data\abase", "cache\rqueue"},
}
if err := dep.Validate(); err != nil {
t.Fatalf("unexpected error: %s", err)
}
// Sanitized like Name/Tags/Condition: non-printable runes are dropped and
// whitespace runes are normalized to spaces, so stray characters don't break
// subchart depends-on lookups.
if dep.DependsOn[0] != "database" {
t.Errorf("DependsOn[0] not sanitized: got %q, want %q", dep.DependsOn[0], "database")
}
if dep.DependsOn[1] != "cache queue" {
t.Errorf("DependsOn[1] not sanitized: got %q, want %q", dep.DependsOn[1], "cache queue")
}
}

@ -65,6 +65,7 @@ func RunAll(baseDir string, values map[string]any, namespace string, options ...
rules.TemplateLinterSkipSchemaValidation(lo.SkipSchemaValidation))
rules.Dependencies(&result)
rules.Crds(&result)
rules.Sequencing(&result, namespace, values)
return result
}

@ -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,219 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package rules // import "helm.sh/helm/v4/pkg/chart/v2/lint/rules"
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"strings"
"sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/chart/common"
commonutil "helm.sh/helm/v4/pkg/chart/common/util"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/chart/v2/lint/support"
"helm.sh/helm/v4/pkg/chart/v2/loader"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/engine"
"helm.sh/helm/v4/pkg/kube"
release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/release/v1/sequence"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
// Sequencing runs lint rules for HIP-0025 sequencing annotations.
func Sequencing(linter *support.Linter, namespace string, values map[string]any) {
c, err := loader.LoadDir(linter.ChartDir)
if err != nil {
return // chart load errors are reported by other lint rules
}
if err := chartutil.ProcessDependencies(c, values); err != nil {
linter.RunLinterRule(support.ErrorSev, linter.ChartDir, err)
return
}
// Render failures are reported by the Templates rule; manifests stays nil
// so Build still validates the top-level subchart DAG (preserving the old
// pre-render validateSubchartSequencing coverage for broken-template charts).
manifests := collectRenderedManifests(linter, c, namespace, values)
plan, err := sequence.Build(c, manifests)
if err != nil {
// Build's fatal classes are exactly what fails at install time:
// subchart/resource-group cycles, unknown depends-on refs, malformed
// helm.sh/depends-on/subcharts, multi-group assignment.
linter.RunLinterRule(support.ErrorSev, linter.ChartDir, err)
return
}
for _, w := range plan.Warnings {
path := w.ChartPath
if path == "" {
path = linter.ChartDir
}
switch w.Kind {
case sequence.WarningKindResourceGroupDemotion:
// Runtime recovers by demoting; the chart author must fix these.
linter.RunLinterRule(support.ErrorSev, path, errors.New(w.Message))
case sequence.WarningKindPartialReadiness:
// Already reported per-template (with better context) by
// validateReadinessAnnotations during collection.
default: // isolated group, undeclared/unresolved subchart
linter.RunLinterRule(support.WarningSev, path, errors.New(w.Message))
}
}
}
func collectRenderedManifests(linter *support.Linter, c *chart.Chart, namespace string, values map[string]any) []releaseutil.Manifest {
options := common.ReleaseOptions{
Name: "test-release",
Namespace: namespace,
}
caps := common.DefaultCapabilities.Copy()
coalescedValues, err := commonutil.CoalesceValues(c, values)
if err != nil {
return nil
}
valuesToRender, err := commonutil.ToRenderValues(c, coalescedValues, options, caps)
if err != nil {
return nil
}
var renderEngine engine.Engine
renderEngine.LintMode = true
renderedContentMap, err := renderEngine.RenderWithContext(context.Background(), c, valuesToRender)
if err != nil {
// Template rendering errors are already reported by the Templates lint rule.
return nil
}
var manifests []releaseutil.Manifest
for _, templatePath := range slices.Sorted(maps.Keys(renderedContentMap)) {
content := renderedContentMap[templatePath]
if strings.TrimSpace(content) == "" {
continue
}
for _, manifest := range parseRenderedManifests(templatePath, content) {
// HIP-0025 explicitly excludes hooks from sequencing: at install
// time SortManifests routes hook resources out before resource-group
// parsing runs, so their sequencing annotations are ignored. Mirror
// that here — otherwise lint reads a hook's leftover
// resource-group/depends-on annotations into the DAG and can report
// spurious cycles or orphan errors that never occur at runtime.
if isHookManifest(manifest) {
continue
}
validateReadinessAnnotations(linter, templatePath, manifest)
manifests = append(manifests, manifest)
}
}
return manifests
}
// isHookManifest reports whether a rendered manifest is a Helm hook. Hooks
// carry the helm.sh/hook annotation and are excluded from HIP-0025 sequencing
// (the install path separates them via SortManifests), so the sequencing lint
// rules must skip them too.
func isHookManifest(manifest releaseutil.Manifest) bool {
if manifest.Head == nil || manifest.Head.Metadata == nil {
return false
}
return strings.TrimSpace(manifest.Head.Metadata.Annotations[release.HookAnnotation]) != ""
}
func parseRenderedManifests(templatePath, content string) []releaseutil.Manifest {
rawManifests := releaseutil.SplitManifests(content)
manifests := make([]releaseutil.Manifest, 0, len(rawManifests))
for _, manifestName := range slices.Sorted(maps.Keys(rawManifests)) {
raw := rawManifests[manifestName]
if strings.TrimSpace(raw) == "" {
continue
}
var head releaseutil.SimpleHead
if err := yaml.Unmarshal([]byte(raw), &head); err != nil {
continue
}
manifests = append(manifests, releaseutil.Manifest{
Name: templatePath,
Content: raw,
Head: &head,
})
}
return manifests
}
func validateReadinessAnnotations(linter *support.Linter, templatePath string, manifest releaseutil.Manifest) {
if manifest.Head == nil || manifest.Head.Metadata == nil {
return
}
annotations := manifest.Head.Metadata.Annotations
successRaw := strings.TrimSpace(annotations[kube.AnnotationReadinessSuccess])
failureRaw := strings.TrimSpace(annotations[kube.AnnotationReadinessFailure])
hasSuccess := successRaw != ""
hasFailure := failureRaw != ""
if hasSuccess != hasFailure {
linter.RunLinterRule(support.ErrorSev, templatePath, fmt.Errorf(
"resource %q has only one of %q / %q annotations; both must be present or absent together",
resourceDisplayName(manifest),
kube.AnnotationReadinessSuccess,
kube.AnnotationReadinessFailure,
))
return
}
// Both present (or both absent). When present, the JSONPath expressions
// must still be well-formed — the presence-symmetry check alone let
// malformed expressions through at lint time.
for key, raw := range map[string]string{
kube.AnnotationReadinessSuccess: successRaw,
kube.AnnotationReadinessFailure: failureRaw,
} {
if err := kube.ValidateReadinessExpressions(raw); err != nil {
linter.RunLinterRule(support.ErrorSev, templatePath, fmt.Errorf(
"resource %q has malformed %q annotation: %w",
resourceDisplayName(manifest), key, err,
))
}
}
}
func resourceDisplayName(manifest releaseutil.Manifest) string {
if manifest.Head == nil || manifest.Head.Metadata == nil {
return manifest.Name
}
if manifest.Head.Kind == "" {
return manifest.Head.Metadata.Name
}
return fmt.Sprintf("%s/%s", manifest.Head.Kind, manifest.Head.Metadata.Name)
}

@ -0,0 +1,449 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package rules
import (
"fmt"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/chart/v2/lint/support"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
func TestSequencing_SubchartCircularDependency(t *testing.T) {
t.Parallel()
root := newChart("testchart", nil)
root.Metadata.Dependencies = []*chart.Dependency{
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", DependsOn: []string{"subchart-b"}},
{Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}},
}
root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil))
messages := runSequencingLint(t, root)
requireMessage(t, messages, support.ErrorSev, "subchart circular dependency detected")
}
func TestSequencing_AliasedSubchartDependsOnOriginalName(t *testing.T) {
t.Parallel()
// Full loader + ProcessDependencies pipeline: an aliased subchart may be
// referenced by its original chart name (HIP-0025: "names or aliases").
// Before the fix this reported a spurious "unknown or disabled subchart".
root := newChart("testchart", nil)
root.Metadata.Dependencies = []*chart.Dependency{
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "aliased-a"},
{Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}},
}
root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil))
messages := runSequencingLint(t, root)
assert.Empty(t, messages)
}
func TestSequencing_AmbiguousDependsOnReported(t *testing.T) {
t.Parallel()
// Ambiguity is now rejected by ProcessDependencies; the lint rule must
// surface that instead of silently skipping sequencing validation.
root := newChart("testchart", nil)
root.Metadata.Dependencies = []*chart.Dependency{
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "first"},
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a", Alias: "second"},
{Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b", DependsOn: []string{"subchart-a"}},
}
root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil))
messages := runSequencingLint(t, root)
requireMessage(t, messages, support.ErrorSev, `ambiguous subchart reference "subchart-a"`)
}
func TestSequencing_SubchartAnnotationRequiresHIPListSyntax(t *testing.T) {
t.Parallel()
root := newChart("testchart", nil)
root.Metadata.Dependencies = []*chart.Dependency{
{Name: "subchart-a", Version: "0.1.0", Repository: "file://charts/subchart-a"},
{Name: "subchart-b", Version: "0.1.0", Repository: "file://charts/subchart-b"},
}
root.Metadata.Annotations = map[string]string{
chartutil.AnnotationDependsOnSubcharts: `{"subchart-a":["subchart-b"],"subchart-b":["subchart-a"]}`,
}
root.SetDependencies(newChart("subchart-a", nil), newChart("subchart-b", nil))
messages := runSequencingLint(t, root)
requireMessage(t, messages, support.ErrorSev, "JSON string array")
}
func TestSequencing_SubchartAnnotationOrphanWithNoDependencies(t *testing.T) {
t.Parallel()
// A chart with ZERO dependencies can still carry a
// helm.sh/depends-on/subcharts annotation referencing a subchart that
// doesn't exist. The lint rule previously early-returned on
// len(Dependencies)==0 and missed this orphan reference entirely.
root := newChart("testchart", nil)
root.Metadata.Annotations = map[string]string{
chartutil.AnnotationDependsOnSubcharts: `["does-not-exist"]`,
}
messages := runSequencingLint(t, root)
requireMessage(t, messages, support.ErrorSev, "unknown or disabled subchart")
}
func TestSequencing_NestedSubchartCircularDependency(t *testing.T) {
t.Parallel()
// The CHILD's Chart.yaml declares a depends-on cycle between its two
// grandchildren. HEAD's lint only validates the ROOT's subchart DAG, so
// this chart lints clean but fails fatally at install (bead lkx).
child := newChart("child", map[string]string{
"templates/cm.yaml": manifestYAML("ConfigMap", "child-cm", nil),
})
child.Metadata.Dependencies = []*chart.Dependency{
{Name: "grandchild-a", Version: "0.1.0", Repository: "file://charts/grandchild-a", DependsOn: []string{"grandchild-b"}},
{Name: "grandchild-b", Version: "0.1.0", Repository: "file://charts/grandchild-b", DependsOn: []string{"grandchild-a"}},
}
child.SetDependencies(newChart("grandchild-a", nil), newChart("grandchild-b", nil))
root := newChart("testchart", nil)
root.Metadata.Dependencies = []*chart.Dependency{
{Name: "child", Version: "0.1.0", Repository: "file://charts/child"},
}
root.SetDependencies(child)
messages := runSequencingLint(t, root)
requireMessage(t, messages, support.ErrorSev, "subchart circular dependency detected")
requireMessage(t, messages, support.ErrorSev, "testchart/charts/child")
}
func TestSequencing_NestedSubchartUnknownDependsOnRef(t *testing.T) {
t.Parallel()
child := newChart("child", map[string]string{
"templates/cm.yaml": manifestYAML("ConfigMap", "child-cm", nil),
})
child.Metadata.Dependencies = []*chart.Dependency{
{Name: "grandchild-a", Version: "0.1.0", Repository: "file://charts/grandchild-a", DependsOn: []string{"missing"}},
}
child.SetDependencies(newChart("grandchild-a", nil))
root := newChart("testchart", nil)
root.Metadata.Dependencies = []*chart.Dependency{
{Name: "child", Version: "0.1.0", Repository: "file://charts/child"},
}
root.SetDependencies(child)
messages := runSequencingLint(t, root)
requireMessage(t, messages, support.ErrorSev, `depends-on unknown or disabled subchart "missing"`)
requireMessage(t, messages, support.ErrorSev, "testchart/charts/child")
}
func TestSequencing_NestedResourceGroupCircularDependency(t *testing.T) {
t.Parallel()
child := newChart("child", map[string]string{
"templates/a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{
releaseutil.AnnotationResourceGroup: "a",
releaseutil.AnnotationDependsOnResourceGroups: `'["b"]'`,
}),
"templates/b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{
releaseutil.AnnotationResourceGroup: "b",
releaseutil.AnnotationDependsOnResourceGroups: `'["a"]'`,
}),
})
root := newChart("testchart", nil)
root.Metadata.Dependencies = []*chart.Dependency{
{Name: "child", Version: "0.1.0", Repository: "file://charts/child"},
}
root.SetDependencies(child)
messages := runSequencingLint(t, root)
requireMessage(t, messages, support.ErrorSev, "resource-group circular dependency detected")
requireMessage(t, messages, support.ErrorSev, "testchart/charts/child")
}
func TestSequencing_IsolatedGroupWarns(t *testing.T) {
t.Parallel()
// Two groups, no depends-on edges between them: runtime demotes both to
// the unsequenced batch with a warning. Lint surfaces that demotion as a
// WARNING (not an error — the chart still deploys).
messages := runSequencingLint(t, newChart("testchart", map[string]string{
"templates/a.yaml": manifestYAML("ConfigMap", "cm-a", map[string]string{releaseutil.AnnotationResourceGroup: "a"}),
"templates/b.yaml": manifestYAML("ConfigMap", "cm-b", map[string]string{releaseutil.AnnotationResourceGroup: "b"}),
}))
requireMessage(t, messages, support.WarningSev, "isolated")
}
func TestSequencing_UndeclaredSubchartWarns(t *testing.T) {
t.Parallel()
// Vendored subchart present in charts/ but absent from Chart.yaml
// dependencies: runtime deploys it after declared subcharts with a
// warning. Lint mirrors that as a WARNING.
sub := newChart("vendored", map[string]string{
"templates/cm.yaml": manifestYAML("ConfigMap", "vendored-cm", nil),
})
root := newChart("testchart", nil)
root.SetDependencies(sub) // deliberately NOT in root.Metadata.Dependencies
messages := runSequencingLint(t, root)
requireMessage(t, messages, support.WarningSev, "not declared in Chart.yaml")
}
func TestSequencing_RenderedAnnotationRules(t *testing.T) {
t.Parallel()
cases := []struct {
name string
templates map[string]string
wantSeverity int
wantSubstring string
}{
{
name: "partial readiness annotations",
templates: map[string]string{
"templates/configmap.yaml": manifestYAML("ConfigMap", "partial-readiness", map[string]string{
releaseutil.AnnotationResourceGroup: "bootstrap",
"helm.sh/readiness-success": `'["{.phase} == \"Ready\""]'`,
}),
},
wantSeverity: support.ErrorSev,
wantSubstring: "both must be present or absent together",
},
{
name: "duplicate group assignment",
templates: map[string]string{
"templates/first.yaml": manifestYAML("ConfigMap", "shared-resource", map[string]string{releaseutil.AnnotationResourceGroup: "bootstrap"}),
"templates/second.yaml": manifestYAML("ConfigMap", "shared-resource", map[string]string{releaseutil.AnnotationResourceGroup: "app"}),
},
wantSeverity: support.ErrorSev,
wantSubstring: "assigned to multiple resource groups",
},
{
name: "circular resource-group dependencies",
templates: map[string]string{
"templates/group-a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{
releaseutil.AnnotationResourceGroup: "a",
releaseutil.AnnotationDependsOnResourceGroups: `'["c"]'`,
}),
"templates/group-b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{
releaseutil.AnnotationResourceGroup: "b",
releaseutil.AnnotationDependsOnResourceGroups: `'["a"]'`,
}),
"templates/group-c.yaml": manifestYAML("ConfigMap", "group-c", map[string]string{
releaseutil.AnnotationResourceGroup: "c",
releaseutil.AnnotationDependsOnResourceGroups: `'["b"]'`,
}),
},
wantSeverity: support.ErrorSev,
wantSubstring: "resource-group circular dependency detected",
},
{
name: "orphan resource-group reference",
templates: map[string]string{
"templates/configmap.yaml": manifestYAML("ConfigMap", "orphaned-group", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `'["database"]'`,
}),
},
wantSeverity: support.ErrorSev,
wantSubstring: `depends-on non-existent group "database"`,
},
{
name: "malformed depends-on-resource-groups annotation",
templates: map[string]string{
"templates/configmap.yaml": manifestYAML("ConfigMap", "bad-json", map[string]string{
releaseutil.AnnotationResourceGroup: "app",
releaseutil.AnnotationDependsOnResourceGroups: `not-a-json-array`,
}),
},
wantSeverity: support.ErrorSev,
wantSubstring: "depends-on",
},
{
// Both readiness annotations present (so the presence-symmetry
// check passes), but the JSONPath in readiness-success is
// malformed. Presence symmetry alone let this through; the rule
// must validate the expressions compile.
name: "malformed readiness JSONPath with both annotations present",
templates: map[string]string{
"templates/job.yaml": manifestYAML("Job", "bad-jsonpath", map[string]string{
"helm.sh/readiness-success": `'["{.status.succeeded >= 1"]'`,
"helm.sh/readiness-failure": `'["{.status.failed} >= 1"]'`,
}),
},
wantSeverity: support.ErrorSev,
wantSubstring: "malformed",
},
{
// The comparison value's type IS statically known: an ordering
// operator with a non-numeric literal can never evaluate at
// runtime, so lint flags it as a definite authoring error.
name: "ordering operator with non-numeric readiness value",
templates: map[string]string{
"templates/job.yaml": manifestYAML("Job", "string-ordering", map[string]string{
"helm.sh/readiness-success": `'["{.status.phase} > \"Running\""]'`,
"helm.sh/readiness-failure": `'["{.status.failed} >= 1"]'`,
}),
},
wantSeverity: support.ErrorSev,
wantSubstring: "requires a numeric comparison value",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
messages := runSequencingLint(t, newChart("testchart", tc.templates))
requireMessage(t, messages, tc.wantSeverity, tc.wantSubstring)
})
}
}
func TestSequencing_HookResourcesExcludedFromResourceGroupDAG(t *testing.T) {
t.Parallel()
// A hook resource carrying sequencing annotations that WOULD form a cycle
// with the non-hook resources must be ignored by the sequencing lint rules,
// exactly as the install path ignores it (SortManifests routes hooks out
// before resource-group parsing). group-b depends on group-a; the hook
// claims group-a depends-on group-b — a cycle only if the hook is counted.
templates := map[string]string{
"templates/group-a.yaml": manifestYAML("ConfigMap", "group-a", map[string]string{
releaseutil.AnnotationResourceGroup: "group-a",
}),
"templates/group-b.yaml": manifestYAML("ConfigMap", "group-b", map[string]string{
releaseutil.AnnotationResourceGroup: "group-b",
releaseutil.AnnotationDependsOnResourceGroups: `'["group-a"]'`,
}),
"templates/hook-job.yaml": manifestYAML("Job", "hook-job", map[string]string{
"helm.sh/hook": "pre-install",
releaseutil.AnnotationResourceGroup: "group-a",
releaseutil.AnnotationDependsOnResourceGroups: `'["group-b"]'`,
}),
}
messages := runSequencingLint(t, newChart("hook-chart", templates))
for _, m := range messages {
assert.NotContains(t, m.Err.Error(), "circular",
"hook resource must be excluded from the resource-group DAG; got: %v", m.Err)
}
}
func TestSequencing_NoMessagesWithoutSequencingAnnotations(t *testing.T) {
t.Parallel()
messages := runSequencingLint(t, newChart("plain-chart", map[string]string{
"templates/configmap.yaml": manifestYAML("ConfigMap", "plain", nil),
}))
assert.Empty(t, messages)
}
func runSequencingLint(t *testing.T, c *chart.Chart) []support.Message {
t.Helper()
tmpDir := t.TempDir()
require.NoError(t, chartutil.SaveDir(c, tmpDir))
linter := support.Linter{ChartDir: filepath.Join(tmpDir, c.Name())}
Sequencing(&linter, "test-namespace", nil)
return linter.Messages
}
func requireMessage(t *testing.T, messages []support.Message, severity int, substring string) {
t.Helper()
for _, message := range messages {
if message.Severity == severity && strings.Contains(message.Err.Error(), substring) {
return
}
}
t.Fatalf("expected severity %d message containing %q, got %#v", severity, substring, messages)
}
func newChart(name string, templates map[string]string) *chart.Chart {
files := make([]*common.File, 0, len(templates))
names := make([]string, 0, len(templates))
for templateName := range templates {
names = append(names, templateName)
}
sort.Strings(names)
for _, templateName := range names {
files = append(files, &common.File{
Name: templateName,
Data: []byte(templates[templateName]),
})
}
return &chart.Chart{
Metadata: &chart.Metadata{
Name: name,
Version: "0.1.0",
APIVersion: chart.APIVersionV2,
},
Templates: files,
}
}
func manifestYAML(kind, name string, annotations map[string]string) string {
if len(annotations) == 0 {
return fmt.Sprintf(`apiVersion: v1
kind: %s
metadata:
name: %s
data:
key: value
`, kind, name)
}
keys := make([]string, 0, len(annotations))
for key := range annotations {
keys = append(keys, key)
}
sort.Strings(keys)
var annotationBlock strings.Builder
for _, key := range keys {
fmt.Fprintf(&annotationBlock, "\n %s: %s", key, annotations[key])
}
return fmt.Sprintf(`apiVersion: v1
kind: %s
metadata:
name: %s
annotations:%s
data:
key: value
`, kind, name, annotationBlock.String())
}

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

@ -148,6 +148,13 @@ func processDependencyEnabled(c *chart.Chart, v map[string]any, path string) err
return nil
}
// Resolve depends-on references while dependency entries still carry
// their original chart names; the alias rewrite below (req.Name =
// req.Alias) makes those names unrecoverable afterwards.
if err := resolveDependsOnReferences(c); err != nil {
return err
}
var chartDependencies []*chart.Chart
// If any dependency is not a part of Chart.yaml
// then this should be added to chartDependencies.

@ -0,0 +1,264 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"encoding/json"
"fmt"
"strings"
chart "helm.sh/helm/v4/pkg/chart/v2"
)
const (
// AnnotationDependsOnSubcharts is the Chart.yaml annotation key for declaring
// parent-chart dependencies on subcharts. The value is a JSON string array of
// subchart names (or aliases) that must be ready before parent resources are
// installed.
AnnotationDependsOnSubcharts = "helm.sh/depends-on/subcharts"
)
// BuildSubchartDAG constructs a DAG from a chart's subchart dependency declarations.
//
// Subchart-to-subchart ordering is read from the depends-on field on Chart.yaml
// dependency entries. The helm.sh/depends-on/subcharts metadata annotation is
// validated as the parent chart's dependencies on subcharts; parent resources
// are deployed after subchart batches by the action layer.
//
// Subcharts are keyed by their effective name (alias if set, otherwise name).
// ProcessDependencies must have been called on the chart first: it prunes
// disabled subcharts, applies alias renames, and rewrites depends-on
// references — dependency depends-on entries and the
// helm.sh/depends-on/subcharts annotation — to effective names (see
// resolveDependsOnReferences). BuildSubchartDAG therefore resolves references
// against effective names only. This holds for charts decoded from release
// storage too: they were processed before being stored, so their persisted
// depends-on references already use effective names.
func BuildSubchartDAG(c *chart.Chart) (*DAG, error) {
dag := NewDAG()
if c == nil || c.Metadata == nil {
return dag, nil
}
// Build the set of subcharts that survived ProcessDependencies.
loaded := make(map[string]bool, len(c.Dependencies()))
for _, sub := range c.Dependencies() {
loaded[sub.Name()] = true
}
// Charts decoded from release storage have no loaded dependency tree:
// Chart.dependencies is unexported, so the release codec (json.Marshal in
// pkg/storage/driver) drops it. Metadata.Dependencies survives, and per
// the ProcessDependencies precondition above it was already pruned to the
// enabled set with aliases and depends-on references rewritten. Enabled
// is only ever set true by ProcessDependencies, so it marks entries that
// were deployable at install time; freshly parsed Chart.yaml entries
// default to Enabled=false and stay excluded here.
trustMetadata := len(c.Dependencies()) == 0
// Each loaded subchart becomes a DAG node keyed by its effective name.
nodes := make(map[string]bool, len(c.Metadata.Dependencies))
for _, dep := range c.Metadata.Dependencies {
if dep == nil {
continue
}
eff := effectiveDependencyName(dep)
trustedFromMetadata := trustMetadata && dep.Enabled
if (!loaded[eff] && !trustedFromMetadata) || nodes[eff] {
continue
}
nodes[eff] = true
dag.AddNode(eff)
}
for _, dep := range c.Metadata.Dependencies {
if dep == nil {
continue
}
eff := effectiveDependencyName(dep)
if !nodes[eff] {
continue
}
for _, prerequisite := range dep.DependsOn {
if !nodes[prerequisite] {
return nil, fmt.Errorf("subchart %q depends-on unknown or disabled subchart %q", eff, prerequisite)
}
if err := dag.AddEdge(prerequisite, eff); err != nil {
return nil, fmt.Errorf("adding sequencing edge %s→%s: %w", prerequisite, eff, err)
}
}
}
if err := validateParentSubchartDependencies(c.Metadata.Annotations[AnnotationDependsOnSubcharts], nodes); err != nil {
return nil, err
}
return dag, nil
}
// resolveDependsOnReferences rewrites depends-on references — Chart.yaml
// dependency depends-on entries and the helm.sh/depends-on/subcharts
// annotation — from a subchart's original chart name to its effective name
// (alias if set, otherwise name). It must run while original names are still
// present in c.Metadata.Dependencies, i.e. before processDependencyEnabled
// applies its Name = Alias rewrite; that rewrite makes original names
// unrecoverable. The rewritten references are what release storage persists
// and what BuildSubchartDAG consumes, including at uninstall time when the
// chart is decoded from the release record.
//
// A reference matching more than one subchart (e.g. the original name of a
// chart pulled in under two aliases) is rejected as ambiguous. References
// matching no dependency are left unchanged so BuildSubchartDAG can report
// them as unknown or disabled, and a malformed annotation is left for
// BuildSubchartDAG's JSON parse error. Resolution is idempotent: effective
// names resolve to themselves, so reprocessing an already-processed chart is
// a no-op.
func resolveDependsOnReferences(c *chart.Chart) error {
refs := newSubchartRefs()
for _, dep := range c.Metadata.Dependencies {
if dep == nil {
continue
}
eff := effectiveDependencyName(dep)
refs.register(eff, eff)
refs.register(dep.Name, eff)
}
for _, dep := range c.Metadata.Dependencies {
if dep == nil {
continue
}
for i, ref := range dep.DependsOn {
eff, found, isAmbiguous := refs.resolve(ref)
if isAmbiguous {
return fmt.Errorf("subchart %q depends-on ambiguous subchart reference %q; reference it by alias to disambiguate", effectiveDependencyName(dep), ref)
}
if found {
dep.DependsOn[i] = eff
}
}
}
return resolveAnnotationDependsOn(c, refs)
}
// resolveAnnotationDependsOn rewrites the parent chart's
// helm.sh/depends-on/subcharts annotation entries to effective subchart names.
func resolveAnnotationDependsOn(c *chart.Chart, refs *subchartRefs) error {
annotation := strings.TrimSpace(c.Metadata.Annotations[AnnotationDependsOnSubcharts])
if annotation == "" {
return nil
}
var prerequisites []string
if err := json.Unmarshal([]byte(annotation), &prerequisites); err != nil {
// Malformed JSON is reported by BuildSubchartDAG with full context.
return nil
}
changed := false
for i, ref := range prerequisites {
eff, found, isAmbiguous := refs.resolve(ref)
if isAmbiguous {
return fmt.Errorf("annotation %s references ambiguous subchart %q; reference it by alias to disambiguate", AnnotationDependsOnSubcharts, ref)
}
if found && eff != ref {
prerequisites[i] = eff
changed = true
}
}
if !changed {
return nil
}
encoded, err := json.Marshal(prerequisites)
if err != nil {
return fmt.Errorf("re-encoding %s annotation: %w", AnnotationDependsOnSubcharts, err)
}
c.Metadata.Annotations[AnnotationDependsOnSubcharts] = string(encoded)
return nil
}
// subchartRefs resolves depends-on references to effective subchart names
// while processing dependencies, i.e. while original chart names are still
// present. A reference may be a subchart's effective name (alias if set,
// otherwise name) or its original chart name. A reference that maps to more
// than one subchart is recorded as ambiguous and rejected on use, rather than
// being silently resolved to one of them.
type subchartRefs struct {
byRef map[string]string
ambiguous map[string]bool
}
func newSubchartRefs() *subchartRefs {
return &subchartRefs{
byRef: make(map[string]string),
ambiguous: make(map[string]bool),
}
}
// register maps ref to the effective subchart name eff. If ref already resolves
// to a different subchart, it is flagged ambiguous.
func (s *subchartRefs) register(ref, eff string) {
if ref == "" {
return
}
if existing, ok := s.byRef[ref]; ok && existing != eff {
s.ambiguous[ref] = true
return
}
s.byRef[ref] = eff
}
// resolve returns the effective subchart name for ref. found is false for an
// unknown reference; isAmbiguous is true when ref maps to multiple subcharts.
func (s *subchartRefs) resolve(ref string) (eff string, found, isAmbiguous bool) {
if s.ambiguous[ref] {
return "", false, true
}
eff, found = s.byRef[ref]
return eff, found, false
}
func validateParentSubchartDependencies(annotation string, nodes map[string]bool) error {
annotation = strings.TrimSpace(annotation)
if annotation == "" {
return nil
}
var prerequisites []string
if err := json.Unmarshal([]byte(annotation), &prerequisites); err != nil {
return fmt.Errorf("parsing %s annotation as JSON string array: %w", AnnotationDependsOnSubcharts, err)
}
for _, prerequisite := range prerequisites {
if !nodes[prerequisite] {
return fmt.Errorf("annotation %s references unknown or disabled subchart %q", AnnotationDependsOnSubcharts, prerequisite)
}
}
return nil
}
func effectiveDependencyName(dep *chart.Dependency) string {
if dep.Alias != "" {
return dep.Alias
}
return dep.Name
}

@ -0,0 +1,554 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
chart "helm.sh/helm/v4/pkg/chart/v2"
)
func TestBuildSubchartDAG_Empty(t *testing.T) {
t.Parallel()
batches := batchesForChart(t, newChart("parent"))
assert.Empty(t, batches)
}
func TestBuildSubchartDAG_NoDependencies(t *testing.T) {
t.Parallel()
c := newChart("parent",
enabledDependency("nginx"),
enabledDependency("rabbitmq"),
enabledDependency("postgres"),
)
assertBatches(t, c, [][]string{{"nginx", "postgres", "rabbitmq"}})
}
func TestBuildSubchartDAG_LinearOrder(t *testing.T) {
t.Parallel()
c := newChart("parent",
enabledDependency("postgres"),
enabledDependency("rabbitmq", "postgres"),
enabledDependency("app", "rabbitmq"),
)
assertBatches(t, c, [][]string{{"postgres"}, {"rabbitmq"}, {"app"}})
}
func TestBuildSubchartDAG_AliasResolution(t *testing.T) {
t.Parallel()
// Simulates post-ProcessDependencies state: the aliased entry has had
// Name rewritten to its alias, and depends-on entries were rewritten to
// effective names. (The real-pipeline path is covered by the
// TestProcessDependencies_* tests below.)
c := newChart("parent",
&chart.Dependency{Name: "primary-db", Alias: "primary-db", Enabled: true},
enabledDependency("app", "primary-db"),
)
assertBatches(t, c, [][]string{{"primary-db"}, {"app"}})
}
func TestBuildSubchartDAG_DisabledSubchart(t *testing.T) {
t.Parallel()
c := newChart("parent",
&chart.Dependency{Name: "cache", Enabled: false},
enabledDependency("app", "cache"),
)
_, err := BuildSubchartDAG(c)
require.Error(t, err)
assert.ErrorContains(t, err, `depends-on unknown or disabled subchart "cache"`)
}
func TestBuildSubchartDAG_DisabledSubchartNotReferenced(t *testing.T) {
t.Parallel()
c := newChart("parent",
&chart.Dependency{Name: "cache", Enabled: false},
enabledDependency("app"),
)
assertBatches(t, c, [][]string{{"app"}})
}
func TestBuildSubchartDAG_CycleDetection(t *testing.T) {
t.Parallel()
c := newChart("parent",
enabledDependency("a", "b"),
enabledDependency("b", "c"),
enabledDependency("c", "a"),
)
dag, err := BuildSubchartDAG(c)
require.NoError(t, err)
batches, err := dag.GetBatches()
require.Error(t, err)
assert.Nil(t, batches)
assert.ErrorContains(t, err, "cycle")
}
func TestBuildSubchartDAG_AnnotationBasedParentDependencies(t *testing.T) {
t.Parallel()
c := newChart("parent",
enabledDependency("postgres"),
enabledDependency("nginx"),
)
c.Metadata.Annotations = map[string]string{
AnnotationDependsOnSubcharts: `["nginx"]`,
}
assertBatches(t, c, [][]string{{"nginx", "postgres"}})
}
func TestBuildSubchartDAG_HIPExample(t *testing.T) {
t.Parallel()
c := newChart("foo",
enabledDependency("nginx"),
enabledDependency("rabbitmq"),
enabledDependency("bar", "nginx", "rabbitmq"),
)
c.Metadata.Annotations = map[string]string{
AnnotationDependsOnSubcharts: `["bar", "rabbitmq"]`,
}
assertBatches(t, c, [][]string{{"nginx", "rabbitmq"}, {"bar"}})
}
func TestBuildSubchartDAG_MixedDeclarations(t *testing.T) {
t.Parallel()
c := newChart("parent",
enabledDependency("database"),
enabledDependency("api", "database"),
enabledDependency("worker"),
)
c.Metadata.Annotations = map[string]string{
AnnotationDependsOnSubcharts: `["worker"]`,
}
assertBatches(t, c, [][]string{{"database", "worker"}, {"api"}})
}
func TestBuildSubchartDAG_InvalidAnnotationJSON(t *testing.T) {
t.Parallel()
c := newChart("parent", enabledDependency("api"))
c.Metadata.Annotations = map[string]string{
AnnotationDependsOnSubcharts: `["api",`,
}
_, err := BuildSubchartDAG(c)
require.Error(t, err)
assert.ErrorContains(t, err, "parsing "+AnnotationDependsOnSubcharts+" annotation")
}
func TestBuildSubchartDAG_ObjectAnnotationRejected(t *testing.T) {
t.Parallel()
c := newChart("parent",
enabledDependency("postgres"),
enabledDependency("nginx"),
)
c.Metadata.Annotations = map[string]string{
AnnotationDependsOnSubcharts: `{"nginx":["postgres"]}`,
}
_, err := BuildSubchartDAG(c)
require.Error(t, err)
assert.ErrorContains(t, err, "JSON string array")
}
func TestBuildSubchartDAG_NonExistentReference(t *testing.T) {
t.Parallel()
c := newChart("parent", enabledDependency("app", "missing"))
_, err := BuildSubchartDAG(c)
require.Error(t, err)
assert.ErrorContains(t, err, `depends-on unknown or disabled subchart "missing"`)
}
func TestBuildSubchartDAG_AnnotationUnknownSubchart(t *testing.T) {
t.Parallel()
c := newChart("parent", enabledDependency("postgres"))
c.Metadata.Annotations = map[string]string{
AnnotationDependsOnSubcharts: `["app"]`,
}
_, err := BuildSubchartDAG(c)
require.Error(t, err)
assert.ErrorContains(t, err, "unknown or disabled subchart")
}
func TestBuildSubchartDAG_NestedSubcharts(t *testing.T) {
t.Parallel()
root := newChart("parent",
enabledDependency("database"),
enabledDependency("application", "database"),
)
nested := newChart("application",
enabledDependency("cache"),
enabledDependency("worker", "cache"),
)
// Replace auto-stubs with real chart objects so nested DAG validation works.
root.SetDependencies(
&chart.Chart{Metadata: &chart.Metadata{Name: "database"}},
nested,
)
assertBatches(t, root, [][]string{{"database"}, {"application"}})
assertBatches(t, nested, [][]string{{"cache"}, {"worker"}})
}
// TestBuildSubchartDAG_StorageDecodedMetadataTrusted: a chart decoded from
// release storage has Metadata.Dependencies (pruned to the enabled set and
// alias-rewritten by ProcessDependencies before it was stored, Enabled=true)
// but an EMPTY loaded-dependency tree — the release codec drops the
// unexported Chart.dependencies field. Those metadata entries must be
// trusted, or uninstall/rollback of any sequenced release fails (bead xmn).
func TestBuildSubchartDAG_StorageDecodedMetadataTrusted(t *testing.T) {
t.Parallel()
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "parent",
Dependencies: []*chart.Dependency{
{Name: "db", Enabled: true},
{Name: "app", Enabled: true, DependsOn: []string{"db"}},
},
Annotations: map[string]string{
AnnotationDependsOnSubcharts: `["app"]`,
},
},
}
// Note: no AddDependency call — c.Dependencies() is empty, as after a
// storage round-trip.
assertBatches(t, c, [][]string{{"db"}, {"app"}})
}
// TestBuildSubchartDAG_MetadataOnlyNotEnabled_Ignored: freshly parsed
// Chart.yaml entries default to Enabled=false — only ProcessDependencies sets
// it true. Without loaded dependencies (e.g. lint before `helm dependency
// build`) such entries stay excluded, preserving the pre-existing contract
// for unprocessed charts.
func TestBuildSubchartDAG_MetadataOnlyNotEnabled_Ignored(t *testing.T) {
t.Parallel()
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "parent",
Dependencies: []*chart.Dependency{
{Name: "ghost"},
},
},
}
batches := batchesForChart(t, c)
assert.Empty(t, batches, "unprocessed metadata-only deps should yield empty DAG")
}
// TestBuildSubchartDAG_AnnotationReferencesUnloadedDep verifies that an
// annotation referencing a subchart present in metadata but pruned from
// c.Dependencies() (e.g., disabled by ProcessDependencies) produces an error.
func TestBuildSubchartDAG_AnnotationReferencesUnloadedDep(t *testing.T) {
t.Parallel()
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "parent",
Dependencies: []*chart.Dependency{
{Name: "loaded-dep", Enabled: true},
{Name: "pruned-dep", Enabled: true},
},
Annotations: map[string]string{
AnnotationDependsOnSubcharts: `["pruned-dep"]`,
},
},
}
// Only loaded-dep is in c.Dependencies(); pruned-dep is not.
c.AddDependency(&chart.Chart{Metadata: &chart.Metadata{Name: "loaded-dep"}})
_, err := BuildSubchartDAG(c)
require.Error(t, err)
assert.ErrorContains(t, err, `unknown or disabled subchart "pruned-dep"`)
}
func assertBatches(t *testing.T, c *chart.Chart, expected [][]string) {
t.Helper()
assert.Equal(t, expected, batchesForChart(t, c))
}
func batchesForChart(t *testing.T, c *chart.Chart) [][]string {
t.Helper()
dag, err := BuildSubchartDAG(c)
require.NoError(t, err)
batches, err := dag.GetBatches()
require.NoError(t, err)
return batches
}
func newChart(name string, deps ...*chart.Dependency) *chart.Chart {
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: name,
Dependencies: deps,
},
}
// Simulate post-ProcessDependencies state: enabled deps appear in
// c.Dependencies() under their effective name (alias if set).
for _, dep := range deps {
if dep == nil || !dep.Enabled {
continue
}
subName := dep.Alias
if subName == "" {
subName = dep.Name
}
c.AddDependency(&chart.Chart{
Metadata: &chart.Metadata{Name: subName},
})
}
return c
}
func enabledDependency(name string, dependsOn ...string) *chart.Dependency {
return &chart.Dependency{
Name: name,
Enabled: true,
DependsOn: dependsOn,
}
}
// pipelineChart builds a parent chart whose loaded dependencies are named
// after the entries' ORIGINAL chart names, exactly as loader.LoadDir produces
// them, so tests exercise the real ProcessDependencies pipeline (alias rename
// + depends-on resolution) instead of hand-constructing post-processed state.
func pipelineChart(deps ...*chart.Dependency) *chart.Chart {
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "parent",
Version: "0.1.0",
APIVersion: chart.APIVersionV2,
Dependencies: deps,
},
}
added := make(map[string]bool)
for _, dep := range deps {
if added[dep.Name] {
continue
}
added[dep.Name] = true
c.AddDependency(&chart.Chart{
Metadata: &chart.Metadata{
Name: dep.Name,
Version: "0.1.0",
APIVersion: chart.APIVersionV2,
},
})
}
return c
}
func pipelineDependency(name, alias string, dependsOn ...string) *chart.Dependency {
return &chart.Dependency{
Name: name,
Version: "0.1.0",
Alias: alias,
DependsOn: dependsOn,
}
}
// TestProcessDependencies_ResolvesDependsOnByOriginalName is the regression
// test for hip-0025-92r. Through the real pipeline, processDependencyEnabled
// rewrites an aliased entry's Name to its alias; before the fix that made the
// original chart name unrecoverable and depends-on: ["postgres"] failed to
// resolve at DAG build time.
func TestProcessDependencies_ResolvesDependsOnByOriginalName(t *testing.T) {
t.Parallel()
c := pipelineChart(
pipelineDependency("postgres", "primary-db"),
pipelineDependency("app", "", "postgres"),
)
require.NoError(t, ProcessDependencies(c, map[string]any{}))
// The alias rename ran, and the depends-on reference was rewritten to the
// effective name — which is also what release storage persists, keeping
// the sequenced uninstall path working.
assert.Equal(t, "primary-db", c.Metadata.Dependencies[0].Name)
assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn)
assertBatches(t, c, [][]string{{"primary-db"}, {"app"}})
}
func TestProcessDependencies_ResolvesDependsOnByAlias(t *testing.T) {
t.Parallel()
c := pipelineChart(
pipelineDependency("postgres", "primary-db"),
pipelineDependency("app", "", "primary-db"),
)
require.NoError(t, ProcessDependencies(c, map[string]any{}))
assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn)
assertBatches(t, c, [][]string{{"primary-db"}, {"app"}})
}
func TestProcessDependencies_AmbiguousDependsOnRejected(t *testing.T) {
t.Parallel()
// The same chart aliased twice makes its original name ambiguous;
// referencing it by that name must be rejected, not silently resolved.
c := pipelineChart(
pipelineDependency("postgres", "db1"),
pipelineDependency("postgres", "db2"),
pipelineDependency("app", "", "postgres"),
)
err := ProcessDependencies(c, map[string]any{})
require.Error(t, err)
assert.ErrorContains(t, err, `ambiguous subchart reference "postgres"`)
}
func TestProcessDependencies_RewritesSubchartAnnotation(t *testing.T) {
t.Parallel()
c := pipelineChart(
pipelineDependency("postgres", "primary-db"),
pipelineDependency("app", ""),
)
c.Metadata.Annotations = map[string]string{
AnnotationDependsOnSubcharts: `["postgres", "app"]`,
}
require.NoError(t, ProcessDependencies(c, map[string]any{}))
assert.Equal(t, `["primary-db","app"]`, c.Metadata.Annotations[AnnotationDependsOnSubcharts])
assertBatches(t, c, [][]string{{"app", "primary-db"}})
}
func TestProcessDependencies_AmbiguousAnnotationRejected(t *testing.T) {
t.Parallel()
c := pipelineChart(
pipelineDependency("postgres", "db1"),
pipelineDependency("postgres", "db2"),
)
c.Metadata.Annotations = map[string]string{
AnnotationDependsOnSubcharts: `["postgres"]`,
}
err := ProcessDependencies(c, map[string]any{})
require.Error(t, err)
assert.ErrorContains(t, err, `references ambiguous subchart "postgres"`)
}
func TestProcessDependencies_UnknownDependsOnReportedByDAG(t *testing.T) {
t.Parallel()
// Unknown references are left alone by the resolver so BuildSubchartDAG
// keeps reporting them with its established error message.
c := pipelineChart(
pipelineDependency("app", "", "missing"),
)
require.NoError(t, ProcessDependencies(c, map[string]any{}))
_, err := BuildSubchartDAG(c)
require.Error(t, err)
assert.ErrorContains(t, err, `unknown or disabled subchart "missing"`)
}
func TestProcessDependencies_DependsOnRewriteIdempotent(t *testing.T) {
t.Parallel()
c := pipelineChart(
pipelineDependency("postgres", "primary-db"),
pipelineDependency("app", "", "postgres"),
)
require.NoError(t, ProcessDependencies(c, map[string]any{}))
// Lint and the sequenced-uninstall re-render run ProcessDependencies again
// on the already-processed chart; the second pass must neither error nor
// corrupt the rewritten references.
require.NoError(t, ProcessDependencies(c, map[string]any{}))
assert.Equal(t, []string{"primary-db"}, c.Metadata.Dependencies[1].DependsOn)
assertBatches(t, c, [][]string{{"primary-db"}, {"app"}})
}
func TestProcessDependencies_PlainAndAliasedSameChart(t *testing.T) {
t.Parallel()
// "svc" is deployed both under its own name and under an alias: the bare
// name matches two subcharts and is ambiguous, while the alias resolves.
ambiguous := pipelineChart(
pipelineDependency("svc", ""),
pipelineDependency("svc", "svc2"),
pipelineDependency("app", "", "svc"),
)
err := ProcessDependencies(ambiguous, map[string]any{})
require.Error(t, err)
assert.ErrorContains(t, err, `ambiguous subchart reference "svc"`)
byAlias := pipelineChart(
pipelineDependency("svc", ""),
pipelineDependency("svc", "svc2"),
pipelineDependency("app", "", "svc2"),
)
require.NoError(t, ProcessDependencies(byAlias, map[string]any{}))
assertBatches(t, byAlias, [][]string{{"svc", "svc2"}, {"app"}})
}
func TestProcessDependencies_SwappedAliasesAmbiguous(t *testing.T) {
t.Parallel()
// x is aliased to y while y is aliased to x: each bare name now refers to
// two different subcharts, so references to either are rejected as
// ambiguous rather than silently resolved to one of them.
c := pipelineChart(
pipelineDependency("x", "y"),
pipelineDependency("y", "x"),
pipelineDependency("app", "", "x"),
)
err := ProcessDependencies(c, map[string]any{})
require.Error(t, err)
assert.ErrorContains(t, err, `ambiguous subchart reference "x"`)
}

@ -0,0 +1,229 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"errors"
"fmt"
"io"
"slices"
"sort"
"strings"
"github.com/spf13/cobra"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/cli/values"
"helm.sh/helm/v4/pkg/cmd/require"
"helm.sh/helm/v4/pkg/release/v1/sequence"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
const dagDesc = `
Print the resource sequencing DAG (directed acyclic graph) for a chart.
This is a development and troubleshooting command for HIP-0025 sequencing. It
loads the chart, evaluates conditional dependencies against the provided values,
renders templates locally, and prints the deployment order that
'helm install --wait=ordered' would use:
- Subchart deployment batches, derived from Chart.yaml dependency 'depends-on'
fields and the 'helm.sh/depends-on/subcharts' annotation.
- Resource-group batches per chart level, derived from the
'helm.sh/resource-group' and 'helm.sh/depends-on/resource-groups'
annotations on rendered manifests.
Cycles in either DAG are reported as errors. Manifests that lack a
'helm.sh/resource-group' annotation, or whose group was demoted because of a
missing dependency, are listed as "Unsequenced" and would be deployed after the
sequenced batches.
Hooks are not part of any sequencing DAG (HIP-0025) and are omitted from the
output. No cluster connection is required; the chart is rendered with
client-side dry-run.
`
func newDagCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
client := action.NewInstall(cfg)
valueOpts := &values.Options{}
var kubeVersion string
var extraAPIs []string
cmd := &cobra.Command{
Use: "dag CHART",
Short: "print the resource sequencing DAG for a chart",
Long: dagDesc,
Args: require.MinimumNArgs(1),
ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return compInstall(args, toComplete, client)
},
RunE: func(_ *cobra.Command, args []string) error {
if kubeVersion != "" {
parsed, err := common.ParseKubeVersion(kubeVersion)
if err != nil {
return fmt.Errorf("invalid kube version %q: %w", kubeVersion, err)
}
client.KubeVersion = parsed
}
registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile,
client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password)
if err != nil {
return fmt.Errorf("missing registry client: %w", err)
}
client.SetRegistryClient(registryClient)
// Render the chart locally without touching the cluster. Hooks are not
// sequenced per HIP-0025, so we suppress them from the rendered output
// to keep the DAG view focused on install-phase resources.
client.DryRunStrategy = action.DryRunClient
client.ReleaseName = "release-name"
client.Replace = true
client.APIVersions = common.VersionSet(extraAPIs)
client.DisableHooks = true
rel, err := runInstall(args, client, valueOpts, out)
if err != nil {
return err
}
if rel == nil || rel.Chart == nil {
return errors.New("no chart rendered")
}
return printSequencingDAG(rel.Chart, strings.TrimSpace(rel.Manifest), out)
},
}
f := cmd.Flags()
addValueOptionsFlags(f, valueOpts)
addChartPathOptionsFlags(f, &client.ChartPathOptions)
f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion")
f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)")
f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before printing the DAG")
return cmd
}
// printSequencingDAG walks a processed chart and its rendered manifest stream,
// printing the subchart deployment batches and per-chart resource-group batches
// in the same order 'helm install --wait=ordered' would deploy them.
func printSequencingDAG(chrt *chart.Chart, manifest string, out io.Writer) error {
var manifests []releaseutil.Manifest
if manifest != "" {
parsed, err := sequence.ParseStoredManifests(manifest)
if err != nil {
return fmt.Errorf("parsing rendered manifests: %w", err)
}
manifests = parsed
}
plan, err := sequence.Build(chrt, manifests)
if err != nil {
return err
}
logSequencePlanWarnings(plan)
levelByPath := make(map[string]*sequence.ChartLevel, len(plan.Levels))
for i := range plan.Levels {
levelByPath[plan.Levels[i].Path] = &plan.Levels[i]
}
groupBatchesByPath := make(map[string][]sequence.Batch)
unsequencedByPath := make(map[string]sequence.Batch)
for _, batch := range plan.Batches {
switch batch.Kind {
case sequence.BatchKindGroups:
groupBatchesByPath[batch.ChartPath] = append(groupBatchesByPath[batch.ChartPath], batch)
case sequence.BatchKindUnsequenced:
unsequencedByPath[batch.ChartPath] = batch
}
}
var printLevel func(level *sequence.ChartLevel)
printLevel = func(level *sequence.ChartLevel) {
indent := strings.Repeat(" ", level.Depth)
fmt.Fprintf(out, "%sChart: %s\n", indent, level.Path)
if len(level.SubchartBatches) == 0 {
fmt.Fprintf(out, "%s Subchart batches: (none)\n", indent)
} else {
fmt.Fprintf(out, "%s Subchart batches:\n", indent)
for i, batch := range level.SubchartBatches {
fmt.Fprintf(out, "%s Batch %d: %s\n", indent, i+1, strings.Join(batch, ", "))
}
}
resourceIndent := indent + " "
groupBatches := groupBatchesByPath[level.Path]
if len(groupBatches) == 0 {
fmt.Fprintf(out, "%sResource-group batches: (none)\n", resourceIndent)
} else {
fmt.Fprintf(out, "%sResource-group batches:\n", resourceIndent)
for i, batch := range groupBatches {
names := make([]string, 0, len(batch.Groups))
for _, group := range batch.Groups {
names = append(names, group.Name)
}
fmt.Fprintf(out, "%s Batch %d: %s\n", resourceIndent, i+1, strings.Join(names, ", "))
}
}
if batch, ok := unsequencedByPath[level.Path]; ok {
names := make([]string, 0, len(batch.Manifests()))
for _, manifest := range batch.Manifests() {
names = append(names, unsequencedResourceLabel(manifest))
}
sort.Strings(names)
fmt.Fprintf(out, "%sUnsequenced (deployed last): %s\n", resourceIndent, strings.Join(names, ", "))
}
printChild := func(name string) {
if slices.Contains(level.Unresolved, name) {
fmt.Fprintf(out, "%s (subchart %q metadata unavailable; sequenced structurally from manifests)\n", indent, name)
}
if child := levelByPath[level.Path+"/charts/"+name]; child != nil {
printLevel(child)
}
}
for _, batch := range level.SubchartBatches {
for _, name := range batch {
printChild(name)
}
}
for _, name := range level.Undeclared {
fmt.Fprintf(out, "%s Undeclared subchart %q (deployed unsequenced):\n", indent, name)
printChild(name)
}
}
if len(plan.Levels) > 0 {
printLevel(&plan.Levels[0])
}
return nil
}
func unsequencedResourceLabel(m releaseutil.Manifest) string {
if m.Head != nil && m.Head.Metadata != nil && m.Head.Metadata.Name != "" {
if m.Head.Kind != "" {
return fmt.Sprintf("%s/%s", m.Head.Kind, m.Head.Metadata.Name)
}
return m.Head.Metadata.Name
}
return m.Name
}

@ -0,0 +1,60 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"fmt"
"strings"
"testing"
)
func TestDagCmd(t *testing.T) {
chartPath := "testdata/testcharts/sequenced-chart"
tests := []cmdTestCase{
{
name: "sequenced chart prints subchart and resource-group batches",
cmd: fmt.Sprintf("dag '%s'", chartPath),
golden: "output/dag-sequenced-chart.txt",
},
{
name: "sequenced chart demotes isolated groups",
cmd: "dag 'testdata/testcharts/sequenced-isolated-chart'",
golden: "output/dag-sequenced-isolated.txt",
},
}
runTestCmd(t, tests)
}
func TestDagCmd_RequiresChartArg(t *testing.T) {
_, _, err := executeActionCommandC(storageFixture(), "dag")
if err == nil {
t.Fatal("expected error when chart argument is missing")
}
}
func TestDagCmd_NonexistentChart(t *testing.T) {
_, _, err := executeActionCommandC(storageFixture(), "dag testdata/testcharts/does-not-exist")
if err == nil {
t.Fatal("expected error for nonexistent chart path")
}
if !strings.Contains(err.Error(), "does-not-exist") &&
!strings.Contains(strings.ToLower(err.Error()), "no such file") &&
!strings.Contains(strings.ToLower(err.Error()), "not found") {
t.Fatalf("expected error to mention the missing chart, got: %v", err)
}
}

@ -25,6 +25,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@ -65,42 +66,99 @@ func AddWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) {
cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy)
}
type waitValue kube.WaitStrategy
func AddOrderedWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) {
cmd.Flags().Var(
newOrderedWaitValue(kube.HookOnlyStrategy, wait),
"wait",
"wait until resources are ready (up to --timeout). Use '--wait' alone for 'watcher' strategy, or specify one of: 'watcher', 'hookOnly', 'legacy', 'ordered'. Default when flag is omitted: 'hookOnly'.",
)
cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy)
}
type waitValue struct {
wait *kube.WaitStrategy
allowOrdered bool
}
func newWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy) *waitValue {
return newConfiguredWaitValue(defaultValue, ws, false)
}
func newOrderedWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy) *waitValue {
return newConfiguredWaitValue(defaultValue, ws, true)
}
func newConfiguredWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy, allowOrdered bool) *waitValue {
*ws = defaultValue
return (*waitValue)(ws)
return &waitValue{wait: ws, allowOrdered: allowOrdered}
}
func (ws *waitValue) String() string {
if ws == nil {
if ws == nil || ws.wait == nil {
return ""
}
return string(*ws)
return string(*ws.wait)
}
func (ws *waitValue) Set(s string) error {
switch s {
case string(kube.StatusWatcherStrategy), string(kube.LegacyStrategy), string(kube.HookOnlyStrategy):
*ws = waitValue(s)
*ws.wait = kube.WaitStrategy(s)
return nil
case string(kube.OrderedWaitStrategy):
if !ws.allowOrdered {
break
}
*ws.wait = kube.WaitStrategy(s)
return nil
case "true":
slog.Warn("--wait=true is deprecated (boolean value) and can be replaced with --wait=watcher")
*ws = waitValue(kube.StatusWatcherStrategy)
*ws.wait = kube.StatusWatcherStrategy
return nil
case "false":
slog.Warn("--wait=false is deprecated (boolean value) and can be replaced with --wait=hookOnly")
*ws = waitValue(kube.HookOnlyStrategy)
*ws.wait = kube.HookOnlyStrategy
return nil
default:
return fmt.Errorf("invalid wait input %q. Valid inputs are %s, %s, and %s", s, kube.StatusWatcherStrategy, kube.HookOnlyStrategy, kube.LegacyStrategy)
}
return fmt.Errorf("invalid wait input %q. Valid inputs are %s", s, formatWaitInputs(ws.allowOrdered))
}
func (ws *waitValue) Type() string {
return "WaitStrategy"
}
func addReadinessTimeoutFlag(f *pflag.FlagSet, readinessTimeout *time.Duration) {
// Default 0 means "unset": the per-batch default of 1m is applied at the
// point of use on the ordered path (install/upgrade/rollback). Defaulting
// the flag itself to 1m would make the readiness-timeout<=timeout check
// reject any plain run with --timeout < 1m, even without --wait=ordered.
f.DurationVar(readinessTimeout, "readiness-timeout", 0, "per-batch timeout when --wait=ordered is used; each resource batch must become ready within this duration (defaults to 1m when unset, and must not exceed --timeout). \"Ready\" is determined by kstatus signals for the resource kind (Deployment/StatefulSet/Pod/etc.) or by helm.sh/readiness-success and helm.sh/readiness-failure annotations when set; vanilla Jobs require --wait-for-jobs for the per-batch readiness gate to apply")
}
func formatWaitInputs(allowOrdered bool) string {
valid := []string{
string(kube.StatusWatcherStrategy),
string(kube.HookOnlyStrategy),
string(kube.LegacyStrategy),
}
if allowOrdered {
valid = append(valid, string(kube.OrderedWaitStrategy))
}
switch len(valid) {
case 0:
return ""
case 1:
return valid[0]
case 2:
return fmt.Sprintf("%s and %s", valid[0], valid[1])
default:
return fmt.Sprintf("%s, and %s", strings.Join(valid[:len(valid)-1], ", "), valid[len(valid)-1])
}
}
func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) {
f.StringVar(&c.Version, "version", "", "specify a version constraint for the chart version to use. This constraint can be a specific tag (e.g. 1.1.1) or it may reference a valid range (e.g. ^2.0.0). If this is not specified, the latest version is used")
f.BoolVar(&c.Verify, "verify", false, "verify the package before using it")

@ -18,6 +18,7 @@ package cmd
import (
"fmt"
"io"
"testing"
"time"
@ -99,6 +100,113 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) {
runTestCmd(t, tests)
}
func TestWaitFlag(t *testing.T) {
t.Run("install accepts ordered wait", func(t *testing.T) {
cmd := newInstallCmd(&action.Configuration{}, io.Discard)
require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"}))
require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String())
})
t.Run("upgrade accepts ordered wait", func(t *testing.T) {
cmd := newUpgradeCmd(&action.Configuration{}, io.Discard)
require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"}))
require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String())
})
t.Run("template accepts ordered wait", func(t *testing.T) {
cmd := newTemplateCmd(&action.Configuration{}, io.Discard)
require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"}))
require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String())
})
t.Run("rollback accepts ordered wait", func(t *testing.T) {
cmd := newRollbackCmd(&action.Configuration{}, io.Discard)
require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"}))
require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String())
})
t.Run("uninstall accepts ordered wait", func(t *testing.T) {
cmd := newUninstallCmd(&action.Configuration{}, io.Discard)
require.NoError(t, cmd.ParseFlags([]string{"--wait=ordered"}))
require.Equal(t, "ordered", cmd.Flags().Lookup("wait").Value.String())
})
}
func TestReadinessTimeout(t *testing.T) {
t.Run("install registers readiness-timeout unset by default", func(t *testing.T) {
cmd := newInstallCmd(&action.Configuration{}, io.Discard)
flag := cmd.Flags().Lookup("readiness-timeout")
require.NotNil(t, flag)
require.Equal(t, "0s", flag.DefValue)
require.NoError(t, cmd.ParseFlags([]string{"--readiness-timeout=30s"}))
require.Equal(t, "30s", flag.Value.String())
})
t.Run("upgrade registers readiness-timeout unset by default", func(t *testing.T) {
cmd := newUpgradeCmd(&action.Configuration{}, io.Discard)
flag := cmd.Flags().Lookup("readiness-timeout")
require.NotNil(t, flag)
require.Equal(t, "0s", flag.DefValue)
require.NoError(t, cmd.ParseFlags([]string{"--readiness-timeout=30s"}))
require.Equal(t, "30s", flag.Value.String())
})
t.Run("template and non-applicable commands do not register readiness-timeout", func(t *testing.T) {
require.Nil(t, newTemplateCmd(&action.Configuration{}, io.Discard).Flags().Lookup("readiness-timeout"))
require.Nil(t, newUninstallCmd(&action.Configuration{}, io.Discard).Flags().Lookup("readiness-timeout"))
})
t.Run("rollback registers readiness-timeout unset by default", func(t *testing.T) {
cmd := newRollbackCmd(&action.Configuration{}, io.Discard)
flag := cmd.Flags().Lookup("readiness-timeout")
require.NotNil(t, flag)
require.Equal(t, "0s", flag.DefValue)
require.NoError(t, cmd.ParseFlags([]string{"--readiness-timeout=30s"}))
require.Equal(t, "30s", flag.Value.String())
})
t.Run("install rejects readiness-timeout longer than timeout", func(t *testing.T) {
_, _, err := executeActionCommand("install timed-install testdata/testcharts/empty --wait=ordered --timeout 30s --readiness-timeout 60s")
require.Error(t, err)
require.Contains(t, err.Error(), "--readiness-timeout (1m0s) must not exceed --timeout (30s)")
})
t.Run("upgrade rejects readiness-timeout longer than timeout", func(t *testing.T) {
releaseName := "timed-upgrade"
relMock, ch, chartPath := prepareMockRelease(t, releaseName)
store := storageFixture()
require.NoError(t, store.Create(relMock(releaseName, 1, ch)))
_, _, err := executeActionCommandC(store, fmt.Sprintf("upgrade %s '%s' --wait=ordered --timeout 30s --readiness-timeout 60s", releaseName, chartPath))
require.Error(t, err)
require.Contains(t, err.Error(), "--readiness-timeout (1m0s) must not exceed --timeout (30s)")
})
t.Run("upgrade --install fallback carries readiness-timeout to install", func(t *testing.T) {
// Regression for hip-0025-vw1: the upgrade --install copy block dropped
// ReadinessTimeout, so first-time installs silently ignored the flag.
// With an empty store the upgrade falls back to install; if the value
// is carried over, install's readiness-timeout<=timeout guard fires.
store := storageFixture()
_, _, err := executeActionCommandC(store, "upgrade missing-release testdata/testcharts/empty --install --wait=ordered --timeout 30s --readiness-timeout 60s")
require.Error(t, err)
require.Contains(t, err.Error(), "--readiness-timeout (1m0s) must not exceed --timeout (30s)")
})
t.Run("readiness-timeout is ignored without ordered wait", func(t *testing.T) {
_, _, err := executeActionCommand("install plain-install testdata/testcharts/empty --readiness-timeout 30s")
require.NoError(t, err)
})
// Regression for hip-0025: a plain install with --timeout below the old
// 1m readiness-timeout default and no --readiness-timeout / --wait=ordered
// must not be rejected by the readiness-timeout<=timeout guard.
t.Run("plain install with sub-minute timeout and no readiness flag succeeds", func(t *testing.T) {
_, _, err := executeActionCommand("install short-timeout testdata/testcharts/empty --timeout 30s")
require.NoError(t, err)
})
}
func TestPostRendererFlagSetOnce(t *testing.T) {
cfg := action.Configuration{}
client := action.NewInstall(&cfg)

@ -34,6 +34,11 @@ This command fetches the generated manifest for a given release.
A manifest is a YAML-encoded representation of the Kubernetes resources that
were generated from this release's chart(s). If a chart is dependent on other
charts, those resources will also be included in the manifest.
The manifest is printed exactly as stored, including Helm-internal sequencing
annotations such as helm.sh/depends-on/resource-groups. They are part of the
release record and drive sequenced uninstall/rollback. Use 'helm template' for
apply-ready output.
`
func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {

@ -17,6 +17,7 @@ limitations under the License.
package cmd
import (
"strings"
"testing"
release "helm.sh/helm/v4/pkg/release/v1"
@ -37,6 +38,40 @@ func TestGetManifest(t *testing.T) {
runTestCmd(t, tests)
}
func TestGetManifestPrintsStoredManifestVerbatim(t *testing.T) {
const annotationLine = ` helm.sh/depends-on/resource-groups: '["db"]'`
manifest := `apiVersion: v1
kind: ConfigMap
metadata:
name: sequenced
annotations:
helm.sh/resource-group: app
` + annotationLine + `
data:
key: value
`
rel := release.Mock(&release.MockReleaseOptions{Name: "sequenced"})
rel.Manifest = manifest
store := storageFixture()
if err := store.Create(rel); err != nil {
t.Fatal(err)
}
_, out, err := executeActionCommandC(store, "get manifest sequenced")
if err != nil {
t.Fatal(err)
}
// Invariant pin: get manifest prints the stored release record verbatim,
// including sequencing annotations that template output strips.
if !strings.Contains(out, "helm.sh/depends-on/resource-groups") {
t.Fatalf("expected stored sequencing annotation key in output:\n%s", out)
}
if !strings.Contains(out, annotationLine) {
t.Fatalf("expected exact stored sequencing annotation line in output:\n%s", out)
}
}
func TestGetManifestCompletion(t *testing.T) {
checkReleaseCompletion(t, "get manifest", false)
}

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

@ -267,6 +267,7 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg
cmd.AddCommand(
// chart commands
newCreateCmd(out),
newDagCmd(actionConfig, out),
newDependencyCmd(actionConfig, out),
newPullCmd(actionConfig, out),
newShowCmd(actionConfig, out),

@ -22,6 +22,7 @@ import (
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"regexp"
@ -35,8 +36,11 @@ import (
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/cli/values"
"helm.sh/helm/v4/pkg/cmd/require"
"helm.sh/helm/v4/pkg/kube"
"helm.sh/helm/v4/pkg/release/v1/sequence"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
@ -105,6 +109,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
client.Replace = true // Skip the name check
client.APIVersions = common.VersionSet(extraAPIs)
client.IncludeCRDs = includeCrds
orderedTemplateOutput := client.WaitStrategy == kube.OrderedWaitStrategy && len(showFiles) == 0 && client.OutputDir == ""
rel, err := runInstall(args, client, valueOpts, out)
if err != nil && !settings.Debug {
@ -118,87 +123,108 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
// We ignore a potential error here because, when the --debug flag was specified,
// we always want to print the YAML, even if it is not valid. The error is still returned afterwards.
if rel != nil {
var manifests bytes.Buffer
fmt.Fprintln(&manifests, strings.TrimSpace(rel.Manifest))
if !client.DisableHooks {
fileWritten := make(map[string]bool)
for _, m := range rel.Hooks {
if skipTests && isTestHook(m) {
continue
}
if client.OutputDir == "" {
fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest)
} else {
newDir := client.OutputDir
if client.UseReleaseName {
newDir = filepath.Join(client.OutputDir, client.ReleaseName)
}
_, err := os.Stat(filepath.Join(newDir, m.Path))
if err == nil {
fileWritten[m.Path] = true
}
err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path])
if err != nil {
return err
orderedRendered := false
if orderedTemplateOutput {
if renderErr := renderOrderedTemplate(rel.Chart, strings.TrimSpace(rel.Manifest), out); renderErr != nil {
// Honor the --debug contract: always print the manifests, even if
// ordered rendering fails (e.g., a document fails YAML structural
// parsing). Fall back to the flat path with a stderr warning.
fmt.Fprintf(os.Stderr, "WARNING: ordered template rendering failed (%v); falling back to flat output\n", renderErr)
} else {
orderedRendered = true
if !client.DisableHooks {
for _, m := range rel.Hooks {
if skipTests && isTestHook(m) {
continue
}
fmt.Fprintf(out, "---\n# Source: %s\n%s\n", m.Path, releaseutil.StripHelmInternalAnnotations(m.Manifest))
}
}
}
}
// if we have a list of files to render, then check that each of the
// provided files exists in the chart.
if len(showFiles) > 0 {
// This is necessary to ensure consistent manifest ordering when using --show-only
// with globs or directory names.
splitManifests := releaseutil.SplitManifests(manifests.String())
manifestsKeys := make([]string, 0, len(splitManifests))
for k := range splitManifests {
manifestsKeys = append(manifestsKeys, k)
}
sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys))
manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)")
var manifestsToRender []string
for _, f := range showFiles {
missing := true
// Use linux-style filepath separators to unify user's input path
f = filepath.ToSlash(f)
for _, manifestKey := range manifestsKeys {
manifest := splitManifests[manifestKey]
submatch := manifestNameRegex.FindStringSubmatch(manifest)
if len(submatch) == 0 {
if !orderedRendered {
var manifests bytes.Buffer
fmt.Fprintln(&manifests, strings.TrimSpace(releaseutil.StripHelmInternalAnnotations(rel.Manifest)))
if !client.DisableHooks {
fileWritten := make(map[string]bool)
for _, m := range rel.Hooks {
if skipTests && isTestHook(m) {
continue
}
manifestName := submatch[1]
// manifest.Name is rendered using linux-style filepath separators on Windows as
// well as macOS/linux.
manifestPathSplit := strings.Split(manifestName, "/")
// manifest.Path is connected using linux-style filepath separators on Windows as
// well as macOS/linux
manifestPath := strings.Join(manifestPathSplit, "/")
// if the filepath provided matches a manifest path in the
// chart, render that manifest
if matched, _ := filepath.Match(f, manifestPath); !matched {
continue
if client.OutputDir == "" {
fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, releaseutil.StripHelmInternalAnnotations(m.Manifest))
} else {
newDir := client.OutputDir
if client.UseReleaseName {
newDir = filepath.Join(client.OutputDir, client.ReleaseName)
}
_, err := os.Stat(filepath.Join(newDir, m.Path))
if err == nil {
fileWritten[m.Path] = true
}
err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path])
if err != nil {
return err
}
}
manifestsToRender = append(manifestsToRender, manifest)
missing = false
}
if missing {
if installErr != nil && settings.Debug {
// assume the manifest itself is too malformed to be rendered
return installErr
}
// if we have a list of files to render, then check that each of the
// provided files exists in the chart.
if len(showFiles) > 0 {
// This is necessary to ensure consistent manifest ordering when using --show-only
// with globs or directory names.
splitManifests := releaseutil.SplitManifests(manifests.String())
manifestsKeys := make([]string, 0, len(splitManifests))
for k := range splitManifests {
manifestsKeys = append(manifestsKeys, k)
}
sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys))
manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)")
var manifestsToRender []string
for _, f := range showFiles {
missing := true
// Use linux-style filepath separators to unify user's input path
f = filepath.ToSlash(f)
for _, manifestKey := range manifestsKeys {
manifest := splitManifests[manifestKey]
submatch := manifestNameRegex.FindStringSubmatch(manifest)
if len(submatch) == 0 {
continue
}
manifestName := submatch[1]
// manifest.Name is rendered using linux-style filepath separators on Windows as
// well as macOS/linux.
manifestPathSplit := strings.Split(manifestName, "/")
// manifest.Path is connected using linux-style filepath separators on Windows as
// well as macOS/linux
manifestPath := strings.Join(manifestPathSplit, "/")
// if the filepath provided matches a manifest path in the
// chart, render that manifest
if matched, _ := filepath.Match(f, manifestPath); !matched {
continue
}
manifestsToRender = append(manifestsToRender, manifest)
missing = false
}
if missing {
if installErr != nil && settings.Debug {
// assume the manifest itself is too malformed to be rendered
return installErr
}
return fmt.Errorf("could not find template %s in chart", f)
}
return fmt.Errorf("could not find template %s in chart", f)
}
for _, m := range manifestsToRender {
fmt.Fprintf(out, "---\n%s\n", m)
}
} else {
fmt.Fprintf(out, "%s", manifests.String())
}
for _, m := range manifestsToRender {
fmt.Fprintf(out, "---\n%s\n", m)
}
} else {
fmt.Fprintf(out, "%s", manifests.String())
}
}
@ -229,6 +255,80 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
return cmd
}
func renderOrderedTemplate(chrt *chart.Chart, manifest string, out io.Writer) error {
if manifest == "" {
return nil
}
manifests, err := sequence.ParseStoredManifests(manifest)
if err != nil {
// Return the parse error so the caller falls back to the flat-output
// path, which strips Helm-internal annotations before emitting. Writing
// the raw manifest here would re-emit stripped sequencing annotations
// (e.g. helm.sh/depends-on/resource-groups) and break the invariant that
// `helm template` output stays directly apply-able. No output has been
// written to `out` yet at this point, so the fallback cannot duplicate.
return err
}
plan, err := sequence.Build(chrt, manifests)
if err != nil {
// Return the plan error so the caller falls back to the flat-output
// path, preserving `helm template`'s apply-ready annotation stripping
// contract while still surfacing cycles or invalid multi-group resources.
return err
}
logSequencePlanWarnings(plan)
// Render into a buffer so we can normalize trailing whitespace to match
// the flat path, which TrimSpaces the whole manifest blob then writes a
// single trailing newline (template.go flat branch). Per-manifest emission
// would otherwise leave one trailing blank line after the final document,
// breaking the HIP-0025 byte-for-byte backwards-compat guarantee for charts
// with no sequencing annotations.
var buf bytes.Buffer
for _, batch := range plan.Batches {
switch batch.Kind {
case sequence.BatchKindGroups:
for _, group := range batch.Groups {
fmt.Fprintf(&buf, "## START resource-group: %s %s\n", sequence.DisplayPath(batch.ChartPath), group.Name)
for _, manifest := range group.Manifests {
writeOrderedManifest(&buf, manifest.Content)
}
fmt.Fprintf(&buf, "## END resource-group: %s %s\n", sequence.DisplayPath(batch.ChartPath), group.Name)
}
case sequence.BatchKindUnsequenced:
for _, manifest := range batch.Manifests() {
writeOrderedManifest(&buf, manifest.Content)
}
}
}
_, err = fmt.Fprintln(out, strings.TrimRight(buf.String(), "\n"))
return err
}
// logSequencePlanWarnings surfaces non-fatal sequencing-plan warnings with the
// same shape as the action layer's logPlanWarnings.
func logSequencePlanWarnings(plan *sequence.Plan) {
for _, w := range plan.Warnings {
slog.Warn("sequencing: "+w.Message, "chart", w.ChartPath)
}
}
// writeOrderedManifest emits a single manifest document with the same
// inter-document whitespace as the flat `helm template` path
// (pkg/action/action.go), which writes "---\n# Source: %s\n%s\n" per
// manifest using renderer-supplied content that always ends with a newline.
// SplitManifests strips trailing newlines from intermediate chunks, so we
// normalize to exactly one trailing newline here before adding the format's
// own trailing "\n" — producing "---\nCONTENT\n\n" between docs and keeping
// `helm template` byte-identical to default mode for charts with no
// sequencing annotations (HIP-0025 backwards-compat guarantee, S04-05).
func writeOrderedManifest(out io.Writer, content string) {
stripped := releaseutil.StripHelmInternalAnnotations(content)
stripped = strings.TrimRight(stripped, "\n") + "\n"
fmt.Fprintf(out, "---\n%s\n", stripped)
}
func isTestHook(h *release.Hook) bool {
return slices.Contains(h.Events, release.HookTest)
}

@ -17,9 +17,16 @@ limitations under the License.
package cmd
import (
"bytes"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/release/v1/sequence"
)
var chartPath = "testdata/testcharts/subchart"
@ -172,10 +179,175 @@ func TestTemplateCmd(t *testing.T) {
cmd: fmt.Sprintf("template '%s' -f %s/extra_values.yaml", chartPath, chartPath),
golden: "output/template-subchart-cm-set-file.txt",
},
{
name: "template with ordered wait strategy shows resource group delimiters",
cmd: "template --wait=ordered 'testdata/testcharts/sequenced-chart'",
golden: "output/template-ordered-delimiters.txt",
},
{
name: "template with ordered wait strategy demotes isolated groups",
cmd: "template --wait=ordered 'testdata/testcharts/sequenced-isolated-chart'",
golden: "output/template-ordered-isolated.txt",
},
}
runTestCmd(t, tests)
}
func TestTemplateWithoutOrderedWaitHasNoDelimiters(t *testing.T) {
_, out, err := executeActionCommand("template 'testdata/testcharts/sequenced-chart'")
require.NoError(t, err)
require.NotContains(t, out, "## START resource-group:")
require.NotContains(t, out, "## END resource-group:")
require.Contains(t, out, "# Source: sequenced-chart/charts/worker/templates/aa-worker-configmap.yaml")
}
// TestTemplateOrderedBackwardsCompat asserts the HIP-0025 backwards-compat
// invariant: for a chart with no sequencing annotations, `helm template` and
// `helm template --wait=ordered` produce the same manifest body (after
// stripping the `## START`/`## END` resource-group delimiter lines that only
// the ordered path emits). This is the unit-test analogue of harness scenario
// S04-05; it regressed twice — once when the ordered path's per-manifest
// emission used tighter inter-document whitespace than the flat path, and once
// when the fix for that left an extra trailing blank line at EOF. The trailing
// whitespace is deliberately NOT trimmed here so this test catches both — the
// shell harness's `$()` substitution masks trailing-newline drift, so a stricter
// comparison belongs at the unit level.
func TestTemplateOrderedBackwardsCompat(t *testing.T) {
const chartPath = "testdata/testcharts/alpine"
_, flat, err := executeActionCommand("template t " + chartPath)
require.NoError(t, err)
_, ordered, err := executeActionCommand("template t " + chartPath + " --wait=ordered")
require.NoError(t, err)
// Strip only the marker lines the ordered path adds; preserve all other
// whitespace including the trailing newline structure.
stripMarkers := func(s string) string {
lines := strings.Split(s, "\n")
out := lines[:0]
for _, l := range lines {
if strings.HasPrefix(l, "## START resource-group:") ||
strings.HasPrefix(l, "## END resource-group:") {
continue
}
out = append(out, l)
}
return strings.Join(out, "\n")
}
require.Equal(t, stripMarkers(flat), stripMarkers(ordered),
"ordered template output must be byte-identical (incl. trailing newline) to flat output for charts without sequencing annotations")
}
// TestTemplateStripsHelmInternalAnnotations asserts that `helm template` output
// never contains the multi-slash internal annotation key
// `helm.sh/depends-on/resource-groups` — its presence in the K8s API would
// fail annotation-key validation and break `helm template | kubectl apply -f -`.
// Valid sibling keys (single-slash) like `helm.sh/resource-group` must survive.
func TestTemplateStripsHelmInternalAnnotations(t *testing.T) {
const internalKey = "helm.sh/depends-on/resource-groups"
const siblingKey = "helm.sh/resource-group"
t.Run("flat path", func(t *testing.T) {
_, out, err := executeActionCommand("template 'testdata/testcharts/sequenced-chart'")
require.NoError(t, err)
require.NotContains(t, out, internalKey, "internal annotation must be stripped from flat template output")
require.Contains(t, out, siblingKey, "valid-key sibling annotation must be preserved")
})
t.Run("ordered path", func(t *testing.T) {
_, out, err := executeActionCommand("template --wait=ordered 'testdata/testcharts/sequenced-chart'")
require.NoError(t, err)
require.NotContains(t, out, internalKey, "internal annotation must be stripped from ordered template output")
require.Contains(t, out, siblingKey, "valid-key sibling annotation must be preserved")
require.Contains(t, out, "## START resource-group:", "ordered output must still emit group delimiters")
})
}
func TestTemplateOrderedMatchesPlan(t *testing.T) {
manifest := `# Source: parent/templates/aa-alpha.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-alpha
annotations:
helm.sh/resource-group: alpha
---
# Source: parent/templates/bb-beta.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-beta
annotations:
helm.sh/resource-group: beta
helm.sh/depends-on/resource-groups: '["alpha"]'
---
# Source: parent/templates/cc-gamma.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-gamma
annotations:
helm.sh/resource-group: gamma
---
# Source: parent/templates/dd-plain.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-plain
---
# Source: parent/charts/vendored/templates/aa-vendored.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-vendored
annotations:
helm.sh/resource-group: vendored
`
chrt := &chart.Chart{Metadata: &chart.Metadata{Name: "parent"}}
manifests, err := sequence.ParseStoredManifests(manifest)
require.NoError(t, err)
plan, err := sequence.Build(chrt, manifests)
require.NoError(t, err)
var out bytes.Buffer
require.NoError(t, renderOrderedTemplate(chrt, manifest, &out))
require.Equal(t, orderedTemplateSequenceFromPlan(plan), orderedTemplateSequenceFromOutput(out.String()))
}
func orderedTemplateSequenceFromPlan(plan *sequence.Plan) []string {
var ordered []string
for _, batch := range plan.Batches {
switch batch.Kind {
case sequence.BatchKindGroups:
for _, group := range batch.Groups {
ordered = append(ordered, "group:"+sequence.DisplayPath(batch.ChartPath)+" "+group.Name)
for _, manifest := range group.Manifests {
ordered = append(ordered, "source:"+manifest.Name)
}
}
case sequence.BatchKindUnsequenced:
for _, manifest := range batch.Manifests() {
ordered = append(ordered, "source:"+manifest.Name)
}
}
}
return ordered
}
func orderedTemplateSequenceFromOutput(output string) []string {
var ordered []string
for line := range strings.SplitSeq(output, "\n") {
if marker, ok := strings.CutPrefix(line, "## START resource-group: "); ok {
ordered = append(ordered, "group:"+marker)
continue
}
if source, ok := strings.CutPrefix(line, "# Source: "); ok {
ordered = append(ordered, "source:"+source)
}
}
return ordered
}
func TestTemplateVersionCompletion(t *testing.T) {
repoFile := "testdata/helmhome/helm/repositories.yaml"
repoCache := "testdata/helmhome/helm/repository"

@ -0,0 +1,11 @@
Chart: sequenced-chart
Subchart batches:
Batch 1: worker
Resource-group batches:
Batch 1: databases
Batch 2: app
Unsequenced (deployed last): ConfigMap/unsequenced-config
Chart: sequenced-chart/charts/worker
Subchart batches: (none)
Resource-group batches:
Batch 1: bootstrap

@ -0,0 +1,6 @@
Chart: sequenced-isolated-chart
Subchart batches: (none)
Resource-group batches:
Batch 1: alpha
Batch 2: beta
Unsequenced (deployed last): ConfigMap/cm-gamma, ConfigMap/cm-plain

@ -0,0 +1,47 @@
## START resource-group: sequenced-chart/worker bootstrap
---
# Source: sequenced-chart/charts/worker/templates/aa-worker-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: worker-bootstrap
annotations:
helm.sh/resource-group: bootstrap
data:
worker: ready
## END resource-group: sequenced-chart/worker bootstrap
## START resource-group: sequenced-chart databases
---
# Source: sequenced-chart/templates/aa-databases-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: database-config
annotations:
helm.sh/resource-group: databases
data:
host: localhost
## END resource-group: sequenced-chart databases
## START resource-group: sequenced-chart app
---
# Source: sequenced-chart/templates/bb-app-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
annotations:
helm.sh/resource-group: app
data:
db_host: localhost
## END resource-group: sequenced-chart app
---
# Source: sequenced-chart/templates/cc-unsequenced-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: unsequenced-config
data:
key: value

@ -0,0 +1,45 @@
## START resource-group: sequenced-isolated-chart alpha
---
# Source: sequenced-isolated-chart/templates/aa-alpha.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-alpha
annotations:
helm.sh/resource-group: alpha
data:
key: alpha
## END resource-group: sequenced-isolated-chart alpha
## START resource-group: sequenced-isolated-chart beta
---
# Source: sequenced-isolated-chart/templates/bb-beta.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-beta
annotations:
helm.sh/resource-group: beta
data:
key: beta
## END resource-group: sequenced-isolated-chart beta
---
# Source: sequenced-isolated-chart/templates/dd-plain.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-plain
data:
key: plain
---
# Source: sequenced-isolated-chart/templates/cc-gamma.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-gamma
annotations:
helm.sh/resource-group: gamma
data:
key: gamma

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

@ -0,0 +1,5 @@
apiVersion: v2
name: sequenced-isolated-chart
description: A test chart with an isolated resource group
type: application
version: 0.1.0

@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-alpha
annotations:
helm.sh/resource-group: alpha
data:
key: alpha

@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-beta
annotations:
helm.sh/resource-group: beta
helm.sh/depends-on/resource-groups: '["alpha"]'
data:
key: beta

@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-gamma
annotations:
helm.sh/resource-group: gamma
data:
key: gamma

@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-plain
data:
key: plain

@ -81,7 +81,7 @@ func newUninstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.StringVar(&client.DeletionPropagation, "cascade", "background", "Must be \"background\", \"orphan\", or \"foreground\". Selects the deletion cascading strategy for the dependents. Defaults to background. Use \"foreground\" with --wait to ensure resources with finalizers are fully deleted before returning.")
f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)")
f.StringVar(&client.Description, "description", "", "add a custom description")
AddWaitFlag(cmd, &client.WaitStrategy)
AddOrderedWaitFlag(cmd, &client.WaitStrategy)
return cmd
}

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

@ -139,6 +139,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
instClient.SkipCRDs = client.SkipCRDs
instClient.Timeout = client.Timeout
instClient.WaitStrategy = client.WaitStrategy
instClient.ReadinessTimeout = client.ReadinessTimeout
instClient.WaitForJobs = client.WaitForJobs
instClient.Devel = client.Devel
instClient.Namespace = client.Namespace
@ -305,7 +306,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
addValueOptionsFlags(f, valueOpts)
bindOutputFlag(cmd, &outfmt)
bindPostRenderFlag(cmd, &client.PostRenderer, settings)
AddWaitFlag(cmd, &client.WaitStrategy)
AddOrderedWaitFlag(cmd, &client.WaitStrategy)
addReadinessTimeoutFlag(f, &client.ReadinessTimeout)
cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts")
cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts")

@ -32,6 +32,7 @@ import (
"sync"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
v1 "k8s.io/api/core/v1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
@ -117,6 +118,10 @@ const (
// HookOnlyStrategy: wait only for hook Pods/Jobs to complete; does not wait for general chart resources.
HookOnlyStrategy WaitStrategy = "hookOnly"
// OrderedWaitStrategy: action-layer sequencing uses ordered batches while the kube layer
// reuses status watching for per-batch readiness checks.
OrderedWaitStrategy WaitStrategy = "ordered"
)
type FieldValidationDirective string
@ -166,6 +171,7 @@ func (c *Client) newStatusWatcher(opts ...WaitOption) (*statusWaiter, error) {
if waitContext == nil {
waitContext = c.WaitContext
}
readers := append([]engine.StatusReader(nil), o.statusReaders...)
sw := &statusWaiter{
restMapper: restMapper,
client: dynamicClient,
@ -174,7 +180,8 @@ func (c *Client) newStatusWatcher(opts ...WaitOption) (*statusWaiter, error) {
waitCtx: o.waitCtx,
waitWithJobsCtx: o.waitWithJobsCtx,
waitForDeleteCtx: o.waitForDeleteCtx,
readers: o.statusReaders,
readers: readers,
customReadiness: o.enableCustomReadinessStatusReader,
}
sw.SetLogger(c.Logger().Handler())
return sw, nil
@ -200,10 +207,12 @@ func (c *Client) GetWaiterWithOptions(strategy WaitStrategy, opts ...WaitOption)
return nil, err
}
return &hookOnlyWaiter{sw: sw}, nil
case OrderedWaitStrategy:
return c.newStatusWatcher(opts...)
case "":
return nil, errors.New("wait strategy not set. Choose one of: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy))
return nil, errors.New("wait strategy not set. Choose one of: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy) + ", " + string(OrderedWaitStrategy))
default:
return nil, errors.New("unknown wait strategy (s" + string(strategy) + "). Valid values are: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy))
return nil, errors.New("unknown wait strategy (" + string(strategy) + "). Valid values are: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy) + ", " + string(OrderedWaitStrategy))
}
}

@ -0,0 +1,54 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package kube
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClientGetWaiterWithOptions_OrderedWaitStrategy(t *testing.T) {
client := newTestClient(t)
waiter, err := client.GetWaiterWithOptions(OrderedWaitStrategy)
require.NoError(t, err)
assert.IsType(t, &statusWaiter{}, waiter)
}
func TestClientGetWaiterWithOptions_UnknownStrategyListsOrdered(t *testing.T) {
client := newTestClient(t)
waiter, err := client.GetWaiterWithOptions(WaitStrategy("bogus"))
require.Error(t, err)
assert.Nil(t, waiter)
assert.Contains(t, err.Error(), string(OrderedWaitStrategy))
}
func TestNewStatusWatcherCustomReadinessFlag(t *testing.T) {
client := newTestClient(t)
w, err := client.newStatusWatcher(WithCustomReadinessStatusReader())
require.NoError(t, err)
assert.True(t, w.customReadiness)
assert.Empty(t, w.readers, "custom readiness must not inject a reader into the shared reader list")
w, err = client.newStatusWatcher()
require.NoError(t, err)
assert.False(t, w.customReadiness)
}

@ -0,0 +1,141 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package kube // import "helm.sh/helm/v4/pkg/kube"
import (
"context"
"fmt"
"log/slog"
"sync"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
)
type customReadinessStatusReader struct {
logger *slog.Logger
// fallback is the reader chain the enclosing wait method would use if
// custom readiness were disabled. Resources that do not carry both
// readiness annotations are delegated to it, so built-in readers (the
// composite Deployment/ReplicaSet/StatefulSet readers, the Job reader in
// WaitWithJobs) and caller-supplied readers keep their semantics.
fallback engine.StatusReader
// warnedExpressions dedups authoring-mistake warnings so a bad expression
// logs once per resource rather than on every watch event. Keys are
// "<ObjMetadata.String()>|<expression>". The reader is invoked
// concurrently by the status watcher, hence sync.Map.
warnedExpressions sync.Map
}
// newCustomReadinessStatusReader wraps fallback with per-resource custom
// readiness evaluation. fallback must be non-nil and must support every
// GroupKind this reader can receive (every chain built in statuswait.go
// terminates in a generic reader that supports all GroupKinds).
func newCustomReadinessStatusReader(logger *slog.Logger, fallback engine.StatusReader) engine.StatusReader {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
return &customReadinessStatusReader{logger: logger, fallback: fallback}
}
// Supports returns true for every GroupKind: annotated resources of any kind
// are evaluated here, and everything else is delegated to the fallback chain.
func (*customReadinessStatusReader) Supports(schema.GroupKind) bool {
return true
}
func (r *customReadinessStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) {
u := &unstructured.Unstructured{}
u.SetGroupVersionKind(schema.GroupVersionKind{
Group: resource.GroupKind.Group,
Kind: resource.GroupKind.Kind,
})
if err := reader.Get(ctx, client.ObjectKey{Namespace: resource.Namespace, Name: resource.Name}, u); err != nil {
return nil, err
}
return r.readStatus(ctx, reader, resource, u)
}
func (r *customReadinessStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
identifier, err := object.RuntimeToObjMeta(resource)
if err != nil {
return nil, err
}
return r.readStatus(ctx, reader, identifier, resource)
}
func (r *customReadinessStatusReader) readStatus(ctx context.Context, reader engine.ClusterReader, identifier object.ObjMetadata, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
annotations := resource.GetAnnotations()
successExprs, err := parseReadinessExpressions(annotations[AnnotationReadinessSuccess])
if err != nil {
return nil, fmt.Errorf("parsing %s for %s/%s: %w", AnnotationReadinessSuccess, identifier.Namespace, identifier.Name, err)
}
failureExprs, err := parseReadinessExpressions(annotations[AnnotationReadinessFailure])
if err != nil {
return nil, fmt.Errorf("parsing %s for %s/%s: %w", AnnotationReadinessFailure, identifier.Namespace, identifier.Name, err)
}
result, useKstatus, warnings, err := EvaluateCustomReadiness(resource, successExprs, failureExprs)
if err != nil {
return nil, err
}
if useKstatus {
// Missing or partial annotations: this resource does not opt in to
// custom readiness, so the default reader chain decides its status.
return r.fallback.ReadStatusForObject(ctx, reader, resource)
}
for _, w := range warnings {
key := identifier.String() + "|" + w.Expression
if _, alreadyWarned := r.warnedExpressions.LoadOrStore(key, struct{}{}); alreadyWarned {
continue
}
r.logger.Warn("custom readiness expression cannot be evaluated; treating condition as not met",
"kind", identifier.GroupKind.Kind,
"namespace", identifier.Namespace,
"name", identifier.Name,
"expression", w.Expression,
"detail", w.Detail,
)
}
st, message := status.InProgressStatus, "waiting for custom readiness conditions"
switch result {
case ReadinessReady:
st, message = status.CurrentStatus, "custom readiness conditions met"
case ReadinessFailed:
st, message = status.FailedStatus, "custom readiness failure condition met"
default:
// ReadinessPending (and any future status) keep the in-progress
// defaults; name any skipped expression so an eventual wait timeout
// error is self-explanatory.
if len(warnings) > 0 {
message = fmt.Sprintf("waiting for custom readiness conditions (%d expression(s) skipped: %s)", len(warnings), warnings[0].Detail)
}
}
return &event.ResourceStatus{
Identifier: identifier,
Status: st,
Message: message,
}, nil
}

@ -0,0 +1,617 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package kube
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"time"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/clusterreader"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
"github.com/fluxcd/cli-utils/pkg/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/kubernetes/scheme"
)
func TestStatusWaitWithCustomReadinessReader(t *testing.T) {
t.Parallel()
tests := []struct {
name string
manifest string
expectErrStrs []string
notExpectErrStrs []string
}{
{
name: "custom readiness makes resource current",
manifest: `
apiVersion: v1
kind: ConfigMap
metadata:
name: ready-config
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
status:
phase: Ready
`,
},
{
name: "custom readiness failure stops wait",
manifest: `
apiVersion: v1
kind: ConfigMap
metadata:
name: failed-config
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
status:
phase: Failed
`,
expectErrStrs: []string{"resource ConfigMap/default/failed-config not ready. status: Failed"},
},
{
name: "resources without annotations fall back to kstatus",
manifest: `
apiVersion: v1
kind: ConfigMap
metadata:
name: current-config
namespace: default
`,
},
{
// END-TO-END pin for hip-0025-gy6: an ordering operator on a
// non-numeric value must leave the resource InProgress (and let
// the wait time out per-resource) — NOT kill the status reporter
// and abort the whole batch.
name: "ordering operator on non-numeric value does not abort the wait",
manifest: `
apiVersion: v1
kind: ConfigMap
metadata:
name: bad-ordering
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} > \"Ready\""]'
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
status:
phase: Running
`,
expectErrStrs: []string{"resource ConfigMap/default/bad-ordering not ready. status: InProgress", "skipped"},
notExpectErrStrs: []string{"failed to compute object status"},
},
{
name: "ordering on numeric string value compares numerically",
manifest: `
apiVersion: v1
kind: ConfigMap
metadata:
name: numeric-string
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} >= 5"]'
helm.sh/readiness-failure: '["{.failed} >= 1"]'
status:
phase: "5"
`,
},
{
name: "incomparable expression skipped when another success condition is met",
manifest: `
apiVersion: v1
kind: ConfigMap
metadata:
name: mixed-success
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} > \"Ready\"", "{.phase} == \"Running\""]'
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
status:
phase: Running
`,
},
{
// Partial annotations (only one of success/failure): custom
// evaluation must NOT engage; the resource is judged by the
// default chain (a bare ConfigMap is Current), even though the
// success expression is unmet.
name: "partial annotation falls back to default readiness",
manifest: `
apiVersion: v1
kind: ConfigMap
metadata:
name: partial-config
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
status:
phase: NotReady
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := newTestClient(t)
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
fakeMapper := testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))
waiter := statusWaiter{
client: fakeClient,
restMapper: fakeMapper,
customReadiness: true,
}
objs := getRuntimeObjFromManifests(t, []string{tt.manifest})
for _, obj := range objs {
u := obj.(*unstructured.Unstructured)
gvr := getGVR(t, fakeMapper, u)
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
}
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
err := waiter.Wait(resourceList, time.Second)
if tt.expectErrStrs == nil {
require.NoError(t, err)
return
}
require.Error(t, err)
for _, expected := range tt.expectErrStrs {
assert.Contains(t, err.Error(), expected)
}
for _, unexpected := range tt.notExpectErrStrs {
assert.NotContains(t, err.Error(), unexpected)
}
})
}
}
func TestWithCustomReadinessStatusReader(t *testing.T) {
opts := &waitOptions{}
WithCustomReadinessStatusReader()(opts)
assert.True(t, opts.enableCustomReadinessStatusReader)
}
func TestCustomReadinessStatusReaderReadStatusForObject(t *testing.T) {
reader := newCustomReadinessStatusReader(nil, statusreaders.NewDefaultStatusReader(testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))))
u := &unstructured.Unstructured{}
u.SetAPIVersion("v1")
u.SetKind("ConfigMap")
u.SetName("ready-config")
u.SetNamespace("default")
u.SetAnnotations(map[string]string{
AnnotationReadinessSuccess: `["{.phase} == \"Ready\""]`,
AnnotationReadinessFailure: `["{.phase} == \"Failed\""]`,
})
require.NoError(t, unstructured.SetNestedField(u.Object, "Ready", "status", "phase"))
result, err := reader.ReadStatusForObject(context.Background(), nil, u)
require.NoError(t, err)
assert.Equal(t, status.CurrentStatus, result.Status)
}
func TestCustomReadinessStatusReaderWarnsOnceForIncomparableExpression(t *testing.T) {
var buf bytes.Buffer
reader := newCustomReadinessStatusReader(slog.New(slog.NewTextHandler(&buf, nil)), statusreaders.NewDefaultStatusReader(testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))))
u := &unstructured.Unstructured{}
u.SetAPIVersion("v1")
u.SetKind("ConfigMap")
u.SetName("bad-ordering")
u.SetNamespace("default")
u.SetAnnotations(map[string]string{
AnnotationReadinessSuccess: `["{.phase} > \"Ready\""]`,
AnnotationReadinessFailure: `["{.failed} >= 1"]`,
})
require.NoError(t, unstructured.SetNestedField(u.Object, "Running", "status", "phase"))
for range 2 {
result, err := reader.ReadStatusForObject(context.Background(), nil, u)
require.NoError(t, err)
assert.Equal(t, status.InProgressStatus, result.Status)
assert.Contains(t, result.Message, "skipped")
assert.Contains(t, result.Message, "ordering operators")
}
logged := buf.String()
assert.Equal(t, 1, strings.Count(logged, "treating condition as not met"),
"warning must be deduplicated to once per resource+expression")
assert.Contains(t, logged, "bad-ordering")
assert.Contains(t, logged, "ordering operators")
}
// The composite Deployment reader is the fallback observable: it populates
// GeneratedResources with the child ReplicaSets it aggregated, which neither
// the generic kstatus reader nor a bare status.Compute ever does. (In
// cli-utils v1.2.2 the composite readers compute the same top-level status as
// status.Compute, so GeneratedResources is the distinguishing signal that the
// default chain — not an internal kstatus shortcut — handled the resource.)
func TestCustomReadinessReaderDelegatesToFallbackChain(t *testing.T) {
t.Parallel()
deploymentYAML := func(annotations string) string {
return `
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: default
generation: 1` + annotations + `
spec:
replicas: 1
selector:
matchLabels:
app: web
status:
observedGeneration: 1
replicas: 1
updatedReplicas: 1
readyReplicas: 1
availableReplicas: 1
conditions:
- type: Available
status: "True"
reason: MinimumReplicasAvailable
`
}
replicaSetManifest := `
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: web-abc123
namespace: default
generation: 1
labels:
app: web
spec:
replicas: 1
selector:
matchLabels:
app: web
status:
observedGeneration: 1
replicas: 1
readyReplicas: 1
availableReplicas: 1
`
tests := []struct {
name string
annotations string
expectStatus status.Status
expectGeneratedResults bool
}{
{
// No annotations: must be delegated to the composite chain.
name: "non-annotated deployment handled by composite reader",
annotations: "",
expectStatus: status.CurrentStatus,
expectGeneratedResults: true,
},
{
// One annotation only: partial config must also delegate.
name: "partial-annotated deployment handled by composite reader",
annotations: `
annotations:
helm.sh/readiness-success: '["{.readyReplicas} >= 1"]'`,
expectStatus: status.CurrentStatus,
expectGeneratedResults: true,
},
{
// Both annotations: custom evaluation engages (unmet expression
// keeps it InProgress) and the fallback chain is bypassed.
name: "fully annotated deployment evaluated by expressions",
annotations: `
annotations:
helm.sh/readiness-success: '["{.readyReplicas} >= 2"]'
helm.sh/readiness-failure: '["{.failed} >= 1"]'`,
expectStatus: status.InProgressStatus,
expectGeneratedResults: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fakeMapper := testutil.NewFakeRESTMapper(
appsv1.SchemeGroupVersion.WithKind("Deployment"),
appsv1.SchemeGroupVersion.WithKind("ReplicaSet"),
v1.SchemeGroupVersion.WithKind("Pod"),
)
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
objs := getRuntimeObjFromManifests(t, []string{deploymentYAML(tt.annotations), replicaSetManifest})
for _, obj := range objs {
u := obj.(*unstructured.Unstructured)
gvr := getGVR(t, fakeMapper, u)
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
}
clusterReader := &clusterreader.DynamicClusterReader{
DynamicClient: fakeClient,
Mapper: fakeMapper,
}
reader := newCustomReadinessStatusReader(nil, statusreaders.NewStatusReader(fakeMapper))
deployment := objs[0].(*unstructured.Unstructured)
result, err := reader.ReadStatusForObject(context.Background(), clusterReader, deployment)
require.NoError(t, err)
assert.Equal(t, tt.expectStatus, result.Status)
if tt.expectGeneratedResults {
require.NotEmpty(t, result.GeneratedResources,
"fallback composite reader must aggregate the child ReplicaSet")
assert.Equal(t, "web-abc123", result.GeneratedResources[0].Identifier.Name)
} else {
assert.Empty(t, result.GeneratedResources)
assert.Contains(t, result.Message, "custom readiness")
}
// The identifier-based entry point must route identically.
id, err := object.RuntimeToObjMeta(deployment)
require.NoError(t, err)
byID, err := reader.ReadStatus(context.Background(), clusterReader, id)
require.NoError(t, err)
assert.Equal(t, tt.expectStatus, byID.Status)
assert.Equal(t, tt.expectGeneratedResults, len(byID.GeneratedResources) > 0)
})
}
}
// A plain (non-annotated) Job in a batch that enables custom readiness must
// keep helm's completion gating: helm's job reader holds a started Job
// InProgress until a Complete/Failed condition, while plain kstatus reports a
// started Job as Current. Before the fix the custom readiness reader shadowed
// the job reader and the wait returned early.
func TestStatusWaitWithJobsMixedCustomReadiness(t *testing.T) {
t.Parallel()
startedJobManifest := `
apiVersion: batch/v1
kind: Job
metadata:
name: slow-job
namespace: default
generation: 1
status:
startTime: 2025-02-06T16:34:20-05:00
active: 1
ready: 1
`
completeJobManifest := `
apiVersion: batch/v1
kind: Job
metadata:
name: slow-job
namespace: default
generation: 1
status:
succeeded: 1
conditions:
- type: Complete
status: "True"
`
readyConfigMapManifest := `
apiVersion: v1
kind: ConfigMap
metadata:
name: gated-config
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
status:
phase: Ready
`
pendingConfigMapManifest := `
apiVersion: v1
kind: ConfigMap
metadata:
name: gated-config
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
status:
phase: Pending
`
tests := []struct {
name string
manifests []string
expectErrStrs []string
notExpectErrStrs []string
}{
{
name: "plain job keeps completion gating in mixed batch",
manifests: []string{startedJobManifest, readyConfigMapManifest},
expectErrStrs: []string{
"resource Job/default/slow-job not ready. status: InProgress",
},
notExpectErrStrs: []string{"ConfigMap"},
},
{
name: "annotated resource keeps expression gating in mixed batch",
manifests: []string{completeJobManifest, pendingConfigMapManifest},
expectErrStrs: []string{
"resource ConfigMap/default/gated-config not ready. status: InProgress",
},
notExpectErrStrs: []string{"Job/default/slow-job"},
},
{
name: "batch completes when job is complete and expression met",
manifests: []string{completeJobManifest, readyConfigMapManifest},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := newTestClient(t)
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
fakeMapper := testutil.NewFakeRESTMapper(
batchv1.SchemeGroupVersion.WithKind("Job"),
v1.SchemeGroupVersion.WithKind("ConfigMap"),
)
waiter := statusWaiter{
client: fakeClient,
restMapper: fakeMapper,
customReadiness: true,
}
objs := getRuntimeObjFromManifests(t, tt.manifests)
for _, obj := range objs {
u := obj.(*unstructured.Unstructured)
gvr := getGVR(t, fakeMapper, u)
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
}
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
err := waiter.WaitWithJobs(resourceList, 2*time.Second)
if tt.expectErrStrs == nil {
require.NoError(t, err)
return
}
require.Error(t, err)
for _, expected := range tt.expectErrStrs {
assert.Contains(t, err.Error(), expected)
}
for _, unexpected := range tt.notExpectErrStrs {
assert.NotContains(t, err.Error(), unexpected)
}
})
}
}
// WatchUntilReady is the hook path: hook readiness must be governed by the
// hook readers (helm's job/pod readers + always-ready for everything else),
// never by the readiness annotations, even when custom readiness is enabled
// on the waiter. Two directions:
// - a started-but-incomplete hook Job must still time out (kstatus would
// wrongly report a started Job as Current);
// - an annotated non-Job hook must complete even though its success
// expression is unmet (annotations are ignored for hooks).
func TestWatchUntilReadyIgnoresCustomReadiness(t *testing.T) {
t.Parallel()
startedJobManifest := `
apiVersion: batch/v1
kind: Job
metadata:
name: hook-job
namespace: default
generation: 1
status:
startTime: 2025-02-06T16:34:20-05:00
active: 1
`
completeJobManifest := `
apiVersion: batch/v1
kind: Job
metadata:
name: hook-job
namespace: default
generation: 1
status:
succeeded: 1
conditions:
- type: Complete
status: "True"
`
annotatedConfigMapManifest := `
apiVersion: v1
kind: ConfigMap
metadata:
name: hook-config
namespace: default
annotations:
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
status:
phase: Pending
`
tests := []struct {
name string
manifests []string
expectErrStrs []string
}{
{
name: "incomplete hook job still gated by hook job reader",
manifests: []string{startedJobManifest},
expectErrStrs: []string{
"resource Job/default/hook-job not ready. status: InProgress",
},
},
{
name: "annotated hook resource ignores readiness annotations",
manifests: []string{annotatedConfigMapManifest},
},
{
name: "complete hook job succeeds",
manifests: []string{completeJobManifest},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := newTestClient(t)
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
fakeMapper := testutil.NewFakeRESTMapper(
batchv1.SchemeGroupVersion.WithKind("Job"),
v1.SchemeGroupVersion.WithKind("ConfigMap"),
)
waiter := statusWaiter{
client: fakeClient,
restMapper: fakeMapper,
customReadiness: true,
}
objs := getRuntimeObjFromManifests(t, tt.manifests)
for _, obj := range objs {
u := obj.(*unstructured.Unstructured)
gvr := getGVR(t, fakeMapper, u)
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
}
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
err := waiter.WatchUntilReady(resourceList, 2*time.Second)
if tt.expectErrStrs == nil {
require.NoError(t, err)
return
}
require.Error(t, err)
for _, expected := range tt.expectErrStrs {
assert.Contains(t, err.Error(), expected)
}
})
}
}

@ -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,369 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package kube // import "helm.sh/helm/v4/pkg/kube"
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/util/jsonpath"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
// The custom-readiness annotation keys' canonical home is
// pkg/release/v1/util, next to the other sequencing annotations, so the pure
// plan builder (pkg/release/v1/sequence) can read them without importing
// pkg/kube. These value-identical aliases keep existing kube callers working.
const (
// AnnotationReadinessSuccess declares custom readiness success conditions.
AnnotationReadinessSuccess = releaseutil.AnnotationReadinessSuccess
// AnnotationReadinessFailure declares custom readiness failure conditions.
// Failure conditions take precedence over success conditions.
AnnotationReadinessFailure = releaseutil.AnnotationReadinessFailure
)
// ReadinessStatus represents the evaluated readiness of a resource.
type ReadinessStatus int
const (
// ReadinessPending means neither success nor failure conditions are met.
ReadinessPending ReadinessStatus = iota
// ReadinessReady means at least one success condition is true.
ReadinessReady
// ReadinessFailed means at least one failure condition is true.
ReadinessFailed
)
func (r ReadinessStatus) String() string {
switch r {
case ReadinessPending:
return "Pending"
case ReadinessReady:
return "Ready"
case ReadinessFailed:
return "Failed"
default:
return "Unknown"
}
}
// ExpressionWarning describes a readiness expression that could not be
// evaluated against the observed object state and was therefore treated as
// "condition not met" rather than failing the whole wait.
type ExpressionWarning struct {
// Expression is the original annotation expression, e.g. `{.phase} > "Ready"`.
Expression string
// Detail is a human-readable reason, including the observed value.
Detail string
}
// errIncomparableOrdering marks a comparison that cannot succeed against the
// observed values: an ordering operator (<, <=, >, >=) applied to operands
// that are not both numeric. The actual value's type is only known at runtime,
// so lint cannot always catch this. EvaluateCustomReadiness downgrades it to
// an ExpressionWarning — the expression counts as "condition not met",
// mirroring how a missing status key is treated — instead of returning an
// error that would abort the entire wait.
var errIncomparableOrdering = errors.New("ordering operators (<, <=, >, >=) require numeric values")
// EvaluateCustomReadiness evaluates custom readiness expressions against a
// resource's .status field.
//
// When both successExprs and failureExprs are empty or nil, or when only one
// side is provided, the caller should fall back to kstatus and useKstatus will
// be returned as true.
//
// Expressions that cannot be evaluated against the observed values (an
// ordering operator applied to non-numeric operands) are treated as
// "condition not met" and reported in the returned warnings rather than as an
// error. Expressions after the first met condition are not evaluated. All
// other evaluation problems (invalid JSONPath, unsupported operator) are
// returned as errors.
func EvaluateCustomReadiness(obj *unstructured.Unstructured, successExprs, failureExprs []string) (ReadinessStatus, bool, []ExpressionWarning, error) {
hasSuccess := len(successExprs) > 0
hasFailure := len(failureExprs) > 0
if !hasSuccess || !hasFailure {
// Partial annotations: fall back to kstatus. The warning for this
// case is emitted once at plan-build time by the sequence plan
// builder (pkg/release/v1/sequence), not here (which runs on every
// poll tick).
return ReadinessPending, true, nil, nil
}
statusObj, found, err := unstructured.NestedMap(obj.Object, "status")
if err != nil || !found {
return ReadinessPending, false, nil, nil
}
statusWrapper := map[string]any{"status": statusObj}
var warnings []ExpressionWarning
for _, expr := range failureExprs {
met, err := evaluateExpression(statusWrapper, expr)
if err != nil {
if errors.Is(err, errIncomparableOrdering) {
warnings = append(warnings, ExpressionWarning{Expression: expr, Detail: err.Error()})
continue
}
return ReadinessPending, false, warnings, fmt.Errorf("evaluating failure expression %q: %w", expr, err)
}
if met {
return ReadinessFailed, false, warnings, nil
}
}
for _, expr := range successExprs {
met, err := evaluateExpression(statusWrapper, expr)
if err != nil {
if errors.Is(err, errIncomparableOrdering) {
warnings = append(warnings, ExpressionWarning{Expression: expr, Detail: err.Error()})
continue
}
return ReadinessPending, false, warnings, fmt.Errorf("evaluating success expression %q: %w", expr, err)
}
if met {
return ReadinessReady, false, warnings, nil
}
}
return ReadinessPending, false, warnings, nil
}
func parseReadinessExpressions(annotation string) ([]string, error) {
annotation = strings.TrimSpace(annotation)
if annotation == "" {
return nil, nil
}
var exprs []string
if err := json.Unmarshal([]byte(annotation), &exprs); err != nil {
return nil, fmt.Errorf("parsing readiness annotation JSON: %w", err)
}
for i, expr := range exprs {
exprs[i] = strings.TrimSpace(expr)
}
return exprs, nil
}
// ValidateReadinessExpressions parses a readiness annotation value (a JSON
// array of "{<jsonpath>} <op> <value>" expressions) and verifies that every
// expression is well-formed and compiles to a valid JSONPath. It does not
// evaluate anything against a live object — it is the static-validation entry
// point used by `helm lint` so malformed expressions are caught at author time
// rather than silently failing readiness at install time. An empty or absent
// annotation is valid (returns nil). It also rejects ordering operators
// (<, <=, >, >=) whose comparison value is not numeric, since such a
// comparison can never evaluate at runtime.
func ValidateReadinessExpressions(annotation string) error {
exprs, err := parseReadinessExpressions(annotation)
if err != nil {
return err
}
for _, expr := range exprs {
path, op, val, err := parseReadinessExpression(expr)
if err != nil {
return err
}
template, err := readinessJSONPath(path)
if err != nil {
return err
}
if err := jsonpath.New("readiness").Parse(template); err != nil {
return fmt.Errorf("invalid JSONPath %q: %w", template, err)
}
// An ordering comparison can only ever succeed numerically, and the
// literal's type is statically known: a non-numeric comparison value
// with <, <=, >, >= is a definite authoring error.
switch op {
case "<", "<=", ">", ">=":
if _, ok := tryParseFloat(trimReadinessValue(val)); !ok {
return fmt.Errorf("expression %q: ordering operator %q requires a numeric comparison value, got %s", expr, op, val)
}
}
}
return nil
}
func evaluateExpression(obj map[string]any, expr string) (bool, error) {
path, op, rawVal, err := parseReadinessExpression(expr)
if err != nil {
return false, err
}
template, err := readinessJSONPath(path)
if err != nil {
return false, err
}
jp := jsonpath.New("readiness")
if err := jp.Parse(template); err != nil {
return false, fmt.Errorf("invalid JSONPath %q: %w", template, err)
}
var buf bytes.Buffer
if err := jp.Execute(&buf, obj); err != nil {
return false, nil
}
return compareValues(strings.TrimSpace(buf.String()), op, rawVal)
}
func readinessJSONPath(path string) (string, error) {
switch {
case strings.HasPrefix(path, ".status.") || strings.HasPrefix(path, ".status["):
// Already rooted at .status — avoid double-prefixing
return "{" + path + "}", nil
case strings.HasPrefix(path, "."):
return "{.status" + path + "}", nil
case strings.HasPrefix(path, "["):
return "{.status" + path + "}", nil
default:
return "", fmt.Errorf("invalid JSONPath %q: path must start with . or [", path)
}
}
// parseReadinessExpression parses "{<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("cannot compare %q %s %q: %w", actual, op, expected, errIncomparableOrdering)
}
}
}
switch op {
case "==":
return actual == expected, nil
case "!=":
return actual != expected, nil
default:
return false, fmt.Errorf("cannot compare %q %s %q: %w", actual, op, expected, errIncomparableOrdering)
}
}
func trimReadinessValue(value string) string {
if len(value) >= 2 {
if value[0] == '"' && value[len(value)-1] == '"' {
return value[1 : len(value)-1]
}
if value[0] == '\'' && value[len(value)-1] == '\'' {
return value[1 : len(value)-1]
}
}
return value
}
func tryParseFloat(value string) (float64, bool) {
parsed, err := strconv.ParseFloat(value, 64)
return parsed, err == nil
}
func tryParseBool(value string) (bool, bool) {
parsed, err := strconv.ParseBool(value)
return parsed, err == nil
}
func compareNumeric(actual float64, op string, expected float64) (bool, error) {
switch op {
case "==":
return actual == expected, nil
case "!=":
return actual != expected, nil
case "<":
return actual < expected, nil
case "<=":
return actual <= expected, nil
case ">":
return actual > expected, nil
case ">=":
return actual >= expected, nil
default:
return false, fmt.Errorf("unknown operator %q", op)
}
}

@ -0,0 +1,526 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package kube
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func makeUnstructuredWithStatus(t *testing.T, statusFields map[string]any) *unstructured.Unstructured {
t.Helper()
obj := &unstructured.Unstructured{}
obj.SetAPIVersion("v1")
obj.SetKind("ConfigMap")
obj.SetName("test-resource")
if statusFields != nil {
require.NoError(t, unstructured.SetNestedField(obj.Object, statusFields, "status"))
}
return obj
}
func TestEvaluateReadiness_BothNil_UsesKstatus(t *testing.T) {
obj := makeUnstructuredWithStatus(t, nil)
result, useKstatus, _, err := EvaluateCustomReadiness(obj, nil, nil)
require.NoError(t, err)
assert.True(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
result, useKstatus, _, err = EvaluateCustomReadiness(obj, []string{}, []string{})
require.NoError(t, err)
assert.True(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
}
func TestEvaluateReadiness_OnlySuccess_FallsBackToKstatus(t *testing.T) {
obj := makeUnstructuredWithStatus(t, nil)
result, useKstatus, _, err := EvaluateCustomReadiness(obj, []string{`{.ready} == true`}, nil)
require.NoError(t, err)
assert.True(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
}
func TestEvaluateReadiness_OnlyFailure_FallsBackToKstatus(t *testing.T) {
obj := makeUnstructuredWithStatus(t, nil)
result, useKstatus, _, err := EvaluateCustomReadiness(obj, nil, []string{`{.failed} == true`})
require.NoError(t, err)
assert.True(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
}
func TestEvaluateReadiness_SuccessTrue(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{
"succeeded": int64(1),
})
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{`{.succeeded} == 1`},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessReady, result)
}
func TestEvaluateReadiness_FailurePrecedesSuccess(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{
"succeeded": int64(1),
"failed": int64(1),
})
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{`{.succeeded} == 1`},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessFailed, result)
}
func TestEvaluateReadiness_ORSemantics_AnySuccessTrue(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{
"phase": "Succeeded",
"another": "nope",
})
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{`{.another} == "yes"`, `{.phase} == "Succeeded"`},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessReady, result)
}
func TestEvaluateReadiness_NeitherConditionMet(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{
"succeeded": int64(0),
})
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{`{.succeeded} == 1`},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
}
func TestEvaluateReadiness_MissingStatusField(t *testing.T) {
t.Run("missing status object", func(t *testing.T) {
obj := makeUnstructuredWithStatus(t, nil)
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{`{.phase} == "Running"`},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
})
t.Run("missing status field", func(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{})
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{`{.phase} == "Running"`},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
})
}
func TestEvaluateReadiness_NumericComparisonOperators(t *testing.T) {
tests := []struct {
name string
expr string
status map[string]any
}{
{name: "equal", expr: `{.succeeded} == 1`, status: map[string]any{"succeeded": int64(1)}},
{name: "not equal", expr: `{.succeeded} != 2`, status: map[string]any{"succeeded": int64(1)}},
{name: "less than", expr: `{.succeeded} < 2`, status: map[string]any{"succeeded": int64(1)}},
{name: "less than or equal", expr: `{.succeeded} <= 1`, status: map[string]any{"succeeded": int64(1)}},
{name: "greater than", expr: `{.succeeded} > 0`, status: map[string]any{"succeeded": int64(1)}},
{name: "greater than or equal", expr: `{.succeeded} >= 1`, status: map[string]any{"succeeded": int64(1)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obj := makeUnstructuredWithStatus(t, tt.status)
result, useKstatus, warnings, err := EvaluateCustomReadiness(
obj,
[]string{tt.expr},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Empty(t, warnings)
assert.Equal(t, ReadinessReady, result)
})
}
}
func TestEvaluateReadiness_BooleanComparison(t *testing.T) {
tests := []struct {
name string
expr string
status map[string]any
}{
{name: "boolean equals", expr: `{.ready} == true`, status: map[string]any{"ready": true}},
{name: "boolean not equals", expr: `{.ready} != false`, status: map[string]any{"ready": true}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obj := makeUnstructuredWithStatus(t, tt.status)
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{tt.expr},
[]string{`{.failed} == true`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessReady, result)
})
}
}
func TestEvaluateReadiness_StringComparison(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{
"phase": "Running",
})
result, useKstatus, warnings, err := EvaluateCustomReadiness(
obj,
[]string{`{.phase} == "Running"`},
[]string{`{.phase} == "Failed"`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Empty(t, warnings)
assert.Equal(t, ReadinessReady, result)
result, useKstatus, warnings, err = EvaluateCustomReadiness(
obj,
[]string{`{.phase} != "Failed"`},
[]string{`{.phase} == "Failed"`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Empty(t, warnings)
assert.Equal(t, ReadinessReady, result)
}
func TestEvaluateReadiness_InvalidJSONPath(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{
"phase": "Running",
})
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{`{.phase[} == "Running"`},
[]string{`{.failed} >= 1`},
)
assert.ErrorContains(t, err, "invalid JSONPath")
assert.False(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
}
func TestEvaluateReadiness_InvalidOperator(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{
"phase": "Running",
})
result, useKstatus, _, err := EvaluateCustomReadiness(
obj,
[]string{`{.phase} <> "Running"`},
[]string{`{.failed} >= 1`},
)
assert.ErrorContains(t, err, "unsupported operator")
assert.False(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
}
func TestParseReadinessExpression(t *testing.T) {
t.Run("valid expressions", func(t *testing.T) {
tests := []struct {
name string
expr string
wantPath string
wantOp string
wantVal string
}{
{name: "string equality", expr: `{.phase} == "Running"`, wantPath: ".phase", wantOp: "==", wantVal: `"Running"`},
{name: "numeric greater than or equal", expr: `{.count} >= 3`, wantPath: ".count", wantOp: ">=", wantVal: "3"},
{name: "boolean not equal", expr: `{.ready} != false`, wantPath: ".ready", wantOp: "!=", wantVal: "false"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path, op, val, err := parseReadinessExpression(tt.expr)
require.NoError(t, err)
assert.Equal(t, tt.wantPath, path)
assert.Equal(t, tt.wantOp, op)
assert.Equal(t, tt.wantVal, val)
})
}
})
t.Run("invalid expressions", func(t *testing.T) {
tests := []struct {
name string
expr string
wantErrPart string
}{
{name: "missing opening brace", expr: `.phase == "Running"`, wantErrPart: "must start"},
{name: "missing closing brace", expr: `{.phase == "Running"`, wantErrPart: "missing closing"},
{name: "missing operator", expr: `{.phase} "Running"`, wantErrPart: "unsupported operator"},
{name: "unsupported operator", expr: `{.phase} <> "Running"`, wantErrPart: "unsupported operator"},
{name: "missing value", expr: `{.phase} ==`, wantErrPart: "missing comparison value"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, err := parseReadinessExpression(tt.expr)
assert.ErrorContains(t, err, tt.wantErrPart)
})
}
})
}
func TestReadinessJSONPath(t *testing.T) {
// Locks the contract that paths already rooted at .status do NOT get
// .status double-prepended. Chart authors commonly write
// `{.status.phase}` (the canonical form in K8s docs); without the
// guard, the effective JSONPath becomes `.status.status.phase` and
// the readiness gate silently fails to find any value.
tests := []struct {
name string
path string
want string
wantErr string
}{
{name: "already rooted at .status. — no double-prepend", path: ".status.phase", want: "{.status.phase}"},
{name: "already rooted at .status[ — no double-prepend", path: `.status['observedGeneration']`, want: `{.status['observedGeneration']}`},
{name: "non-status dot path gets .status prefix", path: ".phase", want: "{.status.phase}"},
{name: "bracket-only path gets .status prefix", path: "[0]", want: "{.status[0]}"},
{name: "invalid path rejected", path: "phase", wantErr: "must start with . or ["},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := readinessJSONPath(tt.path)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestValidateReadinessExpressions(t *testing.T) {
tests := []struct {
name string
annotation string
wantErr string
}{
{name: "empty annotation is valid", annotation: ""},
{name: "well-formed single expression", annotation: `["{.status.phase} == \"Ready\""]`},
{name: "well-formed multiple expressions", annotation: `["{.status.succeeded} >= 1", "{.status.ready} == true"]`},
{name: "not a JSON array", annotation: `not-json`, wantErr: "parsing readiness annotation JSON"},
{name: "missing closing brace", annotation: `["{.status.succeeded >= 1"]`, wantErr: "missing closing"},
{name: "path not rooted at . or [", annotation: `["{status.phase} == \"Ready\""]`, wantErr: "must start with . or ["},
{name: "ordering operator with quoted string value", annotation: `["{.status.phase} > \"Running\""]`, wantErr: "requires a numeric comparison value"},
{name: "ordering operator with boolean value", annotation: `["{.status.ready} >= true"]`, wantErr: "requires a numeric comparison value"},
{name: "ordering operator with quoted numeric value is valid", annotation: `["{.status.replicas} >= \"3\""]`},
{name: "ordering operator with unquoted non-numeric value", annotation: `["{.status.phase} > Running"]`, wantErr: "requires a numeric comparison value"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateReadinessExpressions(tt.annotation)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
func TestReadinessExpressionsJSON(t *testing.T) {
t.Run("empty list", func(t *testing.T) {
exprs, err := parseReadinessExpressions(`[]`)
require.NoError(t, err)
assert.Empty(t, exprs)
})
t.Run("malformed annotation JSON", func(t *testing.T) {
_, err := parseReadinessExpressions(`["{.ready} == true"`)
assert.ErrorContains(t, err, "parsing readiness annotation JSON")
})
}
func TestEvaluateReadiness_OrderingOnNonNumericString_TreatedAsNotMet(t *testing.T) {
for _, op := range []string{"<", "<=", ">", ">="} {
t.Run(op, func(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
expr := fmt.Sprintf(`{.phase} %s "Ready"`, op)
result, useKstatus, warnings, err := EvaluateCustomReadiness(
obj,
[]string{expr},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
require.Len(t, warnings, 1)
assert.Equal(t, expr, warnings[0].Expression)
assert.Contains(t, warnings[0].Detail, "ordering operators")
assert.Contains(t, warnings[0].Detail, `"Running"`)
})
}
}
func TestEvaluateReadiness_IncomparableFailureExpression_Symmetric(t *testing.T) {
// A type-mismatched FAILURE expression must mean "failure condition not
// met" — not "failed" and not an error — so a satisfied success
// expression still marks the resource ready, with the mistake surfaced
// as a warning.
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
result, useKstatus, warnings, err := EvaluateCustomReadiness(
obj,
[]string{`{.phase} == "Running"`},
[]string{`{.phase} > "Failed"`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessReady, result)
require.Len(t, warnings, 1)
assert.Equal(t, `{.phase} > "Failed"`, warnings[0].Expression)
}
func TestEvaluateReadiness_NumericStringCoercionPreserved(t *testing.T) {
// A string status value that parses as a number keeps comparing
// numerically — only genuinely non-numeric values changed behavior.
tests := []struct{ name, expr string }{
{name: "unquoted numeric literal", expr: `{.phase} >= 5`},
{name: "quoted numeric literal", expr: `{.phase} >= "5"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "5"})
result, useKstatus, warnings, err := EvaluateCustomReadiness(
obj,
[]string{tt.expr},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Empty(t, warnings)
assert.Equal(t, ReadinessReady, result)
})
}
}
func TestEvaluateReadiness_OrderingOnBooleans_TreatedAsNotMet(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{"ready": true})
result, useKstatus, warnings, err := EvaluateCustomReadiness(
obj,
[]string{`{.ready} >= true`},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.False(t, useKstatus)
assert.Equal(t, ReadinessPending, result)
require.Len(t, warnings, 1)
}
func TestEvaluateReadiness_AllSuccessExpressionsIncomparable_Pending(t *testing.T) {
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
result, _, warnings, err := EvaluateCustomReadiness(
obj,
[]string{`{.phase} > "Ready"`, `{.phase} < "Ready"`},
[]string{`{.failed} >= 1`},
)
require.NoError(t, err)
assert.Equal(t, ReadinessPending, result)
assert.Len(t, warnings, 2)
}
func TestCompareValues_OrderingOnNonNumeric_ReturnsSentinel(t *testing.T) {
met, err := compareValues("Running", ">", `"Ready"`)
require.ErrorIs(t, err, errIncomparableOrdering)
assert.False(t, met)
// A numeric actual against a non-numeric literal is equally incomparable.
met, err = compareValues("5", ">", `"high"`)
require.ErrorIs(t, err, errIncomparableOrdering)
assert.False(t, met)
}

@ -51,6 +51,12 @@ type statusWaiter struct {
waitWithJobsCtx context.Context
waitForDeleteCtx context.Context
readers []engine.StatusReader
// customReadiness enables per-resource evaluation of the
// helm.sh/readiness-success|failure annotations in Wait and WaitWithJobs.
// WatchUntilReady deliberately ignores it: hook readiness is defined by
// hook semantics, and the partial-annotation warning only runs for
// sequenced release resources.
customReadiness bool
logging.LogHolder
}
@ -74,6 +80,17 @@ func getStatusWatcher(dynamicClient dynamic.Interface, mapper meta.RESTMapper) *
return sw
}
// wrapCustomReadiness layers custom readiness evaluation on top of the reader
// chain a wait method would otherwise use. It must wrap the complete chain so
// that resources without the annotations are delegated to exactly the readers
// they would get with custom readiness disabled.
func (w *statusWaiter) wrapCustomReadiness(fallback engine.StatusReader) engine.StatusReader {
if !w.customReadiness {
return fallback
}
return newCustomReadinessStatusReader(w.Logger(), fallback)
}
func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error {
if timeout == 0 {
timeout = DefaultStatusWatcherTimeout
@ -87,6 +104,7 @@ func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.D
// We don't want to wait on any other resources as watchUntilReady is only for Helm hooks.
// If custom readers are defined they can be used as Helm hooks support any resource.
// We put them in front since the DelegatingStatusReader uses the first reader that matches.
// Custom readiness annotations (w.customReadiness) are deliberately not applied to hooks.
genericSR := statusreaders.NewGenericStatusReader(w.restMapper, alwaysReady)
sr := &statusreaders.DelegatingStatusReader{
@ -104,7 +122,7 @@ func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) er
defer cancel()
w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
sw := getStatusWatcher(w.client, w.restMapper)
sw.StatusReader = statusreaders.NewStatusReader(w.restMapper, w.readers...)
sw.StatusReader = w.wrapCustomReadiness(statusreaders.NewStatusReader(w.restMapper, w.readers...))
return w.wait(ctx, resourceList, sw)
}
@ -119,8 +137,7 @@ func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Dura
newCustomJobStatusReader := helmStatusReaders.NewCustomJobStatusReader(w.restMapper)
readers := append([]engine.StatusReader(nil), w.readers...)
readers = append(readers, newCustomJobStatusReader)
customSR := statusreaders.NewStatusReader(w.restMapper, readers...)
sw.StatusReader = customSR
sw.StatusReader = w.wrapCustomReadiness(statusreaders.NewStatusReader(w.restMapper, readers...))
return w.wait(ctx, resourceList, sw)
}

@ -25,6 +25,15 @@ type ApplyMethod string
const ApplyMethodClientSideApply ApplyMethod = "csa"
const ApplyMethodServerSideApply ApplyMethod = "ssa"
// SequencingInfo is the legacy ordered-sequencing record stored by earlier
// builds of this fork. It is retained so those releases keep decoding; new
// code reads Release.IsSequenced and writers are migrating to the Sequenced
// field.
type SequencingInfo struct {
Enabled bool `json:"enabled,omitempty"`
Strategy string `json:"strategy,omitempty"`
}
// Release describes a deployment of a chart, together with the chart
// and the variables used to deploy that chart.
type Release struct {
@ -51,6 +60,13 @@ type Release struct {
// ApplyMethod stores whether server-side or client-side apply was used for the release
// Unset (empty string) should be treated as the default of client-side apply
ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa"
// Sequenced records that this revision was deployed with ordered sequencing
// (--wait=ordered / WaitStrategy=ordered). It is set before the release record
// is first persisted so that any failure-cleanup path sees it.
Sequenced bool `json:"sequenced,omitempty"`
// SequencingInfo is read for releases stored by earlier builds of this fork.
// Legacy, decode-only: writers are migrating to Sequenced; read via IsSequenced.
SequencingInfo *SequencingInfo `json:"sequencing_info,omitempty"`
}
// SetStatus is a helper for setting the status on a release.
@ -58,3 +74,11 @@ func (r *Release) SetStatus(status common.Status, msg string) {
r.Info.Status = status
r.Info.Description = msg
}
// IsSequenced reports whether this release revision was deployed with ordered
// sequencing. It reads both the current Sequenced field and the legacy
// SequencingInfo shape stored by earlier builds, so pre-existing sequenced
// releases keep sequencing through uninstall and rollback.
func (r *Release) IsSequenced() bool {
return r.Sequenced || (r.SequencingInfo != nil && r.SequencingInfo.Enabled)
}

@ -0,0 +1,95 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReleaseSequencingInfoBackwardCompatibility(t *testing.T) {
release := Release{Name: "demo"}
data, err := json.Marshal(&release)
require.NoError(t, err)
assert.NotContains(t, string(data), "sequencing_info")
var decoded Release
err = json.Unmarshal([]byte(`{"name":"demo"}`), &decoded)
require.NoError(t, err)
assert.Nil(t, decoded.SequencingInfo)
}
func TestReleaseSequencingInfoRoundTrip(t *testing.T) {
release := Release{
Name: "demo",
SequencingInfo: &SequencingInfo{
Enabled: true,
Strategy: "ordered",
},
}
data, err := json.Marshal(&release)
require.NoError(t, err)
assert.Contains(t, string(data), `"sequencing_info"`)
var decoded Release
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.SequencingInfo)
assert.True(t, decoded.SequencingInfo.Enabled)
assert.Equal(t, "ordered", decoded.SequencingInfo.Strategy)
}
func TestReleaseIsSequencedLegacyDecode(t *testing.T) {
var decoded Release
err := json.Unmarshal([]byte(`{"name":"demo","sequencing_info":{"enabled":true}}`), &decoded)
require.NoError(t, err)
assert.False(t, decoded.Sequenced)
assert.True(t, decoded.IsSequenced())
}
func TestReleaseSequencedRoundTrip(t *testing.T) {
release := Release{Name: "demo", Sequenced: true}
data, err := json.Marshal(&release)
require.NoError(t, err)
assert.Contains(t, string(data), `"sequenced":true`)
var decoded Release
require.NoError(t, json.Unmarshal(data, &decoded))
assert.True(t, decoded.Sequenced)
assert.True(t, decoded.IsSequenced())
assert.Nil(t, decoded.SequencingInfo)
}
func TestReleaseNotSequencedOmitted(t *testing.T) {
release := Release{Name: "demo"}
data, err := json.Marshal(&release)
require.NoError(t, err)
// Non-sequenced release JSON is byte-identical to pre-schema-change output.
assert.NotContains(t, string(data), `"sequenced"`)
assert.NotContains(t, string(data), `"sequencing_info"`)
assert.False(t, release.IsSequenced())
}
func TestReleaseIsSequencedDisabledLegacyInfo(t *testing.T) {
release := Release{Name: "demo", SequencingInfo: &SequencingInfo{Enabled: false}}
assert.False(t, release.IsSequenced())
}

@ -0,0 +1,359 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package sequence
import (
"fmt"
"maps"
"slices"
"strings"
chart "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
type builder struct {
plan *Plan
}
// GroupManifestsByDirectSubchart groups manifests by the direct subchart they belong to.
// chartPath is the full path-prefix for the current chart level — at the top level
// it is the chart name (e.g. "parent"); at deeper recursion levels it is the joined
// path through "/charts/" segments (e.g. "parent/charts/sub").
// The current chart level's own manifests are returned under the empty string key "".
// Direct subcharts are keyed by their immediate directory name under
// "<chartPath>/charts/<subchart>/". Nested grandchildren are grouped under their
// direct subchart parent ("sub"), since nested sequencing is handled recursively.
func GroupManifestsByDirectSubchart(manifests []releaseutil.Manifest, chartPath string) map[string][]releaseutil.Manifest {
result := make(map[string][]releaseutil.Manifest)
if chartPath == "" {
result[""] = append(result[""], manifests...)
return result
}
chartsPrefix := chartPath + "/charts/"
for _, m := range manifests {
if !strings.HasPrefix(m.Name, chartsPrefix) {
result[""] = append(result[""], m)
continue
}
rest := m.Name[len(chartsPrefix):]
subchartName, _, ok := strings.Cut(rest, "/")
if !ok {
result[""] = append(result[""], m)
continue
}
result[subchartName] = append(result[subchartName], m)
}
return result
}
// FindSubchart resolves a direct dependency by effective name (alias if set,
// else name) — the single home for the chart-walker subchart lookup. It uses
// the parent chart's Metadata.Dependencies to resolve aliases, since the alias
// is stored on the Dependency struct in Chart.yaml, not on the subchart's own
// Metadata.
func FindSubchart(chrt *chart.Chart, nameOrAlias string) *chart.Chart {
if chrt == nil {
return nil
}
aliasMap := make(map[string]string) // chart name → effective name (alias or name)
if chrt.Metadata != nil {
for _, dep := range chrt.Metadata.Dependencies {
if dep == nil {
continue
}
effective := dep.Name
if dep.Alias != "" {
effective = dep.Alias
}
aliasMap[dep.Name] = effective
}
}
for _, dep := range chrt.Dependencies() {
effective := dep.Name()
if alias, ok := aliasMap[dep.Name()]; ok {
effective = alias
}
if effective == nameOrAlias || dep.Name() == nameOrAlias {
return dep
}
}
return nil
}
// Build constructs the deployment plan for chrt's rendered manifests.
//
// Preconditions: manifests are hook-free (SortManifests output or a stored
// rel.Manifest — hooks are stored separately) and chrt has been through
// ProcessDependencies. Storage-decoded charts satisfy this in metadata only:
// the release codec drops the loaded dependency tree (Chart.dependencies is
// unexported), so only the root chart object and its pruned, alias-rewritten
// Metadata.Dependencies survive. Build handles that shape: the root subchart
// DAG is built from the trusted metadata (see BuildSubchartDAG) and nested
// levels are walked structurally from manifest "# Source:" paths
// (buildStructuralLevel). Build does not re-filter hooks.
// chrt == nil yields a flat single-level plan at ChartPath "".
//
// Errors (fatal — the caller must not proceed):
// - subchart DAG construction failure: depends-on referencing unknown/disabled
// subcharts, malformed helm.sh/depends-on/subcharts JSON (BuildSubchartDAG)
// - cycle in a subchart DAG or a resource-group DAG (any level)
// - a resource assigned to two different groups (ParseResourceGroups)
//
// Warnings (non-fatal, returned in Plan.Warnings; the manifest is still placed):
// - invalid depends-on/resource-groups JSON on a resource → demoted to unsequenced
// - group referencing a missing group → whole group demoted (transitively)
// - isolated group among ≥2 groups → demoted to unsequenced
// - resource with only one of the two readiness annotations (falls back to kstatus)
// - rendered subchart not declared in Chart.yaml (deployed after declared subcharts)
// - rendered subchart not resolvable to a chart object and with ≥2 nested
// sibling subcharts (structural walk cannot recover sibling ordering)
//
// Postcondition: every input manifest appears in exactly one batch;
// len(manifests) == Σ len(batch.Manifests()). Unit-enforced.
//
// Build is deterministic: identical input yields an identical plan.
func Build(chrt *chart.Chart, manifests []releaseutil.Manifest) (*Plan, error) {
b := &builder{plan: &Plan{}}
if chrt == nil {
b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: "", Depth: 0})
if err := b.appendResourceGroupBatches("", 0, manifests); err != nil {
return nil, err
}
return b.plan, nil
}
if err := b.buildLevel(chrt, manifests, chrt.Name(), 0); err != nil {
return nil, err
}
return b.plan, nil
}
func (b *builder) warnf(kind WarningKind, chartPath, format string, args ...any) {
b.plan.Warnings = append(b.plan.Warnings, Warning{
Kind: kind,
ChartPath: chartPath,
Message: fmt.Sprintf(format, args...),
})
}
func (b *builder) buildLevel(c *chart.Chart, manifests []releaseutil.Manifest, chartPath string, depth int) error {
levelIdx := len(b.plan.Levels)
b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: chartPath, Depth: depth})
grouped := GroupManifestsByDirectSubchart(manifests, chartPath)
dag, err := chartutil.BuildSubchartDAG(c)
if err != nil {
return fmt.Errorf("building subchart DAG for %s: %w", chartPath, err)
}
batches, err := dag.GetBatches()
if err != nil {
return fmt.Errorf("subchart circular dependency detected in %s: %w", chartPath, err)
}
b.plan.Levels[levelIdx].SubchartBatches = batches
declared := make(map[string]bool, len(batches))
for _, batch := range batches {
for _, name := range batch {
declared[name] = true
if err := b.buildSubchart(c, chartPath, name, grouped[name], depth, levelIdx); err != nil {
return err
}
}
}
for _, name := range slices.Sorted(maps.Keys(grouped)) {
if name == "" || declared[name] {
continue
}
b.plan.Levels[levelIdx].Undeclared = append(b.plan.Levels[levelIdx].Undeclared, name)
b.warnf(WarningKindUndeclaredSubchart, chartPath, "rendered subchart %q is not declared in Chart.yaml dependencies; sequencing it after declared subcharts", name)
if err := b.buildSubchart(c, chartPath, name, grouped[name], depth, levelIdx); err != nil {
return err
}
}
slices.Sort(b.plan.Levels[levelIdx].Unresolved)
return b.appendResourceGroupBatches(chartPath, depth, grouped[""])
}
func (b *builder) buildSubchart(parent *chart.Chart, chartPath, name string, manifests []releaseutil.Manifest, depth, parentLevelIdx int) error {
if len(manifests) == 0 {
return nil
}
subPath := chartPath + "/charts/" + name
sub := FindSubchart(parent, name)
if sub == nil {
b.plan.Levels[parentLevelIdx].Unresolved = append(b.plan.Levels[parentLevelIdx].Unresolved, name)
return b.buildStructuralLevel(subPath, depth+1, manifests)
}
return b.buildLevel(sub, manifests, subPath, depth+1)
}
// buildStructuralLevel sequences a subtree whose chart object is unavailable.
// Charts decoded from release storage lose their loaded dependency tree (the
// release codec serializes only exported fields, and Chart.dependencies is
// not one), so at uninstall/rollback time nested subchart levels have
// manifests but no chart. The tree structure is recovered from the manifests'
// "# Source:" path prefixes and walked exactly like buildLevel — subchart
// subtrees first, then the level's own resource-group batches (the group
// annotations live on the manifests and fully survive storage). The only
// ordering information lost with the chart is depends-on edges BETWEEN
// sibling subcharts: siblings are walked in name order, and a warning is
// recorded when a level has two or more.
func (b *builder) buildStructuralLevel(chartPath string, depth int, manifests []releaseutil.Manifest) error {
levelIdx := len(b.plan.Levels)
b.plan.Levels = append(b.plan.Levels, ChartLevel{Path: chartPath, Depth: depth})
grouped := GroupManifestsByDirectSubchart(manifests, chartPath)
subcharts := slices.DeleteFunc(slices.Sorted(maps.Keys(grouped)), func(name string) bool { return name == "" })
if len(subcharts) > 0 {
b.plan.Levels[levelIdx].SubchartBatches = [][]string{subcharts}
}
if len(subcharts) >= 2 {
b.warnf(WarningKindUnresolvedSubchart, chartPath, "chart metadata for %s is unavailable; sequencing its subcharts %v in name order (depends-on between them, if any, is not recoverable)", chartPath, subcharts)
}
for _, name := range subcharts {
if err := b.buildStructuralLevel(chartPath+"/charts/"+name, depth+1, grouped[name]); err != nil {
return err
}
}
return b.appendResourceGroupBatches(chartPath, depth, grouped[""])
}
func (b *builder) appendResourceGroupBatches(chartPath string, depth int, manifests []releaseutil.Manifest) error {
if len(manifests) == 0 {
return nil
}
for _, manifest := range manifests {
if manifest.Head == nil || manifest.Head.Metadata == nil {
continue
}
annotations := manifest.Head.Metadata.Annotations
_, hasSuccess := annotations[releaseutil.AnnotationReadinessSuccess]
_, hasFailure := annotations[releaseutil.AnnotationReadinessFailure]
if hasSuccess != hasFailure {
b.warnf(WarningKindPartialReadiness, chartPath, "resource %q has only one of %s and %s; falling back to kstatus readiness", manifest.Head.Metadata.Name, releaseutil.AnnotationReadinessSuccess, releaseutil.AnnotationReadinessFailure)
}
}
result, warnings, err := releaseutil.ParseResourceGroups(manifests)
if err != nil {
return fmt.Errorf("parsing resource-group annotations for %s: %w", chartPath, err)
}
for _, warning := range warnings {
b.plan.Warnings = append(b.plan.Warnings, Warning{
Kind: WarningKindResourceGroupDemotion,
ChartPath: chartPath,
Message: warning,
})
}
unsequenced := result.Unsequenced
if len(result.Groups) >= 2 {
dependents := resourceGroupDependents(result.GroupDeps)
for _, groupName := range slices.Sorted(maps.Keys(result.Groups)) {
if len(result.GroupDeps[groupName]) != 0 || dependents[groupName] {
continue
}
b.warnf(WarningKindIsolatedGroup, chartPath, "resource-group %q is isolated (no depends-on edges and no dependents); deploying it in the unsequenced batch after sequenced groups", groupName)
unsequenced = append(unsequenced, result.Groups[groupName]...)
delete(result.Groups, groupName)
delete(result.GroupDeps, groupName)
}
}
if len(result.Groups) > 0 {
dag, err := releaseutil.BuildResourceGroupDAG(result)
if err != nil {
return fmt.Errorf("building resource-group DAG for %s: %w", chartPath, err)
}
groupBatches, err := dag.GetBatches()
if err != nil {
return fmt.Errorf("resource-group circular dependency detected in %s: %w", chartPath, err)
}
dependents := resourceGroupDependents(result.GroupDeps)
for _, groupBatch := range groupBatches {
batch := Batch{
ChartPath: chartPath,
Depth: depth,
Kind: BatchKindGroups,
Wait: true,
}
for _, groupName := range groupBatch {
batch.Groups = append(batch.Groups, Group{
Name: groupName,
Manifests: result.Groups[groupName],
})
if !dependents[groupName] {
batch.LeafGroups = append(batch.LeafGroups, groupName)
}
}
batch.HasCustomReadiness = hasCustomReadiness(batch.Manifests())
b.plan.Batches = append(b.plan.Batches, batch)
}
}
if len(unsequenced) > 0 {
b.plan.Batches = append(b.plan.Batches, Batch{
ChartPath: chartPath,
Depth: depth,
Kind: BatchKindUnsequenced,
Groups: []Group{{Name: "", Manifests: unsequenced}},
Wait: true,
HasCustomReadiness: hasCustomReadiness(unsequenced),
})
}
return nil
}
func resourceGroupDependents(groupDeps map[string][]string) map[string]bool {
dependents := make(map[string]bool)
for _, deps := range groupDeps {
for _, dep := range deps {
dependents[dep] = true
}
}
return dependents
}
func hasCustomReadiness(manifests []releaseutil.Manifest) bool {
for _, manifest := range manifests {
if manifest.Head == nil || manifest.Head.Metadata == nil {
continue
}
annotations := manifest.Head.Metadata.Annotations
if annotations == nil {
continue
}
if annotations[releaseutil.AnnotationReadinessSuccess] != "" && annotations[releaseutil.AnnotationReadinessFailure] != "" {
return true
}
}
return false
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,75 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package sequence
import (
"fmt"
"sort"
"strings"
"sigs.k8s.io/yaml"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
// ParseStoredManifests parses a stored release manifest stream (rel.Manifest)
// or `helm template` output into named manifests, recovering source-template
// paths from "# Source:" comments. Documents without a Source comment keep
// their positional "manifest-%d" split key, and therefore route to the
// top-level/flat portion of a plan built from them. Returns an error only on
// YAML head-parse failure.
func ParseStoredManifests(manifest string) ([]releaseutil.Manifest, error) {
rawManifests := releaseutil.SplitManifests(manifest)
keys := make([]string, 0, len(rawManifests))
for key := range rawManifests {
keys = append(keys, key)
}
sort.Sort(releaseutil.BySplitManifestsOrder(keys))
manifests := make([]releaseutil.Manifest, 0, len(keys))
for _, key := range keys {
content := rawManifests[key]
name := manifestSourcePath(content)
if name == "" {
name = key
}
var head releaseutil.SimpleHead
if err := yaml.Unmarshal([]byte(content), &head); err != nil {
return nil, fmt.Errorf("YAML parse error on %s: %w", name, err)
}
manifests = append(manifests, releaseutil.Manifest{
Name: name,
Content: content,
Head: &head,
})
}
return manifests, nil
}
func manifestSourcePath(content string) string {
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimSpace(line)
if source, ok := strings.CutPrefix(line, "# Source: "); ok {
return source
}
}
return ""
}

@ -0,0 +1,160 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package sequence computes the ordered deployment plan for a release's
// rendered manifests per HIP-0025: a two-level walk over the chart's subchart
// DAG and each chart level's resource-group DAG, flattened into an ordered
// list of apply-and-wait batches. The package is pure — it never talks to a
// cluster and must not import pkg/kube, pkg/action, or pkg/cmd — so the same
// plan is usable by install/upgrade drivers, uninstall/rollback (reversed),
// `helm template`/`helm dag` rendering, and lint.
package sequence
import (
"strings"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
// BatchKind distinguishes the two batch flavors a chart level produces.
type BatchKind uint8
const (
// BatchKindGroups: one topological level of the resource-group DAG —
// one or more groups deployable in parallel.
BatchKindGroups BatchKind = iota
// BatchKindUnsequenced: a chart level's trailing batch — resources with no
// group annotation, resources demoted for invalid depends-on JSON,
// groups demoted for missing references, and isolated groups.
BatchKindUnsequenced
)
// Group is a named set of manifests deployed as one unit within a batch.
type Group struct {
Name string // "" only inside a BatchKindUnsequenced batch
Manifests []releaseutil.Manifest // input order preserved
}
// Batch is one apply-and-wait unit. Drivers process Batches strictly in order.
type Batch struct {
// ChartPath is the manifest path-prefix of the owning chart level:
// "parent", "parent/charts/db", "parent/charts/db/charts/redis".
// "" when the plan was built without a chart (flat fallback).
ChartPath string
// Depth is the chart-nesting depth (0 = top level). Display-only.
Depth int
Kind BatchKind
// Groups: for BatchKindGroups, ≥1 groups sorted by Name (one DAG level);
// for BatchKindUnsequenced, exactly one Group{Name: ""}.
Groups []Group
// Wait: gate on readiness (install/upgrade) or deletion (reversed plans)
// before starting the next batch. The builder sets true for every batch;
// the field exists so a strict-HIP mode can flip leaf-only batches later.
Wait bool
// HasCustomReadiness: at least one manifest in the batch carries BOTH
// helm.sh/readiness-success and helm.sh/readiness-failure (drivers add
// kube.WithCustomReadinessStatusReader for such batches).
HasCustomReadiness bool
// LeafGroups: names of groups in this batch with no dependents in this
// chart level's DAG. Informational — display and future policy only.
LeafGroups []string
}
// Manifests flattens the batch's groups in order. Convenience for drivers.
func (b Batch) Manifests() []releaseutil.Manifest {
var manifests []releaseutil.Manifest
for _, group := range b.Groups {
manifests = append(manifests, group.Manifests...)
}
return manifests
}
// WarningKind classifies a Warning so consumers can map runtime demotion
// classes to severities without string-matching messages.
type WarningKind uint8
const (
// WarningKindResourceGroupDemotion: a resource or group demoted to the
// unsequenced batch by ParseResourceGroups (malformed depends-on JSON,
// reference to a non-existent group). Lint treats these as errors.
WarningKindResourceGroupDemotion WarningKind = iota
// WarningKindIsolatedGroup: a group with no edges demoted to unsequenced.
WarningKindIsolatedGroup
// WarningKindPartialReadiness: only one of the two readiness annotations
// present; the resource falls back to kstatus.
WarningKindPartialReadiness
// WarningKindUndeclaredSubchart: rendered subchart absent from Chart.yaml.
WarningKindUndeclaredSubchart
// WarningKindUnresolvedSubchart: rendered subchart dir with no resolvable
// chart object (typical for nested levels of storage-decoded charts) whose
// structural walk found ≥2 sibling subcharts, so their depends-on order,
// if any was declared, could not be recovered.
WarningKindUnresolvedSubchart
)
// Warning is a non-fatal misconfiguration surfaced during Build.
// Drivers log them; lint maps them to rule severities.
type Warning struct {
Kind WarningKind
ChartPath string // "" for chart-independent warnings
Message string
}
// ChartLevel summarizes one chart's slot in the walk, in pre-order.
// Consumed by `helm dag` (subchart-batch display) and diagnostics.
type ChartLevel struct {
Path string // manifest path-prefix (as Batch.ChartPath)
Depth int
SubchartBatches [][]string // declared subcharts per parallel DAG batch (effective names)
Undeclared []string // rendered subcharts absent from Chart.yaml deps (sorted)
Unresolved []string // rendered subchart dirs with no resolvable chart object (sorted)
}
// Plan is the complete deployment sequence for one release's manifests.
type Plan struct {
Batches []Batch // forward (install) order
Levels []ChartLevel // pre-order chart traversal
Warnings []Warning
}
// Reverse returns a new Plan whose Batches are in exact reverse order, for
// uninstall and removed-resource deletion (HIP: uninstall is the exact reverse
// of install). Levels and Warnings are shared unchanged; Wait retains its
// meaning ("gate before next batch", i.e. wait-for-delete).
func (p *Plan) Reverse() *Plan {
if p == nil {
return nil
}
reversed := make([]Batch, len(p.Batches))
for i, batch := range p.Batches {
reversed[len(p.Batches)-1-i] = batch
}
return &Plan{
Batches: reversed,
Levels: p.Levels,
Warnings: p.Warnings,
}
}
// DisplayPath converts a manifest path-prefix to the HIP display form used by
// template markers and `helm dag`: "parent/charts/db/charts/redis" → "parent/db/redis".
// (Chart names cannot contain '/', so replacing "/charts/" is unambiguous;
// a subchart literally named "charts" is pathological and unsupported.)
func DisplayPath(chartPath string) string {
return strings.ReplaceAll(chartPath, "/charts/", "/")
}

@ -0,0 +1,171 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package sequence
import (
"fmt"
"go/parser"
"go/token"
"os"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
)
func TestPlanReverse(t *testing.T) {
t.Parallel()
manifests := []releaseutil.Manifest{
makeManifest("base", "parent/templates/base.yaml", groupAnnotations("base")),
makeManifest("left", "parent/templates/left.yaml", groupAnnotations("left", "base")),
makeManifest("right", "parent/templates/right.yaml", groupAnnotations("right", "base")),
makeManifest("top", "parent/templates/top.yaml", groupAnnotations("top", "left", "right")),
makeManifest("plain", "parent/templates/plain.yaml", nil),
}
plan, err := Build(newChart("parent"), manifests)
require.NoError(t, err)
require.GreaterOrEqual(t, len(plan.Batches), 4)
original := batchSummaries(plan)
reversed := plan.Reverse()
require.NotNil(t, reversed)
require.Len(t, reversed.Batches, len(plan.Batches))
for i := range reversed.Batches {
assert.Equal(t, original[len(original)-1-i], summarizeBatch(reversed.Batches[i]))
}
assert.Equal(t, original, batchSummaries(plan), "Reverse must not mutate the original plan")
assert.Equal(t, plan.Levels, reversed.Levels)
assert.Equal(t, plan.Warnings, reversed.Warnings)
assert.Equal(t, original, batchSummaries(reversed.Reverse()))
}
func TestDisplayPath(t *testing.T) {
t.Parallel()
tests := map[string]string{
"parent": "parent",
"parent/charts/db": "parent/db",
"parent/charts/db/charts/redis": "parent/db/redis",
"": "",
}
for input, expected := range tests {
assert.Equal(t, expected, DisplayPath(input))
}
}
func TestBatchManifests(t *testing.T) {
t.Parallel()
first := makeManifest("first", "parent/templates/first.yaml", nil)
second := makeManifest("second", "parent/templates/second.yaml", nil)
third := makeManifest("third", "parent/templates/third.yaml", nil)
batch := Batch{
Groups: []Group{
{Name: "a", Manifests: []releaseutil.Manifest{first, second}},
{Name: "b", Manifests: []releaseutil.Manifest{third}},
},
}
assert.Equal(t, []releaseutil.Manifest{first, second, third}, batch.Manifests())
}
func TestReadinessAnnotationValues(t *testing.T) {
t.Parallel()
assert.Equal(t, "helm.sh/readiness-success", releaseutil.AnnotationReadinessSuccess)
assert.Equal(t, "helm.sh/readiness-failure", releaseutil.AnnotationReadinessFailure)
}
func TestPackageImportPurity(t *testing.T) {
t.Parallel()
entries, err := os.ReadDir(".")
require.NoError(t, err)
allowedHelmImports := map[string]bool{
"helm.sh/helm/v4/pkg/chart/v2": true,
"helm.sh/helm/v4/pkg/chart/v2/util": true,
"helm.sh/helm/v4/pkg/release/v1/util": true,
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
file, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, parser.ImportsOnly)
require.NoError(t, err)
for _, imported := range file.Imports {
path, err := strconv.Unquote(imported.Path.Value)
require.NoError(t, err)
if strings.HasPrefix(path, "helm.sh/helm/v4/") {
assert.Truef(t, allowedHelmImports[path], "forbidden helm import %q in %s", path, entry.Name())
}
assert.Falsef(t, strings.HasPrefix(path, "k8s.io/"), "forbidden Kubernetes import %q in %s", path, entry.Name())
}
}
}
func TestParseStoredManifests(t *testing.T) {
t.Parallel()
stream := strings.Join([]string{
"---\n# Source: parent/templates/a.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n annotations:\n example.com/key: value\n",
"---\n# Source: parent/charts/db/templates/b.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: b\n",
"---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n",
}, "")
parsed, err := ParseStoredManifests(stream)
require.NoError(t, err)
require.Len(t, parsed, 3)
assert.Equal(t, []string{"parent/templates/a.yaml", "parent/charts/db/templates/b.yaml", "manifest-2"}, manifestNames(parsed))
assert.Equal(t, "ConfigMap", parsed[0].Head.Kind)
assert.Equal(t, "a", parsed[0].Head.Metadata.Name)
assert.Equal(t, "value", parsed[0].Head.Metadata.Annotations["example.com/key"])
assert.Equal(t, "b", parsed[1].Head.Metadata.Name)
assert.Equal(t, "c", parsed[2].Head.Metadata.Name)
_, err = ParseStoredManifests("---\n# Source: bad.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: [\n")
require.Error(t, err)
assert.Contains(t, err.Error(), "YAML parse error")
flat, err := Build(nil, parsed)
require.NoError(t, err)
assertPlanComplete(t, flat, parsed)
require.Len(t, flat.Batches, 1)
assert.Empty(t, flat.Batches[0].ChartPath)
db := makeManifest("db", "parent/templates/db.yaml", groupAnnotations("db"))
app := makeManifest("app", "parent/templates/app.yaml", groupAnnotations("app", "db"))
stored := fmt.Sprintf("---\n# Source: %s\n%s---\n# Source: %s\n%s", db.Name, db.Content, app.Name, app.Content)
parsedStored, err := ParseStoredManifests(stored)
require.NoError(t, err)
fromStored, err := Build(newChart("parent"), parsedStored)
require.NoError(t, err)
fromDirect, err := Build(newChart("parent"), []releaseutil.Manifest{db, app})
require.NoError(t, err)
assert.Equal(t, batchSummaries(fromDirect), batchSummaries(fromStored))
assertPlanComplete(t, fromStored, parsedStored)
}

@ -19,9 +19,12 @@ package util
import (
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
"sigs.k8s.io/yaml"
)
// SimpleHead defines what the structure of the head of a manifest file
@ -30,6 +33,7 @@ type SimpleHead struct {
Kind string `json:"kind,omitempty"`
Metadata *struct {
Name string `json:"name"`
Namespace string `json:"namespace,omitempty"`
Annotations map[string]string `json:"annotations"`
} `json:"metadata,omitempty"`
}
@ -80,3 +84,248 @@ func (a BySplitManifestsOrder) Less(i, j int) bool {
return anum < bnum
}
func (a BySplitManifestsOrder) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
// StripHelmInternalAnnotations returns manifest content with Helm-internal
// sequencing annotations removed from top-level object metadata. This exists
// so that `helm template | kubectl apply -f -` remains valid even when charts
// use HIP-0025 sequencing annotation keys with multiple `/` separators, which
// fail Kubernetes annotation-key validation.
//
// The strip is block-scoped and conservative. Each YAML document first takes a
// byte-identity fast path unless the parsed SimpleHead confirms that a
// Helm-internal sequencing key is present in metadata.annotations. Confirmed
// documents are scanned as text, and the function only deletes whole lines
// inside the confirmed top-level metadata.annotations block, including
// continuation lines for stripped values. On any structural doubt, it returns
// the original document verbatim. The failure mode is "annotation retained",
// never "manifest corrupted".
//
// Stored release manifests keep these annotations because they are the
// uninstall/rollback plan input recovered by sequence.ParseStoredManifests.
// `helm get manifest` prints the stored record verbatim by design. Only output
// surfaces such as `helm template` and apply-time paths strip them. This aligns
// with the object-domain visitor stripSequencingAnnotations in pkg/action,
// which also strips only top-level object metadata. Annotations nested in pod
// templates or List items are not functional for sequencing and are no longer
// masked by this text strip.
func StripHelmInternalAnnotations(content string) string {
if strings.TrimSpace(content) == "" {
return content
}
var out strings.Builder
var doc strings.Builder
for _, line := range strings.SplitAfter(content, "\n") {
if isYAMLDocumentSeparatorLine(line) {
out.WriteString(stripHelmInternalAnnotationsFromDoc(doc.String()))
doc.Reset()
out.WriteString(line)
continue
}
doc.WriteString(line)
}
out.WriteString(stripHelmInternalAnnotationsFromDoc(doc.String()))
return out.String()
}
func stripHelmInternalAnnotationsFromDoc(doc string) string {
if strings.TrimSpace(doc) == "" {
return doc
}
var head SimpleHead
if err := yaml.Unmarshal([]byte(doc), &head); err != nil {
return doc
}
if head.Metadata == nil || !hasHelmInternalSequencingAnnotation(head.Metadata.Annotations) {
return doc
}
lines := strings.SplitAfter(doc, "\n")
metadataLine := -1
for i, line := range lines {
if isExactYAMLKeyLine(line, 0, "metadata") {
metadataLine = i
break
}
}
if metadataLine == -1 {
return doc
}
metadataStart := metadataLine + 1
metadataEnd := len(lines)
for i := metadataStart; i < len(lines); i++ {
if isBlankYAMLLine(lines[i]) {
continue
}
if yamlLineIndent(lines[i]) == 0 {
metadataEnd = i
break
}
}
metadataChildIndent, ok := minimumYAMLIndent(lines[metadataStart:metadataEnd])
if !ok {
return doc
}
annotationsLine := -1
for i := metadataStart; i < metadataEnd; i++ {
if isExactYAMLKeyLine(lines[i], metadataChildIndent, "annotations") {
annotationsLine = i
break
}
}
if annotationsLine == -1 {
return doc
}
annotationsStart := annotationsLine + 1
annotationsEnd := metadataEnd
for i := annotationsStart; i < metadataEnd; i++ {
if isBlankYAMLLine(lines[i]) {
continue
}
if yamlLineIndent(lines[i]) <= metadataChildIndent {
annotationsEnd = i
break
}
}
annotationEntryIndent, ok := minimumYAMLIndent(lines[annotationsStart:annotationsEnd])
if !ok {
return doc
}
var out strings.Builder
for i := 0; i < len(lines); {
if i >= annotationsStart &&
i < annotationsEnd &&
yamlLineIndent(lines[i]) == annotationEntryIndent &&
isHelmInternalAnnotationEntryLine(lines[i], annotationEntryIndent) {
i++
for i < annotationsEnd {
if isBlankYAMLLine(lines[i]) {
nextNonBlank := i + 1
for nextNonBlank < annotationsEnd && isBlankYAMLLine(lines[nextNonBlank]) {
nextNonBlank++
}
if nextNonBlank < annotationsEnd && yamlLineIndent(lines[nextNonBlank]) > annotationEntryIndent {
i = nextNonBlank
continue
}
break
}
if yamlLineIndent(lines[i]) <= annotationEntryIndent {
break
}
i++
}
continue
}
out.WriteString(lines[i])
i++
}
return out.String()
}
func hasHelmInternalSequencingAnnotation(annotations map[string]string) bool {
if annotations == nil {
return false
}
for _, key := range helmInternalSequencingAnnotations {
if _, ok := annotations[key]; ok {
return true
}
}
return false
}
func isYAMLDocumentSeparatorLine(line string) bool {
body := strings.TrimSuffix(line, "\n")
return strings.TrimRight(body, " \t\r") == "---"
}
func lineBody(line string) string {
body := strings.TrimSuffix(line, "\n")
return strings.TrimSuffix(body, "\r")
}
func isBlankYAMLLine(line string) bool {
return strings.TrimSpace(lineBody(line)) == ""
}
func yamlLineIndent(line string) int {
body := lineBody(line)
for i, r := range body {
if r != ' ' && r != '\t' {
return i
}
}
return len(body)
}
func isExactYAMLKeyLine(line string, indent int, key string) bool {
body := lineBody(line)
if yamlLineIndent(line) != indent {
return false
}
return strings.TrimRight(body[indent:], " \t") == key+":"
}
func minimumYAMLIndent(lines []string) (int, bool) {
minIndent := 0
found := false
for _, line := range lines {
if isBlankYAMLLine(line) {
continue
}
indent := yamlLineIndent(line)
if !found || indent < minIndent {
minIndent = indent
found = true
}
}
return minIndent, found
}
func isHelmInternalAnnotationEntryLine(line string, indent int) bool {
key, ok := yamlEntryKey(line, indent)
if !ok {
return false
}
return slices.Contains(helmInternalSequencingAnnotations, key)
}
func yamlEntryKey(line string, indent int) (string, bool) {
body := lineBody(line)
if yamlLineIndent(line) != indent {
return "", false
}
entry := body[indent:]
if entry == "" {
return "", false
}
switch entry[0] {
case '\'', '"':
quote := entry[0]
end := strings.IndexByte(entry[1:], quote)
if end == -1 {
return "", false
}
key := entry[1 : end+1]
rest := entry[end+2:]
if !strings.HasPrefix(rest, ":") {
return "", false
}
return key, true
default:
for _, key := range helmInternalSequencingAnnotations {
if strings.HasPrefix(entry, key+":") {
return key, true
}
}
return "", false
}
}

@ -17,9 +17,11 @@ limitations under the License.
package util // import "helm.sh/helm/v4/pkg/release/v1/util"
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"sigs.k8s.io/yaml"
)
func TestSplitManifests(t *testing.T) {
@ -514,3 +516,355 @@ metadata:
})
}
}
func TestStripHelmInternalAnnotations(t *testing.T) {
tests := []struct {
name string
input string
expected string
mustNotContain []string
mustContain []string
mustEqualVerbatim bool
mustParseOutput bool
}{
{
name: "strips multi-slash key, preserves siblings",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-a
annotations:
helm.sh/depends-on/resource-groups: '["foo"]'
helm.sh/resource-group: app
other.example.com/keep: "yes"
data:
k: v
`,
mustNotContain: []string{"helm.sh/depends-on/resource-groups"},
mustContain: []string{"helm.sh/resource-group", "other.example.com/keep", "name: cm-a"},
},
{
name: "leaves dangling empty annotations key when sole annotation stripped",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-b
annotations:
helm.sh/depends-on/resource-groups: '["foo"]'
data:
k: v
`,
mustNotContain: []string{"helm.sh/depends-on/resource-groups"},
mustContain: []string{"name: cm-b", "data:"},
},
{
name: "no annotations leaves doc unchanged",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-c
data:
k: v
`,
mustEqualVerbatim: true,
},
{
name: "non-internal annotations preserved",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-d
annotations:
helm.sh/resource-group: app
data:
k: v
`,
mustEqualVerbatim: true,
},
{
name: "non-kubernetes yaml passes through",
input: "foo: bar\nbaz: 1\n",
mustEqualVerbatim: true,
},
{
name: "invalid yaml passes through",
input: ":\n\tnot valid yaml at all: [",
mustEqualVerbatim: true,
},
{
name: "data block scalar look-alike survives",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-rules
data:
rules.txt: |
helm.sh/depends-on/resource-groups: ["a"]
second line
`,
mustEqualVerbatim: true,
},
{
name: "annotation in metadata AND look-alike in data",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-mixed
annotations:
helm.sh/depends-on/resource-groups: '["db"]'
helm.sh/resource-group: app
data:
rules.txt: |
helm.sh/depends-on/resource-groups: '["db"]'
second line
`,
expected: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-mixed
annotations:
helm.sh/resource-group: app
data:
rules.txt: |
helm.sh/depends-on/resource-groups: '["db"]'
second line
`,
},
{
name: "multi-line annotation value stripped whole",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-multiline
annotations:
helm.sh/depends-on/resource-groups: >-
["databases",
"cache"]
keep.example.com/x: "y"
data:
k: v
`,
expected: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-multiline
annotations:
keep.example.com/x: "y"
data:
k: v
`,
},
{
name: "folded annotation value with internal blank line stripped whole",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-blank
annotations:
helm.sh/depends-on/resource-groups: >-
["a",
"b"]
keep.example.com/x: "y"
data:
k: v
`,
expected: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-blank
annotations:
keep.example.com/x: "y"
data:
k: v
`,
mustParseOutput: true,
},
{
name: "literal annotation value with internal blank line stripped whole",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-literal-blank
annotations:
helm.sh/depends-on/resource-groups: |
["a",
"b"]
keep.example.com/x: "y"
data:
script: |
preserve
me
`,
expected: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-literal-blank
annotations:
keep.example.com/x: "y"
data:
script: |
preserve
me
`,
mustParseOutput: true,
},
{
name: "value containing '#' handled",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-hash
annotations:
helm.sh/depends-on/resource-groups: '["a#b"]'
keep.example.com/x: "y"
data:
k: v
`,
expected: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-hash
annotations:
keep.example.com/x: "y"
data:
k: v
`,
},
{
name: "multi-doc stream: only affected doc changes",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-clean
data:
k: v
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-annotated
annotations:
helm.sh/depends-on/resource-groups: '["db"]'
keep.example.com/x: "y"
data:
k: v
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-data
data:
rules.txt: |
helm.sh/depends-on/resource-groups: ["db"]
keep
`,
expected: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-clean
data:
k: v
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-annotated
annotations:
keep.example.com/x: "y"
data:
k: v
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-data
data:
rules.txt: |
helm.sh/depends-on/resource-groups: ["db"]
keep
`,
},
{
name: "byte-identity fast path odd formatting",
input: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm-fast \n # comment about annotations\n\ndata:\n k: v ",
mustEqualVerbatim: true,
},
{
name: "byte-identity fast path CRLF",
input: "apiVersion: v1\r\nkind: ConfigMap\r\nmetadata:\r\n name: cm-crlf\r\ndata:\r\n k: v\r\n",
mustEqualVerbatim: true,
},
{
name: "quoted key form stripped",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-quoted
annotations:
"helm.sh/depends-on/resource-groups": '["a"]'
keep.example.com/x: "y"
data:
k: v
`,
expected: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-quoted
annotations:
keep.example.com/x: "y"
data:
k: v
`,
},
{
name: "separator inside block scalar does not corrupt",
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: cm-separator
data:
script: |
---
still byte-identical
`,
mustEqualVerbatim: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripHelmInternalAnnotations(tt.input)
if tt.mustParseOutput {
if err := yaml.Unmarshal([]byte(got), map[string]any{}); err != nil {
t.Errorf("expected output to parse as yaml, got error: %v\n%s", err, got)
}
}
if tt.expected != "" {
if got != tt.expected {
t.Errorf("expected exact output:\n%s\ngot:\n%s", tt.expected, got)
}
return
}
if tt.mustEqualVerbatim {
if got != tt.input {
t.Errorf("expected verbatim passthrough, got:\n%s", got)
}
return
}
for _, s := range tt.mustNotContain {
if strings.Contains(got, s) {
t.Errorf("output unexpectedly contains %q:\n%s", s, got)
}
}
for _, s := range tt.mustContain {
if !strings.Contains(got, s) {
t.Errorf("output missing expected %q:\n%s", s, got)
}
}
})
}
}

@ -0,0 +1,261 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"encoding/json"
"fmt"
"slices"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
)
const (
// AnnotationResourceGroup declares the resource-group a resource belongs to.
AnnotationResourceGroup = "helm.sh/resource-group"
// AnnotationDependsOnResourceGroups declares prerequisite resource-groups for a
// resource's group as a JSON string array.
AnnotationDependsOnResourceGroups = "helm.sh/depends-on/resource-groups"
// AnnotationReadinessSuccess declares custom readiness success conditions.
// Its value is a JSON array of expressions in the form:
// "{.fieldPath} <operator> <value>"
// If any success condition evaluates to true, the resource is considered ready.
// This is the canonical home of the key (moved from pkg/kube, which keeps
// value-identical aliases) so the pure sequencing plan builder can read it
// without importing cluster machinery.
AnnotationReadinessSuccess = "helm.sh/readiness-success"
// AnnotationReadinessFailure declares custom readiness failure conditions.
// Its value is a JSON array of expressions in the form:
// "{.fieldPath} <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"
)
// helmInternalSequencingAnnotations lists annotation keys used by Helm for
// resource sequencing that are NOT valid Kubernetes annotation keys (their
// names contain multiple `/` separators). Helm strips these before applying
// resources to the API server, and before printing manifests via
// `helm template` so downstream tooling like `kubectl apply` accepts the
// output unmodified.
//
// It is unexported, and the regex in manifest.go is compiled from it once at
// init. External callers read it through the HelmInternalSequencingAnnotations
// accessor (which returns a copy) so the canonical list — and the regex derived
// from it — cannot be mutated, avoiding the silent-no-op and data-race footguns
// of an exported mutable slice.
var helmInternalSequencingAnnotations = []string{
AnnotationDependsOnResourceGroups,
}
// HelmInternalSequencingAnnotations returns a copy of the helm-internal
// sequencing annotation keys that Helm strips from rendered output. A copy is
// returned so callers cannot mutate the canonical list.
func HelmInternalSequencingAnnotations() []string {
return slices.Clone(helmInternalSequencingAnnotations)
}
// ResourceGroupResult holds the output of ParseResourceGroups.
type ResourceGroupResult struct {
// Groups maps group name to manifests assigned to that group.
Groups map[string][]Manifest
// GroupDeps maps group name to the group names it depends on.
GroupDeps map[string][]string
// Unsequenced contains manifests that should be deployed outside the DAG.
Unsequenced []Manifest
}
// ParseResourceGroups extracts resource-group annotations from rendered
// manifests and partitions them into sequenced groups and unsequenced manifests.
//
// Manifests without a resource-group annotation are treated as unsequenced.
// Invalid depends-on annotations emit a warning and demote that manifest to the
// unsequenced batch. References to unknown groups emit a warning and demote the
// entire referencing group to the unsequenced batch. If the same resource is
// assigned to different groups, ParseResourceGroups returns an error.
func ParseResourceGroups(manifests []Manifest) (ResourceGroupResult, []string, error) {
result := ResourceGroupResult{
Groups: make(map[string][]Manifest),
GroupDeps: make(map[string][]string),
}
var warnings []string
resourceAssignments := make(map[string]string)
groupDeps := make(map[string][]string)
groupOrder := make([]string, 0)
for _, manifest := range manifests {
groupName, hasGroup := resourceGroupName(manifest)
if !hasGroup {
result.Unsequenced = append(result.Unsequenced, manifest)
continue
}
resourceID := resourceGroupResourceID(manifest)
if existingGroup, ok := resourceAssignments[resourceID]; ok && existingGroup != groupName {
return ResourceGroupResult{}, warnings, fmt.Errorf(
"resource %q assigned to multiple resource groups %q and %q",
resourceID,
existingGroup,
groupName,
)
}
resourceAssignments[resourceID] = groupName
deps, warning, err := resourceGroupDependencies(manifest)
if err != nil {
warnings = append(warnings, warning)
result.Unsequenced = append(result.Unsequenced, manifest)
continue
}
if _, ok := result.Groups[groupName]; !ok {
groupOrder = append(groupOrder, groupName)
}
result.Groups[groupName] = append(result.Groups[groupName], manifest)
groupDeps[groupName] = appendUniqueStrings(groupDeps[groupName], deps...)
}
for changed := true; changed; {
changed = false
for _, groupName := range groupOrder {
groupManifests, ok := result.Groups[groupName]
if !ok {
continue
}
missingDep := firstMissingDependency(result.Groups, groupDeps[groupName])
if missingDep == "" {
continue
}
warnings = append(warnings, fmt.Sprintf(
"group %q depends-on non-existent group %q; moving group to unsequenced batch",
groupName,
missingDep,
))
result.Unsequenced = append(result.Unsequenced, groupManifests...)
delete(result.Groups, groupName)
changed = true
}
}
for _, groupName := range groupOrder {
if _, ok := result.Groups[groupName]; !ok {
continue
}
result.GroupDeps[groupName] = groupDeps[groupName]
}
return result, warnings, nil
}
// BuildResourceGroupDAG constructs a DAG from the resource-group parse result.
// Each group becomes a DAG node and every dependency becomes an edge.
func BuildResourceGroupDAG(result ResourceGroupResult) (*chartutil.DAG, error) {
dag := chartutil.NewDAG()
for groupName := range result.Groups {
dag.AddNode(groupName)
}
for groupName, deps := range result.GroupDeps {
for _, dep := range deps {
if err := dag.AddEdge(dep, groupName); err != nil {
return nil, fmt.Errorf("resource-group sequencing edge %s→%s: %w", dep, groupName, err)
}
}
}
return dag, nil
}
func resourceGroupName(manifest Manifest) (string, bool) {
if manifest.Head == nil || manifest.Head.Metadata == nil || len(manifest.Head.Metadata.Annotations) == 0 {
return "", false
}
groupName, ok := manifest.Head.Metadata.Annotations[AnnotationResourceGroup]
if !ok || groupName == "" {
return "", false
}
return groupName, true
}
func resourceGroupDependencies(manifest Manifest) ([]string, string, error) {
annotations := manifest.Head.Metadata.Annotations
rawDeps, ok := annotations[AnnotationDependsOnResourceGroups]
if !ok {
return nil, "", nil
}
var deps []string
if err := json.Unmarshal([]byte(rawDeps), &deps); err != nil {
return nil, fmt.Sprintf(
"manifest %q: invalid JSON in %s annotation: %v; moving to unsequenced batch",
manifest.Name,
AnnotationDependsOnResourceGroups,
err,
), err
}
return deps, "", nil
}
func firstMissingDependency(groups map[string][]Manifest, deps []string) string {
for _, dep := range deps {
if _, ok := groups[dep]; !ok {
return dep
}
}
return ""
}
func appendUniqueStrings(existing []string, values ...string) []string {
seen := make(map[string]struct{}, len(existing))
for _, value := range existing {
seen[value] = struct{}{}
}
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
existing = append(existing, value)
seen[value] = struct{}{}
}
return existing
}
func resourceGroupResourceID(manifest Manifest) string {
if manifest.Head == nil || manifest.Head.Metadata == nil || manifest.Head.Metadata.Name == "" {
return manifest.Name
}
return fmt.Sprintf("%s/%s/%s/%s", manifest.Head.Version, manifest.Head.Kind, manifest.Head.Metadata.Namespace, manifest.Head.Metadata.Name)
}

@ -0,0 +1,381 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package util
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseResourceGroups_NoAnnotations(t *testing.T) {
t.Parallel()
result, warnings := parseResourceGroups(t,
makeManifest("plain-a", "chart/templates/plain-a.yaml", nil),
makeManifest("plain-b", "chart/templates/plain-b.yaml", nil),
)
assert.Empty(t, result.Groups)
assert.Empty(t, result.GroupDeps)
require.Len(t, result.Unsequenced, 2)
assert.Equal(t, []string{"chart/templates/plain-a.yaml", "chart/templates/plain-b.yaml"}, manifestPaths(result.Unsequenced))
assert.Empty(t, warnings)
}
func TestParseResourceGroups_GroupWithDependency(t *testing.T) {
t.Parallel()
result, warnings, batches := parseResourceGroupBatches(t,
makeManifest("database", "chart/templates/database.yaml", map[string]string{
AnnotationResourceGroup: "database",
}),
makeManifest("app", "chart/templates/app.yaml", map[string]string{
AnnotationResourceGroup: "app",
AnnotationDependsOnResourceGroups: `["database"]`,
}),
)
require.Len(t, result.Groups, 2)
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
assert.Empty(t, result.Unsequenced)
assert.Empty(t, warnings)
}
func TestParseResourceGroups_MultipleResourcesPerGroup(t *testing.T) {
t.Parallel()
result, warnings, batches := parseResourceGroupBatches(t,
makeManifest("database-config", "chart/templates/database-config.yaml", map[string]string{
AnnotationResourceGroup: "database",
}),
makeManifest("database-secret", "chart/templates/database-secret.yaml", map[string]string{
AnnotationResourceGroup: "database",
}),
makeManifest("app", "chart/templates/app.yaml", map[string]string{
AnnotationResourceGroup: "app",
AnnotationDependsOnResourceGroups: `["database"]`,
}),
)
require.Len(t, result.Groups["database"], 2)
assert.Equal(t, []string{
"chart/templates/database-config.yaml",
"chart/templates/database-secret.yaml",
}, manifestPaths(result.Groups["database"]))
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
assert.Empty(t, warnings)
}
func TestParseResourceGroups_ResourceAssignedToMultipleGroups(t *testing.T) {
t.Parallel()
_, _, err := ParseResourceGroups([]Manifest{
makeManifest("shared", "chart/templates/database.yaml", map[string]string{
AnnotationResourceGroup: "database",
}),
makeManifest("shared", "chart/templates/cache.yaml", map[string]string{
AnnotationResourceGroup: "cache",
}),
})
require.Error(t, err)
assert.ErrorContains(t, err, "assigned to multiple resource groups")
assert.ErrorContains(t, err, "database")
assert.ErrorContains(t, err, "cache")
}
// TestParseResourceGroups_SameNameDifferentNamespaces locks the contract
// that two resources sharing apiVersion+kind+name but living in distinct
// namespaces are NOT collapsed into a single resource ID. Without the
// namespace dimension in resourceGroupResourceID, this scenario would
// raise a false "assigned to multiple resource groups" error.
func TestParseResourceGroups_SameNameDifferentNamespaces(t *testing.T) {
t.Parallel()
m1 := makeManifest("shared", "chart/templates/a.yaml", map[string]string{
AnnotationResourceGroup: "groupA",
})
m1.Head.Metadata.Namespace = "ns-a"
m2 := makeManifest("shared", "chart/templates/b.yaml", map[string]string{
AnnotationResourceGroup: "groupB",
})
m2.Head.Metadata.Namespace = "ns-b"
result, warnings, err := ParseResourceGroups([]Manifest{m1, m2})
require.NoError(t, err, "same-name resources in different namespaces must not collide")
assert.Empty(t, warnings)
assert.Len(t, result.Groups["groupA"], 1)
assert.Len(t, result.Groups["groupB"], 1)
}
func TestParseResourceGroups_NonExistentGroupReferenceWarning(t *testing.T) {
t.Parallel()
result, warnings := parseResourceGroups(t,
makeManifest("app", "chart/templates/app.yaml", map[string]string{
AnnotationResourceGroup: "app",
AnnotationDependsOnResourceGroups: `["missing"]`,
}),
)
assert.Empty(t, result.Groups)
require.Len(t, result.Unsequenced, 1)
assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced))
require.Len(t, warnings, 1)
assert.Contains(t, warnings[0], "non-existent group")
assert.Contains(t, warnings[0], "missing")
}
func TestParseResourceGroups_CascadingMissingDeps(t *testing.T) {
t.Parallel()
manifests := []Manifest{
makeManifest("app", "chart/templates/app.yaml", map[string]string{
AnnotationResourceGroup: "app",
AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeManifest("database", "chart/templates/database.yaml", map[string]string{
AnnotationResourceGroup: "database",
AnnotationDependsOnResourceGroups: `["missing"]`,
}),
}
for range 64 {
result, warnings := parseResourceGroups(t, manifests...)
assert.Empty(t, result.Groups)
assert.Empty(t, result.GroupDeps)
require.Len(t, result.Unsequenced, 2)
assert.ElementsMatch(t, []string{
"chart/templates/app.yaml",
"chart/templates/database.yaml",
}, manifestPaths(result.Unsequenced))
require.Len(t, warnings, 2)
assert.Contains(t, warnings[0], `group "database" depends-on non-existent group "missing"`)
assert.Contains(t, warnings[1], `group "app" depends-on non-existent group "database"`)
dag, err := BuildResourceGroupDAG(result)
require.NoError(t, err)
batches, err := dag.GetBatches()
require.NoError(t, err)
assert.Empty(t, batches)
}
}
func TestParseResourceGroups_InvalidDependsOnJSON(t *testing.T) {
t.Parallel()
result, warnings := parseResourceGroups(t,
makeManifest("app", "chart/templates/app.yaml", map[string]string{
AnnotationResourceGroup: "app",
AnnotationDependsOnResourceGroups: `not-valid-json`,
}),
)
assert.Empty(t, result.Groups)
require.Len(t, result.Unsequenced, 1)
assert.Equal(t, []string{"chart/templates/app.yaml"}, manifestPaths(result.Unsequenced))
require.Len(t, warnings, 1)
assert.Contains(t, warnings[0], "invalid JSON")
}
func TestParseResourceGroups_MixedSequencedAndUnsequenced(t *testing.T) {
t.Parallel()
result, warnings := parseResourceGroups(t,
makeManifest("database", "chart/templates/database.yaml", map[string]string{
AnnotationResourceGroup: "database",
}),
makeManifest("plain", "chart/templates/plain.yaml", nil),
)
require.Len(t, result.Groups, 1)
assert.Contains(t, result.Groups, "database")
require.Len(t, result.Unsequenced, 1)
assert.Equal(t, []string{"chart/templates/plain.yaml"}, manifestPaths(result.Unsequenced))
assert.Empty(t, warnings)
}
func TestResourceGroupDAG_CycleDetection(t *testing.T) {
t.Parallel()
result, _ := parseResourceGroups(t,
makeManifest("a", "chart/templates/a.yaml", map[string]string{
AnnotationResourceGroup: "a",
AnnotationDependsOnResourceGroups: `["c"]`,
}),
makeManifest("b", "chart/templates/b.yaml", map[string]string{
AnnotationResourceGroup: "b",
AnnotationDependsOnResourceGroups: `["a"]`,
}),
makeManifest("c", "chart/templates/c.yaml", map[string]string{
AnnotationResourceGroup: "c",
AnnotationDependsOnResourceGroups: `["b"]`,
}),
)
dag, err := BuildResourceGroupDAG(result)
require.NoError(t, err)
batches, err := dag.GetBatches()
require.Error(t, err)
assert.Nil(t, batches)
assert.ErrorContains(t, err, "cycle")
}
func TestParseResourceGroups_DeduplicatesDependencies(t *testing.T) {
t.Parallel()
result, warnings, batches := parseResourceGroupBatches(t,
makeManifest("database", "chart/templates/database.yaml", map[string]string{
AnnotationResourceGroup: "database",
}),
makeManifest("app-config", "chart/templates/app-config.yaml", map[string]string{
AnnotationResourceGroup: "app",
AnnotationDependsOnResourceGroups: `["database"]`,
}),
makeManifest("app-secret", "chart/templates/app-secret.yaml", map[string]string{
AnnotationResourceGroup: "app",
AnnotationDependsOnResourceGroups: `["database"]`,
}),
)
assert.Equal(t, []string{"database"}, result.GroupDeps["app"])
assert.Equal(t, [][]string{{"database"}, {"app"}}, batches)
assert.Empty(t, warnings)
}
func TestParseResourceGroups_IsolatedGroupsRemainInBatch0(t *testing.T) {
t.Parallel()
_, warnings, batches := parseResourceGroupBatches(t,
makeManifest("database", "chart/templates/database.yaml", map[string]string{
AnnotationResourceGroup: "database",
}),
makeManifest("metrics", "chart/templates/metrics.yaml", map[string]string{
AnnotationResourceGroup: "metrics",
}),
makeManifest("app", "chart/templates/app.yaml", map[string]string{
AnnotationResourceGroup: "app",
AnnotationDependsOnResourceGroups: `["database"]`,
}),
)
assert.Equal(t, [][]string{{"database", "metrics"}, {"app"}}, batches)
assert.Empty(t, warnings)
}
func TestParseResourceGroups_ComplexDAG(t *testing.T) {
t.Parallel()
_, warnings, batches := parseResourceGroupBatches(t,
makeManifest("cache", "chart/templates/cache.yaml", map[string]string{
AnnotationResourceGroup: "cache",
}),
makeManifest("database", "chart/templates/database.yaml", map[string]string{
AnnotationResourceGroup: "database",
}),
makeManifest("queue", "chart/templates/queue.yaml", map[string]string{
AnnotationResourceGroup: "queue",
}),
makeManifest("api", "chart/templates/api.yaml", map[string]string{
AnnotationResourceGroup: "api",
AnnotationDependsOnResourceGroups: `["database","queue"]`,
}),
makeManifest("worker", "chart/templates/worker.yaml", map[string]string{
AnnotationResourceGroup: "worker",
AnnotationDependsOnResourceGroups: `["cache","queue"]`,
}),
makeManifest("frontend", "chart/templates/frontend.yaml", map[string]string{
AnnotationResourceGroup: "frontend",
AnnotationDependsOnResourceGroups: `["api"]`,
}),
)
assert.Equal(t, [][]string{
{"cache", "database", "queue"},
{"api", "worker"},
{"frontend"},
}, batches)
assert.Empty(t, warnings)
}
func parseResourceGroups(t *testing.T, manifests ...Manifest) (ResourceGroupResult, []string) {
t.Helper()
result, warnings, err := ParseResourceGroups(manifests)
require.NoError(t, err)
return result, warnings
}
func parseResourceGroupBatches(t *testing.T, manifests ...Manifest) (ResourceGroupResult, []string, [][]string) {
t.Helper()
result, warnings := parseResourceGroups(t, manifests...)
dag, err := BuildResourceGroupDAG(result)
require.NoError(t, err)
batches, err := dag.GetBatches()
require.NoError(t, err)
return result, warnings, batches
}
func makeManifest(name, sourcePath string, annotations map[string]string) Manifest {
var content strings.Builder
fmt.Fprintf(&content, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: %s\n", name)
if len(annotations) > 0 {
content.WriteString(" annotations:\n")
for key, value := range annotations {
fmt.Fprintf(&content, " %s: %q\n", key, value)
}
}
head := &SimpleHead{
Version: "v1",
Kind: "ConfigMap",
Metadata: &struct {
Name string `json:"name"`
Namespace string `json:"namespace,omitempty"`
Annotations map[string]string `json:"annotations"`
}{
Name: name,
Annotations: annotations,
},
}
return Manifest{
Name: sourcePath,
Content: content.String(),
Head: head,
}
}
func manifestPaths(manifests []Manifest) []string {
paths := make([]string, 0, len(manifests))
for _, manifest := range manifests {
paths = append(paths, manifest.Name)
}
return paths
}
Loading…
Cancel
Save