Repository: helm/helm
Pull request #12876 (2024-03-13 13:10:21)

Allow install, update action to adopt existing resources (sdk only)
We use the Helm SDK in a GitOps operator and need more control over existing resources. We considered patching them with the Helm labels and annotations, but that would mean another update request per resource. Furthermore it seems wrong to apply Helm labels to resources, before the chart has actually been applied to them.

The PR introduces a new option `TakeOwnership`. If `TakeOwnership` is set, existing resources will be adopted, overwritten, etc. when installing or updating. Helm will not check for labels.

If it is not set, Helm behaves like before: adoption is only possible if they existing resources have the right labels
(`managed-by`) and annotations (`release-name`, ...).



Patch:
diff --git a/pkg/action/install.go b/pkg/action/install.go
index e3538a4f5..6c923ca34 100644
--- a/pkg/action/install.go
+++ b/pkg/action/install.go
@@ -105,7 +105,9 @@ type Install struct {
 	// Used by helm template to add the release as part of OutputDir path
 	// OutputDir/<ReleaseName>
 	UseReleaseName bool
-	PostRenderer   postrender.PostRenderer
+	// TakeOwnership will ignore the check for helm annotations and take ownership of the resources.
+	TakeOwnership bool
+	PostRenderer  postrender.PostRenderer
 	// Lock to control raceconditions when the process receives a SIGTERM
 	Lock sync.Mutex
 }
@@ -335,7 +337,11 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
 	// deleting the release because the manifest will be pointing at that
 	// resource
 	if !i.ClientOnly && !isUpgrade && len(resources) > 0 {
-		toBeAdopted, err = existingResourceConflict(resources, rel.Name, rel.Namespace)
+		if i.TakeOwnership {
+			toBeAdopted, err = requireAdoption(resources)
+		} else {
+			toBeAdopted, err = existingResourceConflict(resources, rel.Name, rel.Namespace)
+		}
 		if err != nil {
 			return nil, errors.Wrap(err, "Unable to continue with install")
 		}
diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go
index ffb7538a6..2fc40de38 100644
--- a/pkg/action/upgrade.go
+++ b/pkg/action/upgrade.go
@@ -110,6 +110,8 @@ type Upgrade struct {
 	Lock sync.Mutex
 	// Enable DNS lookups when rendering templates
 	EnableDNS bool
+	// TakeOwnership will skip the check for helm annotations and adopt all existing resources.
+	TakeOwnership bool
 }
 
 type resultMessage struct {
@@ -329,7 +331,12 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
 		}
 	}
 
-	toBeUpdated, err := existingResourceConflict(toBeCreated, upgradedRelease.Name, upgradedRelease.Namespace)
+	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, errors.Wrap(err, "Unable to continue with update")
 	}
diff --git a/pkg/action/validate.go b/pkg/action/validate.go
index 73eb1937b..127e9bf96 100644
--- a/pkg/action/validate.go
+++ b/pkg/action/validate.go
@@ -37,6 +37,31 @@ const (
 	helmReleaseNamespaceAnnotation = "meta.helm.sh/release-namespace"
 )
 
+// requireAdoption returns the subset of resources that already exist in the cluster.
+func requireAdoption(resources kube.ResourceList) (kube.ResourceList, error) {
+	var requireUpdate kube.ResourceList
+
+	err := resources.Visit(func(info *resource.Info, err error) error {
+		if err != nil {
+			return err
+		}
+
+		helper := resource.NewHelper(info.Client, info.Mapping)
+		_, err = helper.Get(info.Namespace, info.Name)
+		if err != nil {
+			if apierrors.IsNotFound(err) {
+				return nil
+			}
+			return errors.Wrapf(err, "could not get information about the resource %s", resourceString(info))
+		}
+
+		requireUpdate.Append(info)
+		return nil
+	})
+
+	return requireUpdate, err
+}
+
 func existingResourceConflict(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, error) {
 	var requireUpdate kube.ResourceList
 
diff --git a/pkg/kube/resource.go b/pkg/kube/resource.go
index ee8f83a25..d441db8a7 100644
--- a/pkg/kube/resource.go
+++ b/pkg/kube/resource.go
@@ -26,7 +26,7 @@ func (r *ResourceList) Append(val *resource.Info) {
 	*r = append(*r, val)
 }
 
-// Visit implements resource.Visitor.
+// Visit implements resource.Visitor. The visitor stops if fn returns an error.
 func (r ResourceList) Visit(fn resource.VisitorFunc) error {
 	for _, i := range r {
 		if err := fn(i, nil); err != nil {


Test patch:
diff --git a/pkg/action/validate_test.go b/pkg/action/validate_test.go
index a9c1cb49c..b3a7ae22a 100644
--- a/pkg/action/validate_test.go
+++ b/pkg/action/validate_test.go
@@ -17,17 +17,23 @@ limitations under the License.
 package action
 
 import (
+	"bytes"
+	"io"
+	"net/http"
 	"testing"
 
 	"helm.sh/helm/v3/pkg/kube"
 
-	appsv1 "k8s.io/api/apps/v1"
-
 	"github.com/stretchr/testify/assert"
+
+	appsv1 "k8s.io/api/apps/v1"
 	"k8s.io/apimachinery/pkg/api/meta"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/cli-runtime/pkg/resource"
+	"k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest/fake"
 )
 
 func newDeploymentResource(name, namespace string) *resource.Info {
@@ -46,6 +52,117 @@ func newDeploymentResource(name, namespace string) *resource.Info {
 	}
 }
 
+func newMissingDeployment(name, namespace string) *resource.Info {
+	info := &resource.Info{
+		Name:      name,
+		Namespace: namespace,
+		Mapping: &meta.RESTMapping{
+			Resource:         schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployment"},
+			GroupVersionKind: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+			Scope:            meta.RESTScopeNamespace,
+		},
+		Object: &appsv1.Deployment{
+			ObjectMeta: v1.ObjectMeta{
+				Name:      name,
+				Namespace: namespace,
+			},
+		},
+		Client: fakeClientWith(http.StatusNotFound, appsV1GV, ""),
+	}
+
+	return info
+}
+
+func newDeploymentWithOwner(name, namespace string, labels map[string]string, annotations map[string]string) *resource.Info {
+	obj := &appsv1.Deployment{
+		ObjectMeta: v1.ObjectMeta{
+			Name:        name,
+			Namespace:   namespace,
+			Labels:      labels,
+			Annotations: annotations,
+		},
+	}
+	return &resource.Info{
+		Name:      name,
+		Namespace: namespace,
+		Mapping: &meta.RESTMapping{
+			Resource:         schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployment"},
+			GroupVersionKind: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+			Scope:            meta.RESTScopeNamespace,
+		},
+		Object: obj,
+		Client: fakeClientWith(http.StatusOK, appsV1GV, runtime.EncodeOrDie(appsv1Codec, obj)),
+	}
+}
+
+var (
+	appsV1GV    = schema.GroupVersion{Group: "apps", Version: "v1"}
+	appsv1Codec = scheme.Codecs.CodecForVersions(scheme.Codecs.LegacyCodec(appsV1GV), scheme.Codecs.UniversalDecoder(appsV1GV), appsV1GV, appsV1GV)
+)
+
+func stringBody(body string) io.ReadCloser {
+	return io.NopCloser(bytes.NewReader([]byte(body)))
+}
+
+func fakeClientWith(code int, gv schema.GroupVersion, body string) *fake.RESTClient {
+	return &fake.RESTClient{
+		GroupVersion:         gv,
+		NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
+		Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
+			header := http.Header{}
+			header.Set("Content-Type", runtime.ContentTypeJSON)
+			return &http.Response{
+				StatusCode: code,
+				Header:     header,
+				Body:       stringBody(body),
+			}, nil
+		}),
+	}
+}
+
+func TestRequireAdoption(t *testing.T) {
+	var (
+		missing   = newMissingDeployment("missing", "ns-a")
+		existing  = newDeploymentWithOwner("existing", "ns-a", nil, nil)
+		resources = kube.ResourceList{missing, existing}
+	)
+
+	// Verify that a resource that lacks labels/annotations can be adopted
+	found, err := requireAdoption(resources)
+	assert.NoError(t, err)
+	assert.Len(t, found, 1)
+	assert.Equal(t, found[0], existing)
+}
+
+func TestExistingResourceConflict(t *testing.T) {
+	var (
+		releaseName      = "rel-name"
+		releaseNamespace = "rel-namespace"
+		labels           = map[string]string{
+			appManagedByLabel: appManagedByHelm,
+		}
+		annotations = map[string]string{
+			helmReleaseNameAnnotation:      releaseName,
+			helmReleaseNamespaceAnnotation: releaseNamespace,
+		}
+		missing   = newMissingDeployment("missing", "ns-a")
+		existing  = newDeploymentWithOwner("existing", "ns-a", labels, annotations)
+		conflict  = newDeploymentWithOwner("conflict", "ns-a", nil, nil)
+		resources = kube.ResourceList{missing, existing}
+	)
+
+	// Verify only existing resources are returned
+	found, err := existingResourceConflict(resources, releaseName, releaseNamespace)
+	assert.NoError(t, err)
+	assert.Len(t, found, 1)
+	assert.Equal(t, found[0], existing)
+
+	// Verify that an existing resource that lacks labels/annotations results in an error
+	resources = append(resources, conflict)
+	_, err = existingResourceConflict(resources, releaseName, releaseNamespace)
+	assert.Error(t, err)
+}
+
 func TestCheckOwnership(t *testing.T) {
 	deployFoo := newDeploymentResource("foo", "ns-a")