You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helm/pkg/action/uninstall_sequenced_test.go

799 lines
30 KiB

/*
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)
require.NoError(t, 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)
}