Skip to content

Commit 0833fa4

Browse files
mkolesnikclaude
andcommitted
Include metadata in WorkApplier cache to detect external changes
The WorkApplier cache uses the desired work hash and the existing work's generation to skip redundant updates. However, metadata-only changes (labels, annotations, owner references) do not bump generation, so the cache misses external modifications to these fields. Store a hash of the desired work's metadata (labels, annotations, owner references) in the cache and compare it against the existing work's metadata on each apply. This ensures the cache invalidates when an external actor modifies metadata fields that the applier owns. Fixes: #223 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Mike Kolesnik <mkolesni@redhat.com>
1 parent 9dbb933 commit 0833fa4

3 files changed

Lines changed: 100 additions & 5 deletions

File tree

pkg/apis/work/v1/applier/workapplier_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,38 @@ func TestWorkApplierWithTypedClient(t *testing.T) {
175175
}
176176
assertActions(t, fakeWorkClient.Actions(), "patch")
177177

178+
assertExternalMetadataChangeDetected := func(description string, tamper func(*workapiv1.ManifestWork)) {
179+
obj, exists, getErr := workInformerFactory.Work().V1().ManifestWorks().Informer().GetStore().GetByKey("test/test")
180+
if getErr != nil || !exists {
181+
t.Fatalf("%s: failed to get work from store: err=%v exists=%v", description, getErr, exists)
182+
}
183+
184+
tamperedWork := obj.(*workapiv1.ManifestWork).DeepCopy()
185+
tamper(tamperedWork)
186+
if err := workInformerFactory.Work().V1().ManifestWorks().Informer().GetStore().Update(tamperedWork); err != nil {
187+
t.Errorf("%s: failed to update work with err %v", description, err)
188+
}
189+
190+
fakeWorkClient.ClearActions()
191+
_, err = workApplier.Apply(context.TODO(), newWork)
192+
if err != nil {
193+
t.Errorf("%s: failed to apply work with err %v", description, err)
194+
}
195+
assertActions(t, fakeWorkClient.Actions(), "patch")
196+
}
197+
198+
assertExternalMetadataChangeDetected("external label change", func(w *workapiv1.ManifestWork) {
199+
w.Labels = map[string]string{"tampered": "true"}
200+
})
201+
assertExternalMetadataChangeDetected("external annotation change", func(w *workapiv1.ManifestWork) {
202+
w.Annotations = map[string]string{"tampered": "true"}
203+
})
204+
assertExternalMetadataChangeDetected("external owner reference change", func(w *workapiv1.ManifestWork) {
205+
w.OwnerReferences = []metav1.OwnerReference{
206+
{APIVersion: "v1", Kind: "ConfigMap", Name: "rogue-owner", UID: "rogue"},
207+
}
208+
})
209+
178210
fakeWorkClient.ClearActions()
179211
err = workApplier.Delete(context.TODO(), newWork.Namespace, newWork.Name)
180212
if err != nil {

pkg/apis/work/v1/applier/workcache.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"sync"
99

10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1011
"k8s.io/klog/v2"
1112

1213
workapiv1 "open-cluster-management.io/api/work/v1"
@@ -20,6 +21,7 @@ type workKey struct {
2021
type cachedResource struct {
2122
resourceHash string
2223
generation int64
24+
metadataHash string
2325
}
2426

2527
type workCache struct {
@@ -48,6 +50,7 @@ func (w *workCache) updateCache(required, existing *workapiv1.ManifestWork) {
4850
value := cachedResource{
4951
resourceHash: hashOfResourceStruct(required),
5052
generation: existing.Generation,
53+
metadataHash: hashOfMetadata(required),
5154
}
5255

5356
w.cache[key] = value
@@ -79,12 +82,11 @@ func (w *workCache) safeToSkipApply(required, existing *workapiv1.ManifestWork)
7982
generation := existing.Generation
8083
w.mutex.RLock()
8184
defer w.mutex.RUnlock()
82-
var generationMatch, hashMatch bool
85+
metadataHash := hashOfMetadata(existing)
86+
8387
if cached, exists := w.cache[cacheKey]; exists {
84-
generationMatch = cached.generation == generation
85-
hashMatch = cached.resourceHash == resourceHash
86-
if generationMatch && hashMatch {
87-
klog.V(4).Infof("found matching generation & manifest hash")
88+
if cached.generation == generation && cached.resourceHash == resourceHash && cached.metadataHash == metadataHash {
89+
klog.V(4).Infof("found matching generation, manifest hash & metadata hash")
8890
return true
8991
}
9092
}
@@ -101,3 +103,16 @@ func hashOfResourceStruct(o interface{}) string {
101103
rval := fmt.Sprintf("%x", h.Sum(nil))
102104
return rval
103105
}
106+
107+
func hashOfMetadata(work *workapiv1.ManifestWork) string {
108+
meta := struct {
109+
Labels map[string]string
110+
Annotations map[string]string
111+
OwnerReferences []metav1.OwnerReference
112+
}{
113+
Labels: work.Labels,
114+
Annotations: work.Annotations,
115+
OwnerReferences: work.OwnerReferences,
116+
}
117+
return hashOfResourceStruct(meta)
118+
}

pkg/apis/work/v1/applier/workcache_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,51 @@ func TestCache(t *testing.T) {
6363
t.Errorf("should update work if related cache is not found")
6464
}
6565
}
66+
67+
func TestCacheDetectsExternalMetadataChange(t *testing.T) {
68+
cases := []struct {
69+
name string
70+
required metav1.ObjectMeta
71+
tamper func(*workapiv1.ManifestWork)
72+
}{
73+
{
74+
name: "labels",
75+
required: metav1.ObjectMeta{Name: "test", Namespace: "cluster1", Labels: map[string]string{"managed-by": "addon"}},
76+
tamper: func(w *workapiv1.ManifestWork) { w.Labels["managed-by"] = "tampered" },
77+
},
78+
{
79+
name: "annotations",
80+
required: metav1.ObjectMeta{Name: "test", Namespace: "cluster1", Annotations: map[string]string{"config-hash": "abc123"}},
81+
tamper: func(w *workapiv1.ManifestWork) { w.Annotations["config-hash"] = "tampered" },
82+
},
83+
{
84+
name: "owner references",
85+
required: metav1.ObjectMeta{Name: "test", Namespace: "cluster1", OwnerReferences: []metav1.OwnerReference{
86+
{APIVersion: "v1", Kind: "ConfigMap", Name: "owner", UID: "abc"},
87+
}},
88+
tamper: func(w *workapiv1.ManifestWork) { w.OwnerReferences[0].Name = "tampered" },
89+
},
90+
}
91+
92+
for _, c := range cases {
93+
t.Run(c.name, func(t *testing.T) {
94+
cache := newWorkCache()
95+
96+
requiredWork := &workapiv1.ManifestWork{ObjectMeta: c.required}
97+
existingWork := requiredWork.DeepCopy()
98+
existingWork.Generation = 1
99+
100+
cache.updateCache(requiredWork, existingWork)
101+
102+
if !cache.safeToSkipApply(requiredWork, existingWork) {
103+
t.Errorf("should skip apply when nothing changed")
104+
}
105+
106+
c.tamper(existingWork)
107+
108+
if cache.safeToSkipApply(requiredWork, existingWork) {
109+
t.Errorf("should not skip apply when %s are externally modified", c.name)
110+
}
111+
})
112+
}
113+
}

0 commit comments

Comments
 (0)