From 906f87ce1d6d7a46bc68601c1217623e40b78877 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Wed, 26 Nov 2025 21:59:49 +0000 Subject: [PATCH 1/6] feat: add ownership verification before deleting resources during uninstall Fixes #31333 Helm uninstall now verifies resource ownership before deletion by checking Helm labels and annotations. Resources not owned by the release being uninstalled are skipped with warnings, preventing accidental deletion of resources belonging to other releases. Signed-off-by: Evans Mungai --- pkg/action/uninstall.go | 75 ++++++++++++++++++++++++++++-- pkg/action/validate.go | 49 ++++++++++++++++++++ pkg/action/validate_test.go | 92 +++++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 4 deletions(-) diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index d5474490c..71db30a19 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -81,6 +81,46 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) if err != nil { return nil, err } + + // Verify ownership in dry-run mode to show what would actually be deleted + manifests := releaseutil.SplitManifests(r.Manifest) + _, files, err := releaseutil.SortManifests(manifests, nil, releaseutil.UninstallOrder) + if err == nil { + filesToKeep, filesToDelete := filterManifestsToKeep(files) + + var builder strings.Builder + for _, file := range filesToDelete { + builder.WriteString("\n---\n" + file.Content) + } + + resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false) + if err == nil && len(resources) > 0 { + _, unownedResources, err := verifyOwnershipBeforeDelete(resources, r.Name, r.Namespace) + if err == nil && len(unownedResources) > 0 { + u.cfg.Logger().Warn("dry-run: resources would be skipped because they are not owned by this release", + "release", r.Name, + "count", len(unownedResources)) + for _, info := range unownedResources { + u.cfg.Logger().Warn("dry-run: would skip resource", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace) + } + } + } + + // Include kept resources in dry-run info + if len(filesToKeep) > 0 { + var kept strings.Builder + 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) + } + res := &releasei.UninstallReleaseResponse{Release: r, Info: kept.String()} + return res, nil + } + } + return &releasei.UninstallReleaseResponse{Release: r}, nil } @@ -256,13 +296,40 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri if err != nil { return nil, "", []error{fmt.Errorf("unable to build kubernetes objects for delete: %w", err)} } + + // Verify ownership before deleting resources + var ownedResources, unownedResources kube.ResourceList if len(resources) > 0 { - _, errs = u.cfg.KubeClient.Delete(resources, parseCascadingFlag(u.DeletionPropagation)) + ownedResources, unownedResources, err = verifyOwnershipBeforeDelete(resources, rel.Name, rel.Namespace) + if err != nil { + return nil, "", []error{fmt.Errorf("unable to verify resource ownership: %w", err)} + } + + // 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) + } + kept.WriteString(fmt.Sprintf("\n%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) + } + } + + // Delete only owned resources + if len(ownedResources) > 0 { + u.cfg.Logger().Debug("deleting resources part of this release", "count", len(ownedResources), "propagation", u.DeletionPropagation) + _, errs = u.cfg.KubeClient.Delete(ownedResources, parseCascadingFlag(u.DeletionPropagation, u.cfg.Logger())) + } } - return resources, kept.String(), errs + return ownedResources, kept.String(), errs } -func parseCascadingFlag(cascadingFlag string) v1.DeletionPropagation { +func parseCascadingFlag(cascadingFlag string, logger *slog.Logger) v1.DeletionPropagation { switch cascadingFlag { case "orphan": return v1.DeletePropagationOrphan @@ -271,7 +338,7 @@ func parseCascadingFlag(cascadingFlag string) v1.DeletionPropagation { case "background": return v1.DeletePropagationBackground default: - slog.Debug("uninstall: given cascade value, defaulting to delete propagation background", "value", cascadingFlag) + logger.Debug("uninstall: given cascade value, defaulting to delete propagation background", "value", cascadingFlag) return v1.DeletePropagationBackground } } diff --git a/pkg/action/validate.go b/pkg/action/validate.go index 1bef5a742..3a9d63253 100644 --- a/pkg/action/validate.go +++ b/pkg/action/validate.go @@ -93,6 +93,55 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN return requireUpdate, err } +// verifyOwnershipBeforeDelete checks that resources in the list are owned by the specified release. +// It returns two lists: owned resources (safe to delete) and unowned resources (should skip). +// Resources that are not found are considered owned (already deleted, safe to attempt delete). +func verifyOwnershipBeforeDelete(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, kube.ResourceList, error) { + var owned kube.ResourceList + var unowned kube.ResourceList + + err := resources.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + // If client is not available, skip verification (test scenario or build failure) + if info.Client == nil { + infoCopy := *info + owned.Append(&infoCopy) + return nil + } + + helper := resource.NewHelper(info.Client, info.Mapping) + existing, err := helper.Get(info.Namespace, info.Name) + if err != nil { + if apierrors.IsNotFound(err) { + // Resource already deleted, skip deletion + return nil + } + // Cannot fetch resource (network/permission issue), cannot verify ownership + infoCopy := *info + unowned.Append(&infoCopy) + return nil + } + + // Verify ownership of the existing resource + if err := checkOwnership(existing, releaseName, releaseNamespace); err != nil { + // Resource not owned by this release, cannot delete + infoCopy := *info + unowned.Append(&infoCopy) + return nil + } + + // Resource is owned by this release, can delete + infoCopy := *info + owned.Append(&infoCopy) + return nil + }) + + return owned, unowned, err +} + func checkOwnership(obj runtime.Object, releaseName, releaseNamespace string) error { lbls, err := accessor.Labels(obj) if err != nil { diff --git a/pkg/action/validate_test.go b/pkg/action/validate_test.go index 879a5fa4f..d3e0edcde 100644 --- a/pkg/action/validate_test.go +++ b/pkg/action/validate_test.go @@ -210,6 +210,98 @@ func TestCheckOwnership(t *testing.T) { assert.EqualError(t, err, `invalid ownership metadata; label validation error: key "app.kubernetes.io/managed-by" must equal "Helm": current value is "helm"`) } +func TestVerifyOwnershipBeforeDelete(t *testing.T) { + var ( + releaseName = "rel-a" + releaseNamespace = "ns-a" + labels = map[string]string{ + appManagedByLabel: appManagedByHelm, + } + annotations = map[string]string{ + helmReleaseNameAnnotation: releaseName, + helmReleaseNamespaceAnnotation: releaseNamespace, + } + wrongAnnotations = map[string]string{ + helmReleaseNameAnnotation: "rel-b", + helmReleaseNamespaceAnnotation: releaseNamespace, + } + ) + + // Test all resources properly owned + t.Run("all resources owned", func(t *testing.T) { + owned1 := newDeploymentWithOwner("owned1", "ns-a", labels, annotations) + owned2 := newDeploymentWithOwner("owned2", "ns-a", labels, annotations) + resources := kube.ResourceList{owned1, owned2} + + ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 2) + assert.Len(t, unownedList, 0) + }) + + // Test mix of owned and unowned resources + t.Run("mixed ownership", func(t *testing.T) { + owned := newDeploymentWithOwner("owned", "ns-a", labels, annotations) + unowned := newDeploymentWithOwner("unowned", "ns-a", labels, wrongAnnotations) + resources := kube.ResourceList{owned, unowned} + + ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 1) + assert.Len(t, unownedList, 1) + assert.Equal(t, "owned", ownedList[0].Name) + assert.Equal(t, "unowned", unownedList[0].Name) + }) + + // Test resource not found (should be skipped - not in either list) + t.Run("resource not found", func(t *testing.T) { + missing := newMissingDeployment("missing", "ns-a") + resources := kube.ResourceList{missing} + + ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 0) + assert.Len(t, unownedList, 0) + }) + + // Test resource with no ownership metadata + t.Run("no ownership metadata", func(t *testing.T) { + noMeta := newDeploymentWithOwner("no-meta", "ns-a", nil, nil) + resources := kube.ResourceList{noMeta} + + ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 0) + assert.Len(t, unownedList, 1) + }) + + // Test resource owned by different release + t.Run("owned by different release", func(t *testing.T) { + otherRelease := newDeploymentWithOwner("other", "ns-a", labels, wrongAnnotations) + resources := kube.ResourceList{otherRelease} + + ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 0) + assert.Len(t, unownedList, 1) + }) + + // Test mixed scenario: owned, unowned, and missing resources + t.Run("mixed with missing resources", func(t *testing.T) { + owned := newDeploymentWithOwner("owned", "ns-a", labels, annotations) + unowned := newDeploymentWithOwner("unowned", "ns-a", labels, wrongAnnotations) + missing := newMissingDeployment("missing", "ns-a") + resources := kube.ResourceList{owned, unowned, missing} + + ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 1) + assert.Len(t, unownedList, 1) + assert.Equal(t, "owned", ownedList[0].Name) + assert.Equal(t, "unowned", unownedList[0].Name) + }) +} + func TestSetMetadataVisitor(t *testing.T) { var ( err error From 6d5b5aab32be039f8f56b0c497a7f4af28ce21f0 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Wed, 26 Nov 2025 22:22:57 +0000 Subject: [PATCH 2/6] Additional logging for ownership verification in dry-run mode Signed-off-by: Evans Mungai --- pkg/action/uninstall.go | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 71db30a19..7e78f820e 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -95,16 +95,30 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false) if err == nil && len(resources) > 0 { - _, unownedResources, err := verifyOwnershipBeforeDelete(resources, r.Name, r.Namespace) - if err == nil && len(unownedResources) > 0 { - u.cfg.Logger().Warn("dry-run: resources would be skipped because they are not owned by this release", - "release", r.Name, - "count", len(unownedResources)) - for _, info := range unownedResources { - u.cfg.Logger().Warn("dry-run: would skip resource", - "kind", info.Mapping.GroupVersionKind.Kind, - "name", info.Name, - "namespace", info.Namespace) + ownedResources, unownedResources, err := verifyOwnershipBeforeDelete(resources, r.Name, r.Namespace) + if err == nil { + if len(unownedResources) > 0 { + u.cfg.Logger().Warn("dry-run: resources would be skipped because they are not owned by this release", + "release", r.Name, + "count", len(unownedResources)) + for _, info := range unownedResources { + u.cfg.Logger().Warn("dry-run: would skip resource", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace) + } + } + + if len(ownedResources) > 0 { + u.cfg.Logger().Debug("dry-run: resources would be deleted", + "release", r.Name, + "count", len(ownedResources)) + for _, info := range ownedResources { + u.cfg.Logger().Debug("dry-run: would delete resource", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace) + } } } } From f552950b05066d4d6a9d6cf9a91c26656f4a90fc Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Thu, 27 Nov 2025 15:32:37 +0000 Subject: [PATCH 3/6] Add ownership verification to uninstall action This commit adds ownership verification to the uninstall action. It checks that the resource to be deleted is owned by the chart before deleting it. Fixes: #31333 Signed-off-by: Evans Mungai --- pkg/action/uninstall.go | 8 +- pkg/action/uninstall_test.go | 186 ++++++++++++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 7e78f820e..c1e5b00e7 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -336,7 +336,13 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri // Delete only owned resources if len(ownedResources) > 0 { - u.cfg.Logger().Debug("deleting resources part of this release", "count", len(ownedResources), "propagation", u.DeletionPropagation) + 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())) } } diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index fba1e391f..bed9c04ac 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -17,9 +17,11 @@ limitations under the License. package action import ( + "bytes" "errors" "fmt" "io" + "log/slog" "testing" "github.com/stretchr/testify/assert" @@ -147,9 +149,16 @@ func TestUninstallRelease_Cascade(t *testing.T) { } }` unAction.cfg.Releases.Create(rel) + + // Create dummy resources with Mapping but no Client - this skips ownership verification + // (nil Client is treated as owned) and goes directly to delete + dummyResources := kube.ResourceList{ + newDeploymentResource("secret", ""), + } + failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.DeleteError = fmt.Errorf("Uninstall with cascade failed") - failer.BuildDummy = true + failer.DummyResources = dummyResources unAction.cfg.KubeClient = failer _, err := unAction.Run(rel.Name) require.Error(t, err) @@ -169,3 +178,178 @@ func TestUninstallRun_UnreachableKubeClient(t *testing.T) { assert.Nil(t, result) assert.ErrorContains(t, err, "connection refused") } + +func TestUninstallRelease_OwnershipVerification(t *testing.T) { + is := assert.New(t) + + // Create a buffer to capture log output + logBuffer := &bytes.Buffer{} + handler := slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}) + + config := actionConfigFixture(t) + config.SetLogger(handler) + + unAction := NewUninstall(config) + unAction.DisableHooks = true + unAction.DryRun = false + unAction.KeepHistory = true + + rel := releaseStub() + rel.Name = "ownership-test" + rel.Namespace = "default" + rel.Manifest = `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap + labels: + app.kubernetes.io/managed-by: Helm + annotations: + meta.helm.sh/release-name: ownership-test + meta.helm.sh/release-namespace: default +data: + key: value` + config.Releases.Create(rel) + + // Create dummy resources with proper ownership metadata + labels := map[string]string{ + "app.kubernetes.io/managed-by": "Helm", + } + annotations := map[string]string{ + "meta.helm.sh/release-name": "ownership-test", + "meta.helm.sh/release-namespace": "default", + } + dummyResources := kube.ResourceList{ + newDeploymentWithOwner("owned-deploy", "default", labels, annotations), + } + failer := config.KubeClient.(*kubefake.FailingKubeClient) + failer.DummyResources = dummyResources + + resi, err := unAction.Run(rel.Name) + is.NoError(err) + is.NotNil(resi) + res, err := releaserToV1Release(resi.Release) + is.NoError(err) + is.Equal(common.StatusUninstalled, res.Info.Status) + + // Verify log contains debug message about deleting owned resource + logOutput := logBuffer.String() + is.Contains(logOutput, "deleting resource owned by this release") + is.Contains(logOutput, "owned-deploy") + is.Contains(logOutput, "Deployment") +} + +func TestUninstallRelease_OwnershipVerification_WithKeepPolicy(t *testing.T) { + is := assert.New(t) + + // Create a buffer to capture log output + logBuffer := &bytes.Buffer{} + handler := slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}) + + config := actionConfigFixture(t) + config.SetLogger(handler) + + unAction := NewUninstall(config) + unAction.DisableHooks = true + unAction.DryRun = false + unAction.KeepHistory = true + + rel := releaseStub() + rel.Name = "keep-and-ownership" + rel.Namespace = "default" + rel.Manifest = `apiVersion: v1 +kind: Secret +metadata: + name: kept-secret + annotations: + helm.sh/resource-policy: keep + meta.helm.sh/release-name: keep-and-ownership + meta.helm.sh/release-namespace: default + labels: + app.kubernetes.io/managed-by: Helm +type: Opaque +data: + password: cGFzc3dvcmQ= +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: deleted-configmap + labels: + app.kubernetes.io/managed-by: Helm + annotations: + meta.helm.sh/release-name: keep-and-ownership + meta.helm.sh/release-namespace: default +data: + key: value` + config.Releases.Create(rel) + + // Create dummy resources - one unowned to test logging + dummyResources := kube.ResourceList{ + newDeploymentWithOwner("unowned-deploy", "default", nil, nil), + } + failer := config.KubeClient.(*kubefake.FailingKubeClient) + failer.DummyResources = dummyResources + + res, err := unAction.Run(rel.Name) + is.NoError(err) + is.NotNil(res) + // Should contain info about kept resources + is.Contains(res.Info, "kept due to the resource policy") + + // Verify log contains warning about skipped unowned resource + logOutput := logBuffer.String() + is.Contains(logOutput, "skipping delete of resource not owned by this release") + is.Contains(logOutput, "unowned-deploy") +} + +func TestUninstallRelease_DryRun_OwnershipVerification(t *testing.T) { + is := assert.New(t) + + // Create a buffer to capture log output + logBuffer := &bytes.Buffer{} + handler := slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}) + + config := actionConfigFixture(t) + config.SetLogger(handler) + + unAction := NewUninstall(config) + unAction.DisableHooks = true + unAction.DryRun = true + + rel := releaseStub() + rel.Name = "dryrun-ownership" + rel.Namespace = "default" + rel.Manifest = `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap + labels: + app.kubernetes.io/managed-by: Helm + annotations: + meta.helm.sh/release-name: dryrun-ownership + meta.helm.sh/release-namespace: default +data: + key: value` + config.Releases.Create(rel) + + // Create dummy resources - one unowned to test dry-run logging + dummyResources := kube.ResourceList{ + newDeploymentWithOwner("dryrun-unowned-deploy", "default", nil, nil), + } + failer := config.KubeClient.(*kubefake.FailingKubeClient) + failer.DummyResources = dummyResources + + resi, err := unAction.Run(rel.Name) + is.NoError(err) + is.NotNil(resi) + is.NotNil(resi.Release) + res, err := releaserToV1Release(resi.Release) + is.NoError(err) + is.Equal("dryrun-ownership", res.Name) + + // Verify log contains dry-run warning about resources that would be skipped + logOutput := logBuffer.String() + is.Contains(logOutput, "dry-run: would skip resource") + is.Contains(logOutput, "dryrun-unowned-deploy") + is.Contains(logOutput, "Deployment") +} From 9728c5af31c7039e9806037c4e59e46eeb43d79c Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Tue, 5 May 2026 18:09:23 +0100 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Evans Mungai --- pkg/action/validate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/action/validate.go b/pkg/action/validate.go index 102259bf1..88771053b 100644 --- a/pkg/action/validate.go +++ b/pkg/action/validate.go @@ -95,7 +95,7 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN if err := checkOwnership(existing, releaseName, releaseNamespace); err != nil { return fmt.Errorf("%s exists and cannot be imported into the current release: %s", resourceString(info), err) } - +// Resources that are not found are skipped because they are already deleted and do not need deletion. infoCopy := *info requireUpdate.Append(&infoCopy) return nil From 007713087bd4f381bb8b74f66f35af9e3dafa376 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Tue, 5 May 2026 19:02:23 +0100 Subject: [PATCH 5/6] fix: address Copilot review on ownership-verified uninstall - Distinguish unowned resources from those whose ownership could not be verified (e.g. RBAC/network errors). Unverifiable resources are now returned in a separate list with the underlying error so log output and the "kept" summary describe what actually happened. - Only prefix the kept summary with the resource-policy header when there are policy-kept resources, so users with only ownership-skipped resources don't see a misleading header. - Update verifyOwnershipBeforeDelete doc comment to reflect that not-found resources are excluded from all returned lists. - Assert success of Releases.Create in uninstall tests so fixture setup failures are surfaced immediately. Signed-off-by: Evans Mungai --- pkg/action/uninstall.go | 52 ++++++++++++++++++++++++++++++------ pkg/action/uninstall_test.go | 8 +++--- pkg/action/validate.go | 28 ++++++++++++++----- pkg/action/validate_test.go | 12 ++++----- 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 73927ed9d..b14305112 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -102,7 +102,7 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false) if err == nil && len(resources) > 0 { - ownedResources, unownedResources, err := verifyOwnershipBeforeDelete(resources, r.Name, r.Namespace) + ownedResources, unownedResources, unverifiableResources, err := verifyOwnershipBeforeDelete(resources, r.Name, r.Namespace) if err == nil { if len(unownedResources) > 0 { u.cfg.Logger().Warn("dry-run: resources would be skipped because they are not owned by this release", @@ -116,6 +116,19 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) } } + if len(unverifiableResources) > 0 { + u.cfg.Logger().Warn("dry-run: resources would be skipped because their ownership could not be verified", + "release", r.Name, + "count", len(unverifiableResources)) + for _, ur := range unverifiableResources { + u.cfg.Logger().Warn("dry-run: would skip resource (ownership could not be verified)", + "kind", ur.Info.Mapping.GroupVersionKind.Kind, + "name", ur.Info.Name, + "namespace", ur.Info.Namespace, + "error", ur.Err) + } + } + if len(ownedResources) > 0 { u.cfg.Logger().Debug("dry-run: resources would be deleted", "release", r.Name, @@ -207,9 +220,6 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) return nil, fmt.Errorf("failed to delete release: %s", name) } - if kept != "" { - kept = "These resources were kept due to the resource policy:\n" + kept - } res.Info = kept if err := waiter.WaitForDelete(deletedResources, u.Timeout); err != nil { @@ -323,8 +333,11 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri filesToKeep, filesToDelete := filterManifestsToKeep(files) var kept strings.Builder - for _, f := range filesToKeep { - fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name) + 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) + } } var builder strings.Builder @@ -339,8 +352,9 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri // Verify ownership before deleting resources var ownedResources, unownedResources kube.ResourceList + var unverifiableResources []unverifiableResource if len(resources) > 0 { - ownedResources, unownedResources, err = verifyOwnershipBeforeDelete(resources, rel.Name, rel.Namespace) + ownedResources, unownedResources, unverifiableResources, err = verifyOwnershipBeforeDelete(resources, rel.Name, rel.Namespace) if err != nil { return nil, "", []error{fmt.Errorf("unable to verify resource ownership: %w", err)} } @@ -354,12 +368,34 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri "namespace", info.Namespace, "release", rel.Name) } - kept.WriteString(fmt.Sprintf("\n%d resource(s) were not deleted because they are not owned by this release:\n", len(unownedResources))) + 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) } } + // 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) + } + } + // Delete only owned resources if len(ownedResources) > 0 { for _, info := range ownedResources { diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index 2bf3d3878..3b6d09c33 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -148,7 +148,7 @@ func TestUninstallRelease_Cascade(t *testing.T) { "password": "password" } }` - unAction.cfg.Releases.Create(rel) + require.NoError(t, unAction.cfg.Releases.Create(rel)) // Create dummy resources with Mapping but no Client - this skips ownership verification // (nil Client is treated as owned) and goes directly to delete @@ -208,7 +208,7 @@ metadata: meta.helm.sh/release-namespace: default data: key: value` - config.Releases.Create(rel) + require.NoError(t, config.Releases.Create(rel)) // Create dummy resources with proper ownership metadata labels := map[string]string{ @@ -281,7 +281,7 @@ metadata: meta.helm.sh/release-namespace: default data: key: value` - config.Releases.Create(rel) + require.NoError(t, config.Releases.Create(rel)) // Create dummy resources - one unowned to test logging dummyResources := kube.ResourceList{ @@ -330,7 +330,7 @@ metadata: meta.helm.sh/release-namespace: default data: key: value` - config.Releases.Create(rel) + require.NoError(t, config.Releases.Create(rel)) // Create dummy resources - one unowned to test dry-run logging dummyResources := kube.ResourceList{ diff --git a/pkg/action/validate.go b/pkg/action/validate.go index 88771053b..bc6cb4e9c 100644 --- a/pkg/action/validate.go +++ b/pkg/action/validate.go @@ -104,12 +104,26 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN return requireUpdate, err } +// unverifiableResource pairs a resource with the error encountered while attempting +// to verify its ownership (for example, RBAC or network failures). +type unverifiableResource struct { + Info *resource.Info + Err error +} + // verifyOwnershipBeforeDelete checks that resources in the list are owned by the specified release. -// It returns two lists: owned resources (safe to delete) and unowned resources (should skip). -// Resources that are not found are considered owned (already deleted, safe to attempt delete). -func verifyOwnershipBeforeDelete(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, kube.ResourceList, error) { +// It returns three lists: +// - owned: resources confirmed to be owned by the release (safe to delete). +// - unowned: resources that exist but are not owned by the release (should be skipped). +// - unverifiable: resources whose ownership could not be determined due to a fetch +// error (e.g. RBAC or network issues), paired with the underlying error. +// +// Resources that are not found on the server are excluded from all returned lists, +// since they have already been deleted and require no further action. +func verifyOwnershipBeforeDelete(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, kube.ResourceList, []unverifiableResource, error) { var owned kube.ResourceList var unowned kube.ResourceList + var unverifiable []unverifiableResource err := resources.Visit(func(info *resource.Info, err error) error { if err != nil { @@ -127,12 +141,12 @@ func verifyOwnershipBeforeDelete(resources kube.ResourceList, releaseName, relea existing, err := helper.Get(info.Namespace, info.Name) if err != nil { if apierrors.IsNotFound(err) { - // Resource already deleted, skip deletion + // Resource already deleted; nothing to do. return nil } - // Cannot fetch resource (network/permission issue), cannot verify ownership + // Cannot fetch resource (network/permission issue); ownership unverifiable. infoCopy := *info - unowned.Append(&infoCopy) + unverifiable = append(unverifiable, unverifiableResource{Info: &infoCopy, Err: err}) return nil } @@ -150,7 +164,7 @@ func verifyOwnershipBeforeDelete(resources kube.ResourceList, releaseName, relea return nil }) - return owned, unowned, err + return owned, unowned, unverifiable, err } func checkOwnership(obj runtime.Object, releaseName, releaseNamespace string) error { diff --git a/pkg/action/validate_test.go b/pkg/action/validate_test.go index b425299f5..1cb1002d6 100644 --- a/pkg/action/validate_test.go +++ b/pkg/action/validate_test.go @@ -234,7 +234,7 @@ func TestVerifyOwnershipBeforeDelete(t *testing.T) { owned2 := newDeploymentWithOwner("owned2", "ns-a", labels, annotations) resources := kube.ResourceList{owned1, owned2} - ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) assert.NoError(t, err) assert.Len(t, ownedList, 2) assert.Len(t, unownedList, 0) @@ -246,7 +246,7 @@ func TestVerifyOwnershipBeforeDelete(t *testing.T) { unowned := newDeploymentWithOwner("unowned", "ns-a", labels, wrongAnnotations) resources := kube.ResourceList{owned, unowned} - ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) assert.NoError(t, err) assert.Len(t, ownedList, 1) assert.Len(t, unownedList, 1) @@ -259,7 +259,7 @@ func TestVerifyOwnershipBeforeDelete(t *testing.T) { missing := newMissingDeployment("missing", "ns-a") resources := kube.ResourceList{missing} - ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) assert.NoError(t, err) assert.Len(t, ownedList, 0) assert.Len(t, unownedList, 0) @@ -270,7 +270,7 @@ func TestVerifyOwnershipBeforeDelete(t *testing.T) { noMeta := newDeploymentWithOwner("no-meta", "ns-a", nil, nil) resources := kube.ResourceList{noMeta} - ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) assert.NoError(t, err) assert.Len(t, ownedList, 0) assert.Len(t, unownedList, 1) @@ -281,7 +281,7 @@ func TestVerifyOwnershipBeforeDelete(t *testing.T) { otherRelease := newDeploymentWithOwner("other", "ns-a", labels, wrongAnnotations) resources := kube.ResourceList{otherRelease} - ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) assert.NoError(t, err) assert.Len(t, ownedList, 0) assert.Len(t, unownedList, 1) @@ -294,7 +294,7 @@ func TestVerifyOwnershipBeforeDelete(t *testing.T) { missing := newMissingDeployment("missing", "ns-a") resources := kube.ResourceList{owned, unowned, missing} - ownedList, unownedList, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) assert.NoError(t, err) assert.Len(t, ownedList, 1) assert.Len(t, unownedList, 1) From 03a65a71f7397620315747ecac4ba60b73f63623 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Tue, 5 May 2026 23:13:14 +0100 Subject: [PATCH 6/6] Fix lint errors Signed-off-by: Evans Mungai --- internal/chart/v3/util/dependencies.go | 5 +++-- pkg/action/hooks.go | 4 ++-- pkg/action/uninstall_test.go | 3 +-- pkg/action/validate.go | 2 +- pkg/chart/v2/util/dependencies.go | 5 +++-- pkg/cmd/history.go | 5 +++-- pkg/repo/v1/index.go | 17 +++++++++-------- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index 9c4d8e80f..b31f7eb96 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" chart "helm.sh/helm/v4/internal/chart/v3" @@ -242,8 +243,8 @@ func set(path []string, data map[string]any) map[string]any { return nil } cur := data - for i := len(path) - 1; i >= 0; i-- { - cur = map[string]any{path[i]: cur} + for _, v := range slices.Backward(path) { + cur = map[string]any{v: cur} } return cur } diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go index a4a8da7a6..e7be37bd8 100644 --- a/pkg/action/hooks.go +++ b/pkg/action/hooks.go @@ -150,8 +150,8 @@ func (cfg *Configuration) execHookWithDelayedShutdown(rl *release.Release, hook return func() error { // If all hooks are successful, check the annotation of each hook to determine whether the hook should be deleted // or output should be logged under succeeded condition. If so, then clear the corresponding resource object in each hook - for i := len(executingHooks) - 1; i >= 0; i-- { - h := executingHooks[i] + for _, v := range slices.Backward(executingHooks) { + h := v if err := cfg.outputLogsByPolicy(h, rl.Namespace, release.HookOutputOnSucceeded); err != nil { // We log here as we still want to attempt hook resource deletion even if output logging fails. log.Printf("error outputting logs for hook failure: %v", err) diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index 3b6d09c33..90f69d639 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -19,7 +19,6 @@ package action import ( "bytes" "errors" - "fmt" "io" "log/slog" "testing" @@ -157,7 +156,7 @@ func TestUninstallRelease_Cascade(t *testing.T) { } failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.DeleteError = fmt.Errorf("Uninstall with cascade failed") + failer.DeleteError = errors.New("Uninstall with cascade failed") failer.DummyResources = dummyResources unAction.cfg.KubeClient = failer _, err := unAction.Run(rel.Name) diff --git a/pkg/action/validate.go b/pkg/action/validate.go index bc6cb4e9c..cce8e32ee 100644 --- a/pkg/action/validate.go +++ b/pkg/action/validate.go @@ -95,7 +95,7 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN if err := checkOwnership(existing, releaseName, releaseNamespace); err != nil { return fmt.Errorf("%s exists and cannot be imported into the current release: %s", resourceString(info), err) } -// Resources that are not found are skipped because they are already deleted and do not need deletion. + // Resources that are not found are skipped because they are already deleted and do not need deletion. infoCopy := *info requireUpdate.Append(&infoCopy) return nil diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index abd673f9d..f28a4f4b1 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" "helm.sh/helm/v4/internal/copystructure" @@ -242,8 +243,8 @@ func set(path []string, data map[string]any) map[string]any { return nil } cur := data - for i := len(path) - 1; i >= 0; i-- { - cur = map[string]any{path[i]: cur} + for _, v := range slices.Backward(path) { + cur = map[string]any{v: cur} } return cur } diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index 3349b7bc1..7c0f3ecde 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "io" + "slices" "strconv" "time" @@ -207,8 +208,8 @@ func getHistory(client *action.History, name string) (releaseHistory, error) { } func getReleaseHistory(rls []*release.Release) (history releaseHistory) { - for i := len(rls) - 1; i >= 0; i-- { - r := rls[i] + for _, v := range slices.Backward(rls) { + r := v c := formatChartName(r.Chart) s := r.Info.Status.String() v := r.Version diff --git a/pkg/repo/v1/index.go b/pkg/repo/v1/index.go index 3dbdf7dfc..825dc2753 100644 --- a/pkg/repo/v1/index.go +++ b/pkg/repo/v1/index.go @@ -25,6 +25,7 @@ import ( "os" "path" "path/filepath" + "slices" "sort" "strings" "time" @@ -356,21 +357,21 @@ func loadIndex(data []byte, source string) (*IndexFile, error) { } for name, cvs := range i.Entries { - for idx := len(cvs) - 1; idx >= 0; idx-- { - if cvs[idx] == nil { + for idx, v := range slices.Backward(cvs) { + if v == nil { slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q from %s: empty entry", name, source)) cvs = append(cvs[:idx], cvs[idx+1:]...) continue } // When metadata section missing, initialize with no data - if cvs[idx].Metadata == nil { - cvs[idx].Metadata = &chart.Metadata{} + if v.Metadata == nil { + v.Metadata = &chart.Metadata{} } - if cvs[idx].APIVersion == "" { - cvs[idx].APIVersion = chart.APIVersionV1 + if v.APIVersion == "" { + v.APIVersion = chart.APIVersionV1 } - if err := cvs[idx].Validate(); ignoreSkippableChartValidationError(err) != nil { - slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err)) + if err := v.Validate(); ignoreSkippableChartValidationError(err) != nil { + slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q %q from %s: %s", name, v.Version, source, err)) cvs = append(cvs[:idx], cvs[idx+1:]...) } }