From a7856c03987d714cec0525114d775afc551f0bc9 Mon Sep 17 00:00:00 2001 From: Mario Manno Date: Wed, 13 Mar 2024 14:09:23 +0100 Subject: [PATCH] Add ability to adopt unmanaged resources Allow the SDK actions to adopt existing resources. This allows install and update to overwrite resources. If TakeOwnership is not set, adoption is only possible if they existing resources have the right labels (managed-by) and annotations (release-name, ...). Signed-off-by: Mario Manno --- pkg/action/install.go | 10 ++- pkg/action/upgrade.go | 9 ++- pkg/action/validate.go | 25 ++++++++ pkg/action/validate_test.go | 121 +++++++++++++++++++++++++++++++++++- pkg/kube/resource.go | 2 +- 5 files changed, 161 insertions(+), 6 deletions(-) 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/ 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/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") 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 {