Repository: scylladb/scylla-operator
Pull request #960 (2022-03-24 15:19:02)

Reconcile metadata explicitly with apply
**Description of your changes:**
We need to keep user's annotations and labels when we reconcile. Previously this was managed by distinguishing a prefix but that has its own downsides:
- We can't handle our own historical keys that don't have our prefix
- Apply will clear our own annotation if they come from a different controller (like the side car)
The new approach:
- Explicitly prunes keys by appending `-` to the required key
```yaml
# Existing object
annotations:
  "managed-1": "alpha"
  "managed-2": "beta"
  "custom": "gama"
```
```yaml
# Required object
annotations:
  "managed-1": "alpha"
  "managed-2-": ""
```
```yaml
# Result
annotations:
  "managed-1": "alpha"
  "custom": "gama"
```
- Keeps all annotations and labels that are not specified in the required object or having a removal key specified there
- Applies required annotations only when the required object has changed (allows to test changing even our own annotations and labels)

TODO:
- [x] Extend this to all apply functions (but let's agree on the approach before I do that)

/cc @zimnx @rzetelskik 


zimnx: approach lgtm, you can proceed with apply functions. Since we have generics now, would you mind trying them out on our resourceapply? 
tnozicka: thx, the generics are more trickier - without a client-go support it felt like it wouldn't help much
rzetelskik: no doubt a much nicer approach, lgtm

Patch:
diff --git a/pkg/resourceapply/apps.go b/pkg/resourceapply/apps.go
index e6b613c6b..464a5ee98 100644
--- a/pkg/resourceapply/apps.go
+++ b/pkg/resourceapply/apps.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"github.com/scylladb/scylla-operator/pkg/naming"
+	"github.com/scylladb/scylla-operator/pkg/resourcemerge"
 	appsv1 "k8s.io/api/apps/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -42,6 +43,7 @@ func ApplyStatefulSet(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.StatefulSets(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "StatefulSet", klog.KObj(requiredCopy))
@@ -74,6 +76,8 @@ func ApplyStatefulSet(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	// TODO: Handle immutable fields. Maybe orphan delete + recreate.
 
 	// Honor the required RV if it was already set.
@@ -118,6 +122,7 @@ func ApplyDaemonSet(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.DaemonSets(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "DaemonSet", klog.KObj(requiredCopy))
@@ -142,6 +147,8 @@ func ApplyDaemonSet(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	// Honor the required RV if it was already set.
 	// Required objects set RV in case their input is based on a previous version of itself.
 	if len(requiredCopy.ResourceVersion) == 0 {
diff --git a/pkg/resourceapply/batch.go b/pkg/resourceapply/batch.go
index 70f15f849..f1da076b5 100644
--- a/pkg/resourceapply/batch.go
+++ b/pkg/resourceapply/batch.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"github.com/scylladb/scylla-operator/pkg/naming"
+	"github.com/scylladb/scylla-operator/pkg/resourcemerge"
 	batchv1 "k8s.io/api/batch/v1"
 	"k8s.io/apimachinery/pkg/api/equality"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -43,6 +44,7 @@ func ApplyJob(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.Jobs(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "Job", klog.KObj(requiredCopy))
@@ -61,6 +63,8 @@ func ApplyJob(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	if !equality.Semantic.DeepEqual(existing.Spec.Completions, requiredCopy.Spec.Completions) ||
 		!equality.Semantic.DeepEqual(existing.Spec.Template, requiredCopy.Spec.Template) ||
 		(requiredCopy.Spec.ManualSelector != nil && *requiredCopy.Spec.ManualSelector && !equality.Semantic.DeepEqual(existing.Spec.Selector, requiredCopy.Spec.Selector)) {
@@ -78,6 +82,7 @@ func ApplyJob(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		created, err := client.Jobs(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		ReportCreateEvent(recorder, requiredCopy, err)
 		if err != nil {
diff --git a/pkg/resourceapply/core.go b/pkg/resourceapply/core.go
index ce25c4cfc..d29aec04d 100644
--- a/pkg/resourceapply/core.go
+++ b/pkg/resourceapply/core.go
@@ -3,9 +3,9 @@ package resourceapply
 import (
 	"context"
 	"fmt"
-	"strings"
 
 	"github.com/scylladb/scylla-operator/pkg/naming"
+	"github.com/scylladb/scylla-operator/pkg/resourcemerge"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/equality"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -45,6 +45,7 @@ func ApplyService(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.Services(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "Service", klog.KObj(requiredCopy))
@@ -71,6 +72,8 @@ func ApplyService(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	// Preserve allocated fields.
 	requiredCopy.Spec.ClusterIP = existing.Spec.ClusterIP
 	requiredCopy.Spec.ClusterIPs = existing.Spec.ClusterIPs
@@ -116,6 +119,7 @@ func ApplySecret(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.Secrets(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "Secret", klog.KObj(requiredCopy))
@@ -142,6 +146,8 @@ func ApplySecret(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	requiredCopy.ResourceVersion = existing.ResourceVersion
 	actual, err := client.Secrets(requiredCopy.Namespace).Update(ctx, requiredCopy, metav1.UpdateOptions{})
 	if apierrors.IsConflict(err) {
@@ -180,6 +186,7 @@ func ApplyConfigMap(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.ConfigMaps(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "ConfigMap", klog.KObj(requiredCopy))
@@ -205,6 +212,8 @@ func ApplyConfigMap(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	// Honor the required RV if it was already set.
 	// Required objects set RV in case their input is based on a previous version of itself.
 	if len(requiredCopy.ResourceVersion) == 0 {
@@ -238,32 +247,18 @@ func ApplyServiceAccount(
 	}
 
 	requiredCopy := required.DeepCopy()
-
-	// Users may need to add custom annotations to integrate with k8s provider services.
-	// These should be filtered out before hash is computed.
-	customAnnotations := map[string]string{}
-	for key, value := range requiredCopy.Annotations {
-		if !strings.HasPrefix(key, "scylla-operator.scylladb.com/") {
-			customAnnotations[key] = value
-			delete(requiredCopy.Annotations, key)
-		}
-	}
-
 	err := SetHashAnnotation(requiredCopy)
 	if err != nil {
 		return nil, false, err
 	}
 
-	for key, value := range customAnnotations {
-		requiredCopy.Annotations[key] = value
-	}
-
 	existing, err := lister.ServiceAccounts(requiredCopy.Namespace).Get(requiredCopy.Name)
 	if err != nil {
 		if !apierrors.IsNotFound(err) {
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.ServiceAccounts(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "ServiceAccount", klog.KObj(requiredCopy))
@@ -301,6 +296,8 @@ func ApplyServiceAccount(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	// Honor the required RV if it was already set.
 	// Required objects set RV in case their input is based on a previous version of itself.
 	if len(requiredCopy.ResourceVersion) == 0 {
@@ -344,6 +341,7 @@ func ApplyNamespace(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.Namespaces().Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "Namespace", klog.KObj(requiredCopy))
@@ -369,6 +367,8 @@ func ApplyNamespace(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	// Honor the required RV if it was already set.
 	// Required objects set RV in case their input is based on a previous version of itself.
 	if len(requiredCopy.ResourceVersion) == 0 {
diff --git a/pkg/resourceapply/policy.go b/pkg/resourceapply/policy.go
index 2ac4f3e13..ab9e8d19d 100644
--- a/pkg/resourceapply/policy.go
+++ b/pkg/resourceapply/policy.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 
 	"github.com/scylladb/scylla-operator/pkg/naming"
+	"github.com/scylladb/scylla-operator/pkg/resourcemerge"
 	policyv1beta1 "k8s.io/api/policy/v1beta1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -44,6 +45,7 @@ func ApplyPodDisruptionBudget(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.PodDisruptionBudgets(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "PodDisruptionBudget", klog.KObj(requiredCopy))
@@ -70,6 +72,8 @@ func ApplyPodDisruptionBudget(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	requiredCopy.ResourceVersion = existing.ResourceVersion
 	actual, err := client.PodDisruptionBudgets(requiredCopy.Namespace).Update(ctx, requiredCopy, metav1.UpdateOptions{})
 	if apierrors.IsConflict(err) {
diff --git a/pkg/resourceapply/rbac.go b/pkg/resourceapply/rbac.go
index c4c6c10d9..a88d2758d 100644
--- a/pkg/resourceapply/rbac.go
+++ b/pkg/resourceapply/rbac.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"github.com/scylladb/scylla-operator/pkg/naming"
+	"github.com/scylladb/scylla-operator/pkg/resourcemerge"
 	rbacv1 "k8s.io/api/rbac/v1"
 	"k8s.io/apimachinery/pkg/api/equality"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -42,6 +43,7 @@ func ApplyClusterRole(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.ClusterRoles().Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "ClusterRole", klog.KObj(requiredCopy))
@@ -73,6 +75,8 @@ func ApplyClusterRole(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	// Honor the required RV if it was already set.
 	// Required objects set RV in case their input is based on a previous version of itself.
 	if len(requiredCopy.ResourceVersion) == 0 {
@@ -116,6 +120,7 @@ func ApplyClusterRoleBinding(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.ClusterRoleBindings().Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "ClusterRoleBinding", klog.KObj(requiredCopy))
@@ -147,6 +152,8 @@ func ApplyClusterRoleBinding(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	if !equality.Semantic.DeepEqual(existing.RoleRef, requiredCopy.RoleRef) {
 		klog.V(2).InfoS(
 			"Apply needs to change immutable field(s) and will recreate the object",
@@ -162,6 +169,7 @@ func ApplyClusterRoleBinding(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		created, err := client.ClusterRoleBindings().Create(ctx, requiredCopy, metav1.CreateOptions{})
 		ReportCreateEvent(recorder, requiredCopy, err)
 		if err != nil {
@@ -215,6 +223,7 @@ func ApplyRoleBinding(
 			return nil, false, err
 		}
 
+		resourcemerge.SanitizeObject(requiredCopy)
 		actual, err := client.RoleBindings(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
 		if apierrors.IsAlreadyExists(err) {
 			klog.V(2).InfoS("Already exists (stale cache)", "RoleBinding", klog.KObj(requiredCopy))
@@ -252,6 +261,8 @@ func ApplyRoleBinding(
 		return existing, false, nil
 	}
 
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
 	// Honor the required RV if it was already set.
 	// Required objects set RV in case their input is based on a previous version of itself.
 	if len(requiredCopy.ResourceVersion) == 0 {
diff --git a/pkg/resourcemerge/objectmerger.go b/pkg/resourcemerge/objectmerger.go
new file mode 100644
index 000000000..9dd472ba6
--- /dev/null
+++ b/pkg/resourcemerge/objectmerger.go
@@ -0,0 +1,55 @@
+package resourcemerge
+
+import (
+	"strings"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func isRemovalKey(k string) bool {
+	return strings.HasSuffix(k, "-")
+}
+
+func toRemovalKey(k string) string {
+	return k + "-"
+}
+
+func cleanRemovalKeys(m map[string]string) map[string]string {
+	for k := range m {
+		if isRemovalKey(k) {
+			delete(m, k)
+		}
+	}
+
+	return m
+}
+
+func SanitizeObject(obj metav1.Object) {
+	cleanRemovalKeys(obj.GetAnnotations())
+	cleanRemovalKeys(obj.GetLabels())
+}
+
+// MergeMapInPlaceWithoutRemovalKeys merges keys from existing into the required object.
+func MergeMapInPlaceWithoutRemovalKeys(required *map[string]string, existing map[string]string) {
+	for existingKey, existingValue := range existing {
+		// Don't copy removed keys.
+		_, isRemoved := (*required)[toRemovalKey(existingKey)]
+		if isRemoved {
+			continue
+		}
+
+		// Copy only keys not present in the required object.
+		_, found := (*required)[existingKey]
+		if !found {
+			(*required)[existingKey] = existingValue
+		}
+	}
+
+	cleanRemovalKeys(*required)
+}
+
+// MergeMetadataInPlace merges metadata from existing into the required.
+func MergeMetadataInPlace(required *metav1.ObjectMeta, existing metav1.ObjectMeta) {
+	MergeMapInPlaceWithoutRemovalKeys(&required.Annotations, existing.Annotations)
+	MergeMapInPlaceWithoutRemovalKeys(&required.Labels, existing.Labels)
+}


Test patch:
diff --git a/pkg/resourceapply/apps_test.go b/pkg/resourceapply/apps_test.go
index 9df8d1e43..dc163a3fa 100644
--- a/pkg/resourceapply/apps_test.go
+++ b/pkg/resourceapply/apps_test.go
@@ -367,6 +367,132 @@ func TestApplyStatefulSet(t *testing.T) {
 			expectedErr:     fmt.Errorf(`statefulset "default/test" is controlled by someone else`),
 			expectedEvents:  []string{`Warning UpdateStatefulSetFailed Failed to update StatefulSet default/test: statefulset "default/test" is controlled by someone else`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *appsv1.StatefulSet {
+					sts := newSts()
+					sts.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					sts.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(sts))
+					sts.Annotations["a-1"] = "a-alpha-changed"
+					sts.Annotations["a-3"] = "a-resurrected"
+					sts.Annotations["a-custom"] = "custom-value"
+					sts.Labels["l-1"] = "l-alpha-changed"
+					sts.Labels["l-3"] = "l-resurrected"
+					sts.Labels["l-custom"] = "custom-value"
+					return sts
+				}(),
+			},
+			required: func() *appsv1.StatefulSet {
+				sts := newSts()
+				sts.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				sts.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return sts
+			}(),
+			forceOwnership: false,
+			expectedSts: func() *appsv1.StatefulSet {
+				sts := newSts()
+				sts.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				sts.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(sts))
+				sts.Annotations["a-1"] = "a-alpha-changed"
+				sts.Annotations["a-3"] = "a-resurrected"
+				sts.Annotations["a-custom"] = "custom-value"
+				sts.Labels["l-1"] = "l-alpha-changed"
+				sts.Labels["l-3"] = "l-resurrected"
+				sts.Labels["l-custom"] = "custom-value"
+				return sts
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *appsv1.StatefulSet {
+					sts := newSts()
+					sts.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					sts.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(sts))
+					sts.Annotations["a-1"] = "a-alpha-changed"
+					sts.Annotations["a-custom"] = "a-custom-value"
+					sts.Labels["l-1"] = "l-alpha-changed"
+					sts.Labels["l-custom"] = "l-custom-value"
+					return sts
+				}(),
+			},
+			required: func() *appsv1.StatefulSet {
+				sts := newSts()
+				sts.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				sts.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return sts
+			}(),
+			forceOwnership: true,
+			expectedSts: func() *appsv1.StatefulSet {
+				sts := newSts()
+				sts.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				sts.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(sts))
+				delete(sts.Annotations, "a-3-")
+				sts.Annotations["a-custom"] = "a-custom-value"
+				delete(sts.Labels, "l-3-")
+				sts.Labels["l-custom"] = "l-custom-value"
+				return sts
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal StatefulSetUpdated StatefulSet default/test updated"},
+		},
 	}
 
 	for _, tc := range tt {
@@ -742,6 +868,130 @@ func TestApplyDaemonSet(t *testing.T) {
 			expectedErr:       fmt.Errorf(`daemonset "default/test" isn't controlled by us`),
 			expectedEvents:    []string{`Warning UpdateDaemonSetFailed Failed to update DaemonSet default/test: daemonset "default/test" isn't controlled by us`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *appsv1.DaemonSet {
+					ds := newDS()
+					ds.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					ds.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(ds))
+					ds.Annotations["a-1"] = "a-alpha-changed"
+					ds.Annotations["a-3"] = "a-resurrected"
+					ds.Annotations["a-custom"] = "custom-value"
+					ds.Labels["l-1"] = "l-alpha-changed"
+					ds.Labels["l-3"] = "l-resurrected"
+					ds.Labels["l-custom"] = "custom-value"
+					return ds
+				}(),
+			},
+			required: func() *appsv1.DaemonSet {
+				ds := newDS()
+				ds.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				ds.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return ds
+			}(),
+			expectedDaemonSet: func() *appsv1.DaemonSet {
+				ds := newDS()
+				ds.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				ds.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(ds))
+				ds.Annotations["a-1"] = "a-alpha-changed"
+				ds.Annotations["a-3"] = "a-resurrected"
+				ds.Annotations["a-custom"] = "custom-value"
+				ds.Labels["l-1"] = "l-alpha-changed"
+				ds.Labels["l-3"] = "l-resurrected"
+				ds.Labels["l-custom"] = "custom-value"
+				return ds
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *appsv1.DaemonSet {
+					ds := newDS()
+					ds.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					ds.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(ds))
+					ds.Annotations["a-1"] = "a-alpha-changed"
+					ds.Annotations["a-custom"] = "a-custom-value"
+					ds.Labels["l-1"] = "l-alpha-changed"
+					ds.Labels["l-custom"] = "l-custom-value"
+					return ds
+				}(),
+			},
+			required: func() *appsv1.DaemonSet {
+				ds := newDS()
+				ds.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				ds.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return ds
+			}(),
+			expectedDaemonSet: func() *appsv1.DaemonSet {
+				ds := newDS()
+				ds.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				ds.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(ds))
+				delete(ds.Annotations, "a-3-")
+				ds.Annotations["a-custom"] = "a-custom-value"
+				delete(ds.Labels, "l-3-")
+				ds.Labels["l-custom"] = "l-custom-value"
+				return ds
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal DaemonSetUpdated DaemonSet default/test updated"},
+		},
 	}
 
 	for _, tc := range tt {
diff --git a/pkg/resourceapply/core_test.go b/pkg/resourceapply/core_test.go
index 8442028d4..12a980d70 100644
--- a/pkg/resourceapply/core_test.go
+++ b/pkg/resourceapply/core_test.go
@@ -339,6 +339,132 @@ func TestApplyService(t *testing.T) {
 			expectedErr:     fmt.Errorf(`service "default/test" isn't controlled by us`),
 			expectedEvents:  []string{`Warning UpdateServiceFailed Failed to update Service default/test: service "default/test" isn't controlled by us`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *corev1.Service {
+					svc := newService()
+					svc.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					svc.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(svc))
+					svc.Annotations["a-1"] = "a-alpha-changed"
+					svc.Annotations["a-3"] = "a-resurrected"
+					svc.Annotations["a-custom"] = "custom-value"
+					svc.Labels["l-1"] = "l-alpha-changed"
+					svc.Labels["l-3"] = "l-resurrected"
+					svc.Labels["l-custom"] = "custom-value"
+					return svc
+				}(),
+			},
+			required: func() *corev1.Service {
+				svc := newService()
+				svc.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				svc.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return svc
+			}(),
+			forceOwnership: false,
+			expectedService: func() *corev1.Service {
+				svc := newService()
+				svc.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				svc.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(svc))
+				svc.Annotations["a-1"] = "a-alpha-changed"
+				svc.Annotations["a-3"] = "a-resurrected"
+				svc.Annotations["a-custom"] = "custom-value"
+				svc.Labels["l-1"] = "l-alpha-changed"
+				svc.Labels["l-3"] = "l-resurrected"
+				svc.Labels["l-custom"] = "custom-value"
+				return svc
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *corev1.Service {
+					svc := newService()
+					svc.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					svc.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(svc))
+					svc.Annotations["a-1"] = "a-alpha-changed"
+					svc.Annotations["a-custom"] = "a-custom-value"
+					svc.Labels["l-1"] = "l-alpha-changed"
+					svc.Labels["l-custom"] = "l-custom-value"
+					return svc
+				}(),
+			},
+			required: func() *corev1.Service {
+				svc := newService()
+				svc.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				svc.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return svc
+			}(),
+			forceOwnership: true,
+			expectedService: func() *corev1.Service {
+				svc := newService()
+				svc.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				svc.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(svc))
+				delete(svc.Annotations, "a-3-")
+				svc.Annotations["a-custom"] = "a-custom-value"
+				delete(svc.Labels, "l-3-")
+				svc.Labels["l-custom"] = "l-custom-value"
+				return svc
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal ServiceUpdated Service default/test updated"},
+		},
 	}
 
 	for _, tc := range tt {
@@ -747,6 +873,132 @@ func TestApplySecret(t *testing.T) {
 			expectedErr:     fmt.Errorf(`secret "default/test" isn't controlled by us`),
 			expectedEvents:  []string{`Warning UpdateSecretFailed Failed to update Secret default/test: secret "default/test" isn't controlled by us`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *corev1.Secret {
+					secret := newSecret()
+					secret.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					secret.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(secret))
+					secret.Annotations["a-1"] = "a-alpha-changed"
+					secret.Annotations["a-3"] = "a-resurrected"
+					secret.Annotations["a-custom"] = "custom-value"
+					secret.Labels["l-1"] = "l-alpha-changed"
+					secret.Labels["l-3"] = "l-resurrected"
+					secret.Labels["l-custom"] = "custom-value"
+					return secret
+				}(),
+			},
+			required: func() *corev1.Secret {
+				secret := newSecret()
+				secret.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				secret.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return secret
+			}(),
+			forceOwnership: false,
+			expectedSecret: func() *corev1.Secret {
+				secret := newSecret()
+				secret.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				secret.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(secret))
+				secret.Annotations["a-1"] = "a-alpha-changed"
+				secret.Annotations["a-3"] = "a-resurrected"
+				secret.Annotations["a-custom"] = "custom-value"
+				secret.Labels["l-1"] = "l-alpha-changed"
+				secret.Labels["l-3"] = "l-resurrected"
+				secret.Labels["l-custom"] = "custom-value"
+				return secret
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *corev1.Secret {
+					secret := newSecret()
+					secret.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					secret.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(secret))
+					secret.Annotations["a-1"] = "a-alpha-changed"
+					secret.Annotations["a-custom"] = "a-custom-value"
+					secret.Labels["l-1"] = "l-alpha-changed"
+					secret.Labels["l-custom"] = "l-custom-value"
+					return secret
+				}(),
+			},
+			required: func() *corev1.Secret {
+				secret := newSecret()
+				secret.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				secret.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return secret
+			}(),
+			forceOwnership: true,
+			expectedSecret: func() *corev1.Secret {
+				secret := newSecret()
+				secret.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				secret.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(secret))
+				delete(secret.Annotations, "a-3-")
+				secret.Annotations["a-custom"] = "a-custom-value"
+				delete(secret.Labels, "l-3-")
+				secret.Labels["l-custom"] = "l-custom-value"
+				return secret
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal SecretUpdated Secret default/test updated"},
+		},
 	}
 
 	for _, tc := range tt {
@@ -1167,27 +1419,64 @@ func TestApplyServiceAccount(t *testing.T) {
 			expectedEvents:  []string{`Warning UpdateServiceAccountFailed Failed to update ServiceAccount default/test: serviceAccount "default/test" isn't controlled by us`},
 		},
 		{
-			name: "annotations not starting with our prefix are ignored during hashing and kept in the object",
+			name: "all label and annotation keys are kept when the hash matches",
 			existing: []runtime.Object{
 				func() *corev1.ServiceAccount {
 					sa := newSAWithControllerRef()
+					sa.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					sa.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
 					utilruntime.Must(SetHashAnnotation(sa))
-					sa.Annotations["custom-annotation"] = "custom-value"
+					sa.Annotations["a-1"] = "a-alpha-changed"
+					sa.Annotations["a-3"] = "a-resurrected"
+					sa.Annotations["a-custom"] = "custom-value"
+					sa.Labels["l-1"] = "l-alpha-changed"
+					sa.Labels["l-3"] = "l-resurrected"
+					sa.Labels["l-custom"] = "custom-value"
 					return sa
 				}(),
 			},
 			required: func() *corev1.ServiceAccount {
 				sa := newSAWithControllerRef()
 				sa.Annotations = map[string]string{
-					"custom-annotation": "custom-value",
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				sa.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
 				}
 				return sa
 			}(),
-			forceOwnership: true,
+			forceOwnership: false,
 			expectedSA: func() *corev1.ServiceAccount {
 				sa := newSAWithControllerRef()
+				sa.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				sa.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
 				utilruntime.Must(SetHashAnnotation(sa))
-				sa.Annotations["custom-annotation"] = "custom-value"
+				sa.Annotations["a-1"] = "a-alpha-changed"
+				sa.Annotations["a-3"] = "a-resurrected"
+				sa.Annotations["a-custom"] = "custom-value"
+				sa.Labels["l-1"] = "l-alpha-changed"
+				sa.Labels["l-3"] = "l-resurrected"
+				sa.Labels["l-custom"] = "custom-value"
 				return sa
 			}(),
 			expectedChanged: false,
@@ -1195,20 +1484,39 @@ func TestApplyServiceAccount(t *testing.T) {
 			expectedEvents:  nil,
 		},
 		{
-			name: "annotations starting with our prefix are accounted during hash compute",
+			name: "only managed label and annotation keys are updated when the hash changes",
 			existing: []runtime.Object{
 				func() *corev1.ServiceAccount {
 					sa := newSAWithControllerRef()
+					sa.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					sa.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
 					utilruntime.Must(SetHashAnnotation(sa))
-					sa.Annotations["custom-annotation"] = "custom-value"
+					sa.Annotations["a-1"] = "a-alpha-changed"
+					sa.Annotations["a-custom"] = "a-custom-value"
+					sa.Labels["l-1"] = "l-alpha-changed"
+					sa.Labels["l-custom"] = "l-custom-value"
 					return sa
 				}(),
 			},
 			required: func() *corev1.ServiceAccount {
 				sa := newSAWithControllerRef()
 				sa.Annotations = map[string]string{
-					"custom-annotation":                   "custom-value",
-					"scylla-operator.scylladb.com/blabla": "123",
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				sa.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
 				}
 				return sa
 			}(),
@@ -1216,10 +1524,20 @@ func TestApplyServiceAccount(t *testing.T) {
 			expectedSA: func() *corev1.ServiceAccount {
 				sa := newSAWithControllerRef()
 				sa.Annotations = map[string]string{
-					"scylla-operator.scylladb.com/blabla": "123",
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				sa.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
 				}
 				utilruntime.Must(SetHashAnnotation(sa))
-				sa.Annotations["custom-annotation"] = "custom-value"
+				delete(sa.Annotations, "a-3-")
+				sa.Annotations["a-custom"] = "a-custom-value"
+				delete(sa.Labels, "l-3-")
+				sa.Labels["l-custom"] = "l-custom-value"
 				return sa
 			}(),
 			expectedChanged: true,
@@ -1281,7 +1599,7 @@ func TestApplyServiceAccount(t *testing.T) {
 					}
 
 					if !equality.Semantic.DeepEqual(gotSA, tc.expectedSA) {
-						t.Errorf("expected %#v, got %#v, diff:\n%s", tc.expectedSA, gotSA, cmp.Diff(tc.expectedSA, gotSA))
+						t.Errorf("expected and got SA differ:\n%s", cmp.Diff(tc.expectedSA, gotSA))
 					}
 
 					// Make sure such object was actually created.
@@ -1586,6 +1904,130 @@ func TestApplyConfigMap(t *testing.T) {
 			expectedErr:     fmt.Errorf(`configmap "default/test" isn't controlled by us`),
 			expectedEvents:  []string{`Warning UpdateConfigMapFailed Failed to update ConfigMap default/test: configmap "default/test" isn't controlled by us`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *corev1.ConfigMap {
+					cm := newConfigMap()
+					cm.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					cm.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(cm))
+					cm.Annotations["a-1"] = "a-alpha-changed"
+					cm.Annotations["a-3"] = "a-resurrected"
+					cm.Annotations["a-custom"] = "custom-value"
+					cm.Labels["l-1"] = "l-alpha-changed"
+					cm.Labels["l-3"] = "l-resurrected"
+					cm.Labels["l-custom"] = "custom-value"
+					return cm
+				}(),
+			},
+			required: func() *corev1.ConfigMap {
+				cm := newConfigMap()
+				cm.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				cm.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return cm
+			}(),
+			expectedCM: func() *corev1.ConfigMap {
+				cm := newConfigMap()
+				cm.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				cm.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(cm))
+				cm.Annotations["a-1"] = "a-alpha-changed"
+				cm.Annotations["a-3"] = "a-resurrected"
+				cm.Annotations["a-custom"] = "custom-value"
+				cm.Labels["l-1"] = "l-alpha-changed"
+				cm.Labels["l-3"] = "l-resurrected"
+				cm.Labels["l-custom"] = "custom-value"
+				return cm
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *corev1.ConfigMap {
+					cm := newConfigMap()
+					cm.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					cm.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(cm))
+					cm.Annotations["a-1"] = "a-alpha-changed"
+					cm.Annotations["a-custom"] = "a-custom-value"
+					cm.Labels["l-1"] = "l-alpha-changed"
+					cm.Labels["l-custom"] = "l-custom-value"
+					return cm
+				}(),
+			},
+			required: func() *corev1.ConfigMap {
+				cm := newConfigMap()
+				cm.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				cm.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return cm
+			}(),
+			expectedCM: func() *corev1.ConfigMap {
+				configMap := newConfigMap()
+				configMap.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				configMap.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(configMap))
+				delete(configMap.Annotations, "a-3-")
+				configMap.Annotations["a-custom"] = "a-custom-value"
+				delete(configMap.Labels, "l-3-")
+				configMap.Labels["l-custom"] = "l-custom-value"
+				return configMap
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal ConfigMapUpdated ConfigMap default/test updated"},
+		},
 	}
 
 	for _, tc := range tt {
@@ -1910,6 +2352,132 @@ func TestApplyNamespace(t *testing.T) {
 			expectedErr:               fmt.Errorf(`namespace "test" isn't controlled by us`),
 			expectedEvents:            []string{`Warning UpdateNamespaceFailed Failed to update Namespace test: namespace "test" isn't controlled by us`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *corev1.Namespace {
+					ns := newNS()
+					ns.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					ns.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(ns))
+					ns.Annotations["a-1"] = "a-alpha-changed"
+					ns.Annotations["a-3"] = "a-resurrected"
+					ns.Annotations["a-custom"] = "custom-value"
+					ns.Labels["l-1"] = "l-alpha-changed"
+					ns.Labels["l-3"] = "l-resurrected"
+					ns.Labels["l-custom"] = "custom-value"
+					return ns
+				}(),
+			},
+			allowMissingControllerRef: true,
+			required: func() *corev1.Namespace {
+				ns := newNS()
+				ns.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				ns.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return ns
+			}(),
+			expectedNS: func() *corev1.Namespace {
+				ns := newNS()
+				ns.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				ns.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(ns))
+				ns.Annotations["a-1"] = "a-alpha-changed"
+				ns.Annotations["a-3"] = "a-resurrected"
+				ns.Annotations["a-custom"] = "custom-value"
+				ns.Labels["l-1"] = "l-alpha-changed"
+				ns.Labels["l-3"] = "l-resurrected"
+				ns.Labels["l-custom"] = "custom-value"
+				return ns
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *corev1.Namespace {
+					ns := newNS()
+					ns.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					ns.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(ns))
+					ns.Annotations["a-1"] = "a-alpha-changed"
+					ns.Annotations["a-custom"] = "a-custom-value"
+					ns.Labels["l-1"] = "l-alpha-changed"
+					ns.Labels["l-custom"] = "l-custom-value"
+					return ns
+				}(),
+			},
+			allowMissingControllerRef: true,
+			required: func() *corev1.Namespace {
+				ns := newNS()
+				ns.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				ns.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return ns
+			}(),
+			expectedNS: func() *corev1.Namespace {
+				ns := newNS()
+				ns.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				ns.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(ns))
+				delete(ns.Annotations, "a-3-")
+				ns.Annotations["a-custom"] = "a-custom-value"
+				delete(ns.Labels, "l-3-")
+				ns.Labels["l-custom"] = "l-custom-value"
+				return ns
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal NamespaceUpdated Namespace test updated"},
+		},
 	}
 
 	for _, tc := range tt {
diff --git a/pkg/resourceapply/policy_test.go b/pkg/resourceapply/policy_test.go
index 46b354601..5a61acf85 100644
--- a/pkg/resourceapply/policy_test.go
+++ b/pkg/resourceapply/policy_test.go
@@ -335,6 +335,132 @@ func TestApplyPodDisruptionBudget(t *testing.T) {
 			expectedErr:     fmt.Errorf(`poddisruptionbudget "default/test" isn't controlled by us`),
 			expectedEvents:  []string{`Warning UpdatePodDisruptionBudgetFailed Failed to update PodDisruptionBudget default/test: poddisruptionbudget "default/test" isn't controlled by us`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *policyv1beta1.PodDisruptionBudget {
+					pdb := newPDB()
+					pdb.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					pdb.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(pdb))
+					pdb.Annotations["a-1"] = "a-alpha-changed"
+					pdb.Annotations["a-3"] = "a-resurrected"
+					pdb.Annotations["a-custom"] = "custom-value"
+					pdb.Labels["l-1"] = "l-alpha-changed"
+					pdb.Labels["l-3"] = "l-resurrected"
+					pdb.Labels["l-custom"] = "custom-value"
+					return pdb
+				}(),
+			},
+			required: func() *policyv1beta1.PodDisruptionBudget {
+				pdb := newPDB()
+				pdb.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				pdb.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return pdb
+			}(),
+			forceOwnership: false,
+			expectedPDB: func() *policyv1beta1.PodDisruptionBudget {
+				pdb := newPDB()
+				pdb.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				pdb.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(pdb))
+				pdb.Annotations["a-1"] = "a-alpha-changed"
+				pdb.Annotations["a-3"] = "a-resurrected"
+				pdb.Annotations["a-custom"] = "custom-value"
+				pdb.Labels["l-1"] = "l-alpha-changed"
+				pdb.Labels["l-3"] = "l-resurrected"
+				pdb.Labels["l-custom"] = "custom-value"
+				return pdb
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *policyv1beta1.PodDisruptionBudget {
+					pdb := newPDB()
+					pdb.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					pdb.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(pdb))
+					pdb.Annotations["a-1"] = "a-alpha-changed"
+					pdb.Annotations["a-custom"] = "a-custom-value"
+					pdb.Labels["l-1"] = "l-alpha-changed"
+					pdb.Labels["l-custom"] = "l-custom-value"
+					return pdb
+				}(),
+			},
+			required: func() *policyv1beta1.PodDisruptionBudget {
+				pdb := newPDB()
+				pdb.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				pdb.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return pdb
+			}(),
+			forceOwnership: true,
+			expectedPDB: func() *policyv1beta1.PodDisruptionBudget {
+				podDisruptionBudget := newPDB()
+				podDisruptionBudget.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				podDisruptionBudget.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(podDisruptionBudget))
+				delete(podDisruptionBudget.Annotations, "a-3-")
+				podDisruptionBudget.Annotations["a-custom"] = "a-custom-value"
+				delete(podDisruptionBudget.Labels, "l-3-")
+				podDisruptionBudget.Labels["l-custom"] = "l-custom-value"
+				return podDisruptionBudget
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal PodDisruptionBudgetUpdated PodDisruptionBudget default/test updated"},
+		},
 	}
 
 	for _, tc := range tt {
diff --git a/pkg/resourceapply/rbac_test.go b/pkg/resourceapply/rbac_test.go
index 6a64846b3..04259c64c 100644
--- a/pkg/resourceapply/rbac_test.go
+++ b/pkg/resourceapply/rbac_test.go
@@ -282,6 +282,132 @@ func TestApplyClusterRole(t *testing.T) {
 			expectedErr:               fmt.Errorf(`clusterrole "test" is controlled by someone else`),
 			expectedEvents:            []string{`Warning UpdateClusterRoleFailed Failed to update ClusterRole test: clusterrole "test" is controlled by someone else`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *rbacv1.ClusterRole {
+					cr := newCr()
+					cr.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					cr.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(cr))
+					cr.Annotations["a-1"] = "a-alpha-changed"
+					cr.Annotations["a-3"] = "a-resurrected"
+					cr.Annotations["a-custom"] = "custom-value"
+					cr.Labels["l-1"] = "l-alpha-changed"
+					cr.Labels["l-3"] = "l-resurrected"
+					cr.Labels["l-custom"] = "custom-value"
+					return cr
+				}(),
+			},
+			allowMissingControllerRef: true,
+			required: func() *rbacv1.ClusterRole {
+				clusterRole := newCr()
+				clusterRole.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				clusterRole.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return clusterRole
+			}(),
+			expectedCr: func() *rbacv1.ClusterRole {
+				cr := newCr()
+				cr.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				cr.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(cr))
+				cr.Annotations["a-1"] = "a-alpha-changed"
+				cr.Annotations["a-3"] = "a-resurrected"
+				cr.Annotations["a-custom"] = "custom-value"
+				cr.Labels["l-1"] = "l-alpha-changed"
+				cr.Labels["l-3"] = "l-resurrected"
+				cr.Labels["l-custom"] = "custom-value"
+				return cr
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *rbacv1.ClusterRole {
+					cr := newCr()
+					cr.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					cr.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(cr))
+					cr.Annotations["a-1"] = "a-alpha-changed"
+					cr.Annotations["a-custom"] = "a-custom-value"
+					cr.Labels["l-1"] = "l-alpha-changed"
+					cr.Labels["l-custom"] = "l-custom-value"
+					return cr
+				}(),
+			},
+			allowMissingControllerRef: true,
+			required: func() *rbacv1.ClusterRole {
+				cr := newCr()
+				cr.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				cr.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return cr
+			}(),
+			expectedCr: func() *rbacv1.ClusterRole {
+				clusterRole := newCr()
+				clusterRole.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				clusterRole.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(clusterRole))
+				delete(clusterRole.Annotations, "a-3-")
+				clusterRole.Annotations["a-custom"] = "a-custom-value"
+				delete(clusterRole.Labels, "l-3-")
+				clusterRole.Labels["l-custom"] = "l-custom-value"
+				return clusterRole
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal ClusterRoleUpdated ClusterRole test updated"},
+		},
 	}
 
 	for _, tc := range tt {
@@ -650,6 +776,132 @@ func TestApplyClusterRoleBinding(t *testing.T) {
 			expectedErr:               fmt.Errorf(`clusterrolebinding "test" is controlled by someone else`),
 			expectedEvents:            []string{`Warning UpdateClusterRoleBindingFailed Failed to update ClusterRoleBinding test: clusterrolebinding "test" is controlled by someone else`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *rbacv1.ClusterRoleBinding {
+					crb := newCrb()
+					crb.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					crb.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(crb))
+					crb.Annotations["a-1"] = "a-alpha-changed"
+					crb.Annotations["a-3"] = "a-resurrected"
+					crb.Annotations["a-custom"] = "custom-value"
+					crb.Labels["l-1"] = "l-alpha-changed"
+					crb.Labels["l-3"] = "l-resurrected"
+					crb.Labels["l-custom"] = "custom-value"
+					return crb
+				}(),
+			},
+			required: func() *rbacv1.ClusterRoleBinding {
+				crb := newCrb()
+				crb.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				crb.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return crb
+			}(),
+			allowMissingControllerRef: true,
+			expectedCrb: func() *rbacv1.ClusterRoleBinding {
+				crb := newCrb()
+				crb.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				crb.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(crb))
+				crb.Annotations["a-1"] = "a-alpha-changed"
+				crb.Annotations["a-3"] = "a-resurrected"
+				crb.Annotations["a-custom"] = "custom-value"
+				crb.Labels["l-1"] = "l-alpha-changed"
+				crb.Labels["l-3"] = "l-resurrected"
+				crb.Labels["l-custom"] = "custom-value"
+				return crb
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *rbacv1.ClusterRoleBinding {
+					crb := newCrb()
+					crb.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					crb.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(crb))
+					crb.Annotations["a-1"] = "a-alpha-changed"
+					crb.Annotations["a-custom"] = "a-custom-value"
+					crb.Labels["l-1"] = "l-alpha-changed"
+					crb.Labels["l-custom"] = "l-custom-value"
+					return crb
+				}(),
+			},
+			required: func() *rbacv1.ClusterRoleBinding {
+				crb := newCrb()
+				crb.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				crb.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return crb
+			}(),
+			allowMissingControllerRef: true,
+			expectedCrb: func() *rbacv1.ClusterRoleBinding {
+				crb := newCrb()
+				crb.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				crb.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(crb))
+				delete(crb.Annotations, "a-3-")
+				crb.Annotations["a-custom"] = "a-custom-value"
+				delete(crb.Labels, "l-3-")
+				crb.Labels["l-custom"] = "l-custom-value"
+				return crb
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal ClusterRoleBindingUpdated ClusterRoleBinding test updated"},
+		},
 	}
 
 	for _, tc := range tt {
@@ -1069,6 +1321,134 @@ func TestApplyRoleBinding(t *testing.T) {
 			expectedErr:     fmt.Errorf(`roleBinding "default/test" isn't controlled by us`),
 			expectedEvents:  []string{`Warning UpdateRoleBindingFailed Failed to update RoleBinding default/test: roleBinding "default/test" isn't controlled by us`},
 		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *rbacv1.RoleBinding {
+					rb := newRB()
+					rb.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					rb.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(rb))
+					rb.Annotations["a-1"] = "a-alpha-changed"
+					rb.Annotations["a-3"] = "a-resurrected"
+					rb.Annotations["a-custom"] = "custom-value"
+					rb.Labels["l-1"] = "l-alpha-changed"
+					rb.Labels["l-3"] = "l-resurrected"
+					rb.Labels["l-custom"] = "custom-value"
+					return rb
+				}(),
+			},
+			allowMissingControllerRef: true,
+			required: func() *rbacv1.RoleBinding {
+				rb := newRB()
+				rb.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				rb.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return rb
+			}(),
+			forceOwnership: false,
+			expectedRB: func() *rbacv1.RoleBinding {
+				roleBinding := newRB()
+				roleBinding.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				roleBinding.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(roleBinding))
+				roleBinding.Annotations["a-1"] = "a-alpha-changed"
+				roleBinding.Annotations["a-3"] = "a-resurrected"
+				roleBinding.Annotations["a-custom"] = "custom-value"
+				roleBinding.Labels["l-1"] = "l-alpha-changed"
+				roleBinding.Labels["l-3"] = "l-resurrected"
+				roleBinding.Labels["l-custom"] = "custom-value"
+				return roleBinding
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *rbacv1.RoleBinding {
+					rb := newRB()
+					rb.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					rb.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(rb))
+					rb.Annotations["a-1"] = "a-alpha-changed"
+					rb.Annotations["a-custom"] = "a-custom-value"
+					rb.Labels["l-1"] = "l-alpha-changed"
+					rb.Labels["l-custom"] = "l-custom-value"
+					return rb
+				}(),
+			},
+			allowMissingControllerRef: true,
+			required: func() *rbacv1.RoleBinding {
+				rb := newRB()
+				rb.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				rb.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return rb
+			}(),
+			forceOwnership: true,
+			expectedRB: func() *rbacv1.RoleBinding {
+				rb := newRB()
+				rb.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				rb.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(rb))
+				delete(rb.Annotations, "a-3-")
+				rb.Annotations["a-custom"] = "a-custom-value"
+				delete(rb.Labels, "l-3-")
+				rb.Labels["l-custom"] = "l-custom-value"
+				return rb
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal RoleBindingUpdated RoleBinding default/test updated"},
+		},
 	}
 
 	for _, tc := range tt {
diff --git a/pkg/resourcemerge/objectmerger_test.go b/pkg/resourcemerge/objectmerger_test.go
new file mode 100644
index 000000000..8d92e3b87
--- /dev/null
+++ b/pkg/resourcemerge/objectmerger_test.go
@@ -0,0 +1,361 @@
+package resourcemerge
+
+import (
+	"fmt"
+	"reflect"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestIsRemovalKey(t *testing.T) {
+	tt := []struct {
+		name     string
+		key      string
+		expected bool
+	}{
+		{
+			name:     "regular key",
+			key:      "foo",
+			expected: false,
+		},
+		{
+			name:     "regular key containing a dash",
+			key:      "foo-bar",
+			expected: false,
+		},
+		{
+			name:     "removal key",
+			key:      "foo-bar-",
+			expected: true,
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			got := isRemovalKey(tc.key)
+
+			if got != tc.expected {
+				t.Errorf("expected %t, got %t", tc.expected, got)
+			}
+		})
+	}
+}
+
+func FuzzToRemovalKey(f *testing.F) {
+	testCases := []string{
+		"foo",
+		"foo-",
+		"scylla-operator.scylladb.com/foo-bar",
+	}
+	for _, tc := range testCases {
+		f.Add(tc)
+	}
+	f.Fuzz(func(t *testing.T, k string) {
+		t.Logf("key: %q", k)
+		rk := toRemovalKey(k)
+
+		if !isRemovalKey(rk) {
+			t.Errorf("%q isn't a removal key", rk)
+		}
+	})
+}
+
+func TestCleanRemovalKeys(t *testing.T) {
+	tt := []struct {
+		name     string
+		m        map[string]string
+		expected map[string]string
+	}{
+		{
+			name:     "nil map",
+			m:        nil,
+			expected: nil,
+		},
+		{
+			name:     "empty map",
+			m:        map[string]string{},
+			expected: map[string]string{},
+		},
+		{
+			name: "removal keys are cleaned",
+			m: map[string]string{
+				"foo":  "alpha",
+				"foo-": "beta",
+				"bar-": "gama",
+			},
+			expected: map[string]string{
+				"foo": "alpha",
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			got := cleanRemovalKeys(tc.m)
+
+			if !reflect.DeepEqual(got, tc.expected) {
+				t.Errorf("expected and got differs: %s", cmp.Diff(tc.expected, got))
+			}
+		})
+	}
+}
+
+func TestSanitizeObject(t *testing.T) {
+	tt := []struct {
+		name     string
+		obj      *metav1.ObjectMeta
+		expected *metav1.ObjectMeta
+	}{
+		{
+			name: "object with nils",
+			obj: &metav1.ObjectMeta{
+				Annotations: nil,
+				Labels:      nil,
+			},
+			expected: &metav1.ObjectMeta{
+				Annotations: nil,
+				Labels:      nil,
+			},
+		},
+		{
+			name: "mixed values",
+			obj: &metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-foo":  "a-alpha",
+					"a-bar-": "a-beta",
+				},
+				Labels: map[string]string{
+					"l-foo":  "l-alpha",
+					"l-bar-": "l-beta",
+				},
+			},
+			expected: &metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-foo": "a-alpha",
+				},
+				Labels: map[string]string{
+					"l-foo": "l-alpha",
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			got := tc.obj.DeepCopy()
+			SanitizeObject(got)
+
+			if !reflect.DeepEqual(got, tc.expected) {
+				t.Errorf("expected and got differs: %s", cmp.Diff(tc.expected, got))
+			}
+		})
+	}
+}
+
+func TestMergeMapInPlaceWithoutRemovalKeys(t *testing.T) {
+	tt := []struct {
+		name           string
+		required       map[string]string
+		existing       map[string]string
+		expected       map[string]string
+		expectPanicErr error
+	}{
+		{
+			name:     "nil required map panics",
+			required: nil,
+			existing: map[string]string{
+				"bar": "beta",
+			},
+			expected:       nil,
+			expectPanicErr: fmt.Errorf("assignment to entry in nil map"),
+		},
+		{
+			name: "no existing map",
+			required: map[string]string{
+				"foo": "alpha",
+			},
+			existing: nil,
+			expected: map[string]string{
+				"foo": "alpha",
+			},
+		},
+		{
+			name: "removal key is removed",
+			required: map[string]string{
+				"foo":  "alpha",
+				"bar-": "",
+			},
+			existing: map[string]string{
+				"foo": "alpha",
+				"bar": "beta",
+			},
+			expected: map[string]string{
+				"foo": "alpha",
+			},
+		},
+		{
+			name: "removal keys are not copied",
+			required: map[string]string{
+				"foo":  "alpha",
+				"bar-": "",
+			},
+			existing: map[string]string{
+				"foo":    "alpha",
+				"bar-":   "beta",
+				"extra-": "gama",
+			},
+			expected: map[string]string{
+				"foo": "alpha",
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			defer func() {
+				r := recover()
+				if r == nil && tc.expectPanicErr != nil {
+					t.Errorf("expected panic error %q, got none", tc.expectPanicErr.Error())
+				}
+				if r != nil && tc.expectPanicErr.Error() != r.(error).Error() {
+					t.Errorf("expected panic error %q, got %q", tc.expectPanicErr.Error(), r.(error).Error())
+
+				}
+			}()
+
+			var got map[string]string
+			if tc.required != nil {
+				got = make(map[string]string, len(tc.required))
+				for k, v := range tc.required {
+					got[k] = v
+				}
+			}
+			MergeMapInPlaceWithoutRemovalKeys(&got, tc.existing)
+
+			if !reflect.DeepEqual(got, tc.expected) {
+				t.Errorf("expected and got differs: %s", cmp.Diff(tc.expected, got))
+			}
+		})
+	}
+}
+
+func TestMergeMetadataInPlace(t *testing.T) {
+	tt := []struct {
+		name     string
+		required *metav1.ObjectMeta
+		existing metav1.ObjectMeta
+		expected metav1.ObjectMeta
+	}{
+		{
+			name: "nil maps",
+			required: &metav1.ObjectMeta{
+				Annotations: nil,
+				Labels:      nil,
+			},
+			existing: metav1.ObjectMeta{
+				Annotations: nil,
+				Labels:      nil,
+			},
+			expected: metav1.ObjectMeta{
+				Annotations: nil,
+				Labels:      nil,
+			},
+		},
+		{
+			name: "removal keys are removed",
+			required: &metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-1":  "foo",
+					"a-2-": "",
+				},
+				Labels: map[string]string{
+					"l-1":  "bar",
+					"l-2-": "",
+				},
+			},
+			existing: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-1": "foo",
+					"a-2": "old",
+				},
+				Labels: map[string]string{
+					"l-1": "bar",
+					"l-2": "old",
+				},
+			},
+			expected: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-1": "foo",
+				},
+				Labels: map[string]string{
+					"l-1": "bar",
+				},
+			},
+		},
+		{
+			name: "new fields are added",
+			required: &metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-1": "foo",
+				},
+				Labels: map[string]string{
+					"l-1": "bar",
+				},
+			},
+			existing: metav1.ObjectMeta{
+				Annotations: nil,
+				Labels:      nil,
+			},
+			expected: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-1": "foo",
+				},
+				Labels: map[string]string{
+					"l-1": "bar",
+				},
+			},
+		},
+		{
+			name: "unmanaged keys are kept",
+			required: &metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-1": "foo",
+				},
+				Labels: map[string]string{
+					"l-1": "bar",
+				},
+			},
+			existing: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"user-annotation": "ua",
+				},
+				Labels: map[string]string{
+					"user-label": "ul",
+				},
+			},
+			expected: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					"a-1":             "foo",
+					"user-annotation": "ua",
+				},
+				Labels: map[string]string{
+					"l-1":        "bar",
+					"user-label": "ul",
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			got := tc.required.DeepCopy()
+			MergeMetadataInPlace(got, tc.existing)
+
+			if !reflect.DeepEqual(*got, tc.expected) {
+				t.Errorf("expected and got differs: %s", cmp.Diff(tc.expected, got))
+			}
+		})
+	}
+}