Repository: crossplane-contrib/provider-helm (go)

Issue:
Trigger helm release upgrade when PatchesFrom are changed
In PatchesFrom we don't detect changes from the related configmaps and secrets.

We need to detect if a change has occurred and trigger a helm upgrade per the design [triggering-an-upgrade](https://github.com/crossplane/crossplane/blob/master/design/one-pager-helm-provider.md#triggering-a-helm-upgrade)



Pull request description:
Trigger upgrade when kustomize patches are modified
Add logic to get shaSum of all computed post render hook patches and trigger helm upgrade when there is a change.

Resolves #11 

Patch:
diff --git a/apis/v1alpha1/types.go b/apis/v1alpha1/types.go
index 6e8e5bf..8a10b44 100644
--- a/apis/v1alpha1/types.go
+++ b/apis/v1alpha1/types.go
@@ -88,6 +88,7 @@ type ReleaseSpec struct {
 type ReleaseStatus struct {
 	runtimev1alpha1.ResourceStatus `json:",inline"`
 	AtProvider                     ReleaseObservation `json:"atProvider,omitempty"`
+	PatchesSha                     string             `json:"patchesSha,omitempty"`
 }
 
 // +kubebuilder:object:root=true
diff --git a/build b/build
index 5e94f74..ee92f43 160000
--- a/build
+++ b/build
@@ -1,1 +1,1 @@
-Subproject commit 5e94f742227fd73d69fb14da54daef391e56b937
+Subproject commit ee92f43008f3ca565c578267d85cc58d62c8a0b5
diff --git a/config/crd/helm.crossplane.io_releases.yaml b/config/crd/helm.crossplane.io_releases.yaml
index 51a1d59..73c0903 100644
--- a/config/crd/helm.crossplane.io_releases.yaml
+++ b/config/crd/helm.crossplane.io_releases.yaml
@@ -342,6 +342,8 @@ spec:
                 - type
                 type: object
               type: array
+            patchesSha:
+              type: string
           type: object
       required:
       - spec
diff --git a/go.sum b/go.sum
index 21684bc..1626796 100644
--- a/go.sum
+++ b/go.sum
@@ -673,6 +673,7 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d/go.mod h1:w5+eXa0mYznDkHaMCXA4XYffjlH+cy1oyKbfzJXa2Do=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/shirou/gopsutil v0.0.0-20190901111213-e4ec7b275ada/go.mod h1:WWnYX4lzhCH5h/3YBfyVA3VbLYjlMZZAQcW9ojMexNc=
 github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4/go.mod h1:qsXQc7+bwAM3Q1u/4XEfrquwF8Lw7D7y5cD8CuHnfIc=
@@ -756,7 +757,6 @@ github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1/go.mod h1:QcJo0QPSf
 github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca h1:1CFlNzQhALwjS9mBAUkycX616GzgsuYUOCHA5+HSlXI=
 github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg=
 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yujunz/go-getter v1.4.1-lite h1:FhvNc94AXMZkfqUwfMKhnQEC9phkphSGdPTL7tIdhOM=
 github.com/yujunz/go-getter v1.4.1-lite/go.mod h1:sbmqxXjyLunH1PkF3n7zSlnVeMvmYUuIl9ZVs/7NyCc=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI=
@@ -805,7 +805,6 @@ golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0=
 golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 h1:/Tl7pH94bvbAAHBdZJT947M/+gp0+CqQXDtMRC0fseo=
 golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -823,8 +822,6 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
 golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180112015858-5ccada7d0a7b/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1154,9 +1151,6 @@ sigs.k8s.io/controller-tools v0.2.4 h1:la1h46EzElvWefWLqfsXrnsO3lZjpkI0asTpX6h8P
 sigs.k8s.io/controller-tools v0.2.4/go.mod h1:m/ztfQNocGYBgTTCmFdnK94uVvgxeZeE3LtJvd/jIzA=
 sigs.k8s.io/controller-tools v0.3.0 h1:y3YD99XOyWaXkiF1kd41uRvfp/64teWcrEZFuHxPhJ4=
 sigs.k8s.io/controller-tools v0.3.0/go.mod h1:enhtKGfxZD1GFEoMgP8Fdbu+uKQ/cq1/WGJhdVChfvI=
-sigs.k8s.io/controller-tools v0.3.1-0.20200617211605-651903477185 h1:wLsmaqTEgs3DIfNzr0u/AfPHSVJbWHj/eevcS4AFvFE=
-sigs.k8s.io/controller-tools v0.3.1-0.20200617211605-651903477185/go.mod h1:JuPG+FXjAeZL7eGmTuXUJduEMlI2/kGqb0rUGlVi+Yo=
-sigs.k8s.io/kustomize v1.0.11 h1:Yb+6DDt9+aR2AvQApvUaKS/ugteeG4MPyoFeUHiPOjk=
 sigs.k8s.io/kustomize v2.0.3+incompatible h1:JUufWFNlI44MdtnjUqVnvh29rR37PQFzPbLXqhyOyX0=
 sigs.k8s.io/kustomize v2.0.3+incompatible/go.mod h1:MkjgH3RdOWrievjo6c9T245dYlB5QeXV4WCbnt/PEpU=
 sigs.k8s.io/kustomize/api v0.5.1 h1:iHGTs5LcnJGqHstUSxWD/kX6XZgmd82x79LLlZwDU0I=
diff --git a/pkg/clients/helm/client.go b/pkg/clients/helm/client.go
index b72274b..f3e8b14 100644
--- a/pkg/clients/helm/client.go
+++ b/pkg/clients/helm/client.go
@@ -166,7 +166,7 @@ func (hc *client) Upgrade(release string, chartDef ChartDefinition, vals map[str
 	}
 
 	if len(patches) > 0 {
-		hc.installClient.PostRenderer = &KustomizationRender{
+		hc.upgradeClient.PostRenderer = &KustomizationRender{
 			patches: patches,
 			logger:  hc.log,
 		}
diff --git a/pkg/controller/observe.go b/pkg/controller/observe.go
index ae9f3c1..f62f063 100644
--- a/pkg/controller/observe.go
+++ b/pkg/controller/observe.go
@@ -20,10 +20,9 @@ import (
 	"context"
 	"reflect"
 
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
 	"github.com/pkg/errors"
 	"helm.sh/helm/v3/pkg/release"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/crossplane-contrib/provider-helm/apis/v1alpha1"
 )
@@ -46,7 +45,7 @@ func generateObservation(in *release.Release) v1alpha1.ReleaseObservation {
 }
 
 // isUpToDate checks whether desired spec up to date with the observed state for a given release
-func isUpToDate(ctx context.Context, kube client.Client, in *v1alpha1.ReleaseParameters, observed *release.Release) (bool, error) {
+func isUpToDate(ctx context.Context, kube client.Client, in *v1alpha1.ReleaseParameters, observed *release.Release, s v1alpha1.ReleaseStatus) (bool, error) {
 	oc := observed.Chart
 	if oc == nil {
 		return false, errors.New(errChartNilInObservedRelease)
@@ -71,5 +70,14 @@ func isUpToDate(ctx context.Context, kube client.Client, in *v1alpha1.ReleasePar
 		return false, nil
 	}
 
+	changed, err := newPatcher().hasUpdates(ctx, kube, in.PatchesFrom, s)
+	if err != nil {
+		return false, errors.Wrap(err, errFailedToLoadPatches)
+	}
+
+	if changed {
+		return false, nil
+	}
+
 	return true, nil
 }
diff --git a/pkg/controller/patch.go b/pkg/controller/patch.go
index 6a793f7..8096e61 100644
--- a/pkg/controller/patch.go
+++ b/pkg/controller/patch.go
@@ -2,6 +2,10 @@ package controller
 
 import (
 	"context"
+	"crypto/sha256"
+	"encoding/json"
+	"fmt"
+	"strings"
 
 	"github.com/pkg/errors"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -16,7 +20,65 @@ const (
 	errFailedToUnmarshallPatch = "failed to unmarshal patch"
 )
 
-func getPatchesFromSpec(ctx context.Context, kube client.Client, vals []v1alpha1.ValueFromSource) ([]ktypes.Patch, error) {
+// Patcher interface for managing Kustomize patches and detecting updates
+type Patcher interface {
+	hasUpdates(ctx context.Context, kube client.Client, in []v1alpha1.ValueFromSource, s v1alpha1.ReleaseStatus) (bool, error)
+	patchGetter
+	patchHasher
+}
+
+type patchGetter interface {
+	getFromSpec(ctx context.Context, kube client.Client, vals []v1alpha1.ValueFromSource) ([]ktypes.Patch, error)
+}
+
+type patchHasher interface {
+	shaOf(patches []ktypes.Patch) (string, error)
+}
+
+func newPatcher() Patcher {
+	return patch{
+		patchHasher: patchSha{},
+		patchGetter: patchGet{},
+	}
+}
+
+type patch struct {
+	patchHasher
+	patchGetter
+}
+
+func (p patch) hasUpdates(ctx context.Context, kube client.Client, in []v1alpha1.ValueFromSource, s v1alpha1.ReleaseStatus) (bool, error) {
+	patches, err := p.getFromSpec(ctx, kube, in)
+	if err != nil {
+		return false, err
+	}
+
+	sum, err := p.shaOf(patches)
+	if err != nil {
+		return false, err
+	}
+
+	return !strings.EqualFold(sum, s.PatchesSha), nil
+}
+
+type patchSha struct{}
+
+func (patchSha) shaOf(patches []ktypes.Patch) (string, error) {
+	if len(patches) == 0 {
+		return "", nil
+	}
+
+	jb, err := json.Marshal(patches)
+	if err != nil {
+		return "", err
+	}
+
+	return fmt.Sprintf("%x", sha256.Sum256(jb)), nil
+}
+
+type patchGet struct{}
+
+func (patchGet) getFromSpec(ctx context.Context, kube client.Client, vals []v1alpha1.ValueFromSource) ([]ktypes.Patch, error) {
 	var base []ktypes.Patch // nolint:prealloc
 
 	for _, vf := range vals {
diff --git a/pkg/controller/release.go b/pkg/controller/release.go
index e334c6f..518eace 100644
--- a/pkg/controller/release.go
+++ b/pkg/controller/release.go
@@ -52,6 +52,7 @@ const (
 	errFailedToComposeValues      = "failed to compose values"
 	errFailedToCreateRestConfig   = "cannot create new rest config using provider secret"
 	errFailedToLoadPatches        = "failed to load patches"
+	errFailedToUpdatePatchSha     = "failed to update patch sha"
 )
 
 // SetupRelease adds a controller that reconciles Release managed resources.
@@ -123,6 +124,7 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E
 		localKube: c.client,
 		kube:      k,
 		helm:      h,
+		patch:     newPatcher(),
 	}, errors.Wrap(err, errNewKubernetesClient)
 }
 
@@ -131,6 +133,7 @@ type helmExternal struct {
 	localKube client.Client
 	kube      client.Client
 	helm      helmClient.Client
+	patch     Patcher
 }
 
 func (e *helmExternal) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
@@ -157,8 +160,7 @@ func (e *helmExternal) Observe(ctx context.Context, mg resource.Managed) (manage
 	}
 
 	cr.Status.AtProvider = generateObservation(rel)
-
-	u, err := isUpToDate(ctx, e.localKube, &cr.Spec.ForProvider, rel)
+	u, err := isUpToDate(ctx, e.localKube, &cr.Spec.ForProvider, rel, cr.Status)
 	if err != nil {
 		return managed.ExternalObservation{}, errors.Wrap(err, errFailedToCheckIfUpToDate)
 	}
@@ -171,6 +173,7 @@ func (e *helmExternal) Observe(ctx context.Context, mg resource.Managed) (manage
 
 func (e *helmExternal) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) {
 	cr, ok := mg.(*v1alpha1.Release)
+
 	if !ok {
 		return managed.ExternalCreation{}, errors.New(errNotRelease)
 	}
@@ -187,7 +190,7 @@ func (e *helmExternal) Create(ctx context.Context, mg resource.Managed) (managed
 		return managed.ExternalCreation{}, errors.Wrap(err, errFailedToBuildChartDef)
 	}
 
-	p, err := getPatchesFromSpec(ctx, e.localKube, cr.Spec.ForProvider.PatchesFrom)
+	p, err := e.patch.getFromSpec(ctx, e.localKube, cr.Spec.ForProvider.PatchesFrom)
 	if err != nil {
 		return managed.ExternalCreation{}, errors.Wrap(err, errFailedToLoadPatches)
 	}
@@ -196,11 +199,18 @@ func (e *helmExternal) Create(ctx context.Context, mg resource.Managed) (managed
 	if err != nil {
 		return managed.ExternalCreation{}, errors.Wrap(err, errFailedToInstall)
 	}
+
 	if rel == nil {
 		return managed.ExternalCreation{}, errors.New(errLastReleaseIsNil)
 	}
 
+	sha, err := e.patch.shaOf(p)
+	if err != nil {
+		return managed.ExternalCreation{}, errors.Wrap(err, errFailedToUpdatePatchSha)
+	}
+	cr.Status.PatchesSha = sha
 	cr.Status.AtProvider = generateObservation(rel)
+
 	return managed.ExternalCreation{}, nil
 }
 
@@ -222,7 +232,7 @@ func (e *helmExternal) Update(ctx context.Context, mg resource.Managed) (managed
 		return managed.ExternalUpdate{}, errors.Wrap(err, errFailedToBuildChartDef)
 	}
 
-	p, err := getPatchesFromSpec(ctx, e.localKube, cr.Spec.ForProvider.PatchesFrom)
+	p, err := e.patch.getFromSpec(ctx, e.localKube, cr.Spec.ForProvider.PatchesFrom)
 	if err != nil {
 		return managed.ExternalUpdate{}, errors.Wrap(err, errFailedToLoadPatches)
 	}
@@ -235,6 +245,12 @@ func (e *helmExternal) Update(ctx context.Context, mg resource.Managed) (managed
 		return managed.ExternalUpdate{}, errors.New(errLastReleaseIsNil)
 	}
 
+	sha, err := e.patch.shaOf(p)
+	if err != nil {
+		return managed.ExternalUpdate{}, errors.Wrap(err, errFailedToUpdatePatchSha)
+	}
+	cr.Status.PatchesSha = sha
+
 	cr.Status.AtProvider = generateObservation(rel)
 	return managed.ExternalUpdate{}, nil
 }


Test patch:
diff --git a/pkg/controller/observe_test.go b/pkg/controller/observe_test.go
index 7b6d601..5fe0a21 100644
--- a/pkg/controller/observe_test.go
+++ b/pkg/controller/observe_test.go
@@ -4,10 +4,9 @@ import (
 	"context"
 	"testing"
 
-	"github.com/pkg/errors"
-
 	"github.com/crossplane/crossplane-runtime/pkg/test"
 	"github.com/google/go-cmp/cmp"
+	"github.com/pkg/errors"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/release"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -281,10 +280,41 @@ func Test_isUpToDate(t *testing.T) {
 				err: nil,
 			},
 		},
+		"SuccessPatchesAdded": {
+			args: args{
+				kube: &test.MockClient{
+					MockGet: nil,
+				},
+				in: &v1alpha1.ReleaseParameters{
+					Chart: v1alpha1.ChartSpec{
+						Name:    testChart,
+						Version: testVersion,
+					},
+					ValuesSpec: v1alpha1.ValuesSpec{
+						Values: testReleaseConfigStr,
+					},
+				},
+				observed: &release.Release{
+					Chart: &chart.Chart{
+						Raw: nil,
+						Metadata: &chart.Metadata{
+							Name:    testChart,
+							Version: testVersion,
+						},
+					},
+					Config: testReleaseConfig,
+				},
+			},
+			want: want{
+				out: true,
+				err: nil,
+			},
+		},
 	}
+
 	for name, tc := range cases {
 		t.Run(name, func(t *testing.T) {
-			got, gotErr := isUpToDate(context.Background(), tc.args.kube, tc.args.in, tc.args.observed)
+			got, gotErr := isUpToDate(context.Background(), tc.args.kube, tc.args.in, tc.args.observed, v1alpha1.ReleaseStatus{})
 			if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" {
 				t.Fatalf("isUpToDate(...): -want error, +got error: %s", diff)
 			}
diff --git a/pkg/controller/patch_test.go b/pkg/controller/patch_test.go
index 6bdc207..1ca8aa8 100644
--- a/pkg/controller/patch_test.go
+++ b/pkg/controller/patch_test.go
@@ -31,8 +31,207 @@ patches:
   target:
     kind: Deployment
 `
+	testMockPatchSha = "magicsha"
 )
 
+func Test_ShaOf(t *testing.T) {
+	pd := types.Patch{
+		Patch: "- op: add\n  path: /spec/template/spec/nodeSelector\n  value:\n    node.size: really-big\n    aws.az: us-west-2a\n    patch.name: " + testCMName,
+		Target: &types.Selector{
+			Gvk: resid.Gvk{
+				Kind: "Deployment",
+			},
+		},
+	}
+	type args struct {
+		patches []types.Patch
+	}
+
+	type want struct {
+		sha string
+		err error
+	}
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"SuccessSha": {
+			args: args{
+				patches: []types.Patch{
+					pd,
+				},
+			},
+			want: want{
+				sha: "a38aee2ef839c3e754444bf80c625900d4b38102591083069bd8f5e55389e8c2",
+				err: nil,
+			},
+		},
+		"Success2Sha": {
+			args: args{
+				patches: []types.Patch{
+					pd,
+					pd,
+				},
+			},
+			want: want{
+				sha: "770183113f382b18c0a0c0343e735852023331b38db70de4f2111aa85d765ec9",
+				err: nil,
+			},
+		},
+		"SuccessEmptyPatchSha": {
+			args: args{
+				patches: []types.Patch{},
+			},
+			want: want{
+				sha: "",
+				err: nil,
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			p := patchSha{}
+			got, gotErr := p.shaOf(tc.args.patches)
+
+			if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" {
+				t.Fatalf("shaOf(...): -want error, +got error: %s", diff)
+			}
+
+			if diff := cmp.Diff(tc.want.sha, got); diff != "" {
+				t.Errorf("shaOf(...): -want result, +got result: %s", diff)
+			}
+		})
+	}
+}
+
+type mockShaOf struct {
+	sum string
+	err error
+}
+
+func (m mockShaOf) shaOf(patches []types.Patch) (string, error) {
+	return m.sum, m.err
+}
+
+type mockPatchGet struct {
+	patches []types.Patch
+	err     error
+}
+
+func (m mockPatchGet) getFromSpec(ctx context.Context, kube client.Client, vals []v1alpha1.ValueFromSource) ([]types.Patch, error) {
+	return m.patches, m.err
+}
+
+func Test_PatchHasUpdates(t *testing.T) {
+	pd := types.Patch{
+		Patch: "- op: add\n  path: /spec/template/spec/nodeSelector\n  value:\n    node.size: really-big\n    aws.az: us-west-2a\n    patch.name: " + testCMName,
+		Target: &types.Selector{
+			Gvk: resid.Gvk{
+				Kind: "Deployment",
+			},
+		},
+	}
+	type args struct {
+		existingSha           string
+		getPatchesFromSpec    []types.Patch
+		getPatchesFromSpecErr error
+		shaOf                 string
+		shaOfErr              error
+	}
+
+	type want struct {
+		updates bool
+		err     error
+	}
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"SuccessNoUpdates": {
+			args: args{
+				shaOf: testMockPatchSha,
+				getPatchesFromSpec: []types.Patch{
+					pd,
+				},
+				existingSha: testMockPatchSha,
+			},
+			want: want{
+				updates: false,
+				err:     nil,
+			},
+		},
+		"SuccessUpdatesNewSha": {
+			args: args{
+				shaOf: testMockPatchSha,
+				getPatchesFromSpec: []types.Patch{
+					pd,
+				},
+				existingSha: "",
+			},
+			want: want{
+				updates: true,
+				err:     nil,
+			},
+		},
+		"SuccessUpdatesDifferentSha": {
+			args: args{
+				shaOf: testMockPatchSha,
+				getPatchesFromSpec: []types.Patch{
+					pd,
+				},
+				existingSha: "nonMatchingSha",
+			},
+			want: want{
+				updates: true,
+				err:     nil,
+			},
+		},
+		"ErrGetPatches": {
+			args: args{
+				getPatchesFromSpecErr: fmt.Errorf("boom"),
+			},
+			want: want{
+				updates: false,
+				err:     fmt.Errorf("boom"),
+			},
+		},
+		"ErrGetSha": {
+			args: args{
+				shaOfErr: fmt.Errorf("boom"),
+			},
+			want: want{
+				updates: false,
+				err:     fmt.Errorf("boom"),
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			p := patch{
+				patchHasher: mockShaOf{
+					sum: tc.shaOf,
+					err: tc.shaOfErr,
+				},
+				patchGetter: mockPatchGet{
+					patches: tc.args.getPatchesFromSpec,
+					err:     tc.args.getPatchesFromSpecErr,
+				},
+			}
+			s := v1alpha1.ReleaseStatus{
+				PatchesSha: tc.existingSha,
+			}
+			got, gotErr := p.hasUpdates(context.Background(), nil, nil, s)
+
+			if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" {
+				t.Fatalf("Patch.hasUpdates(...): -want error, +got error: %s", diff)
+			}
+			if diff := cmp.Diff(tc.want.updates, got); diff != "" {
+				t.Errorf("Patch.hasUpdates(...): -want result, +got result: %s", diff)
+			}
+		})
+	}
+}
+
 func Test_getPatchesFromSpec(t *testing.T) {
 	type args struct {
 		kube client.Client
@@ -194,12 +393,14 @@ func Test_getPatchesFromSpec(t *testing.T) {
 	}
 	for name, tc := range cases {
 		t.Run(name, func(t *testing.T) {
-			got, gotErr := getPatchesFromSpec(context.Background(), tc.args.kube, tc.args.spec)
+			pg := patchGet{}
+			got, gotErr := pg.getFromSpec(context.Background(), tc.args.kube, tc.args.spec)
 			if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" {
-				t.Fatalf("getPatchesFromSpec(...): -want error, +got error: %s", diff)
+				t.Fatalf("getFromSpec(...): -want error, +got error: %s", diff)
 			}
+
 			if diff := cmp.Diff(tc.want.out, got); diff != "" {
-				t.Errorf("getPatchesFromSpec(...): -want result, +got result: %s", diff)
+				t.Errorf("getFromSpec(...): -want result, +got result: %s", diff)
 			}
 		})
 	}