Skip to content

Commit 1f0fac7

Browse files
author
Moritz Clasmeier
committed
Implement strategy for resolving conflicts in updater by merging-in externally managed conditions
1 parent 14f2d61 commit 1f0fac7

File tree

3 files changed

+363
-12
lines changed

3 files changed

+363
-12
lines changed

pkg/reconciler/internal/updater/updater.go

Lines changed: 176 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ package updater
1818

1919
import (
2020
"context"
21+
"fmt"
22+
"reflect"
2123

24+
"github.com/go-logr/logr"
2225
"helm.sh/helm/v3/pkg/release"
2326
corev1 "k8s.io/api/core/v1"
2427
"k8s.io/apimachinery/pkg/api/errors"
@@ -33,17 +36,35 @@ import (
3336
"github.com/operator-framework/helm-operator-plugins/pkg/internal/status"
3437
)
3538

36-
func New(client client.Client) Updater {
39+
func New(client client.Client, logger logr.Logger) Updater {
40+
logger = logger.WithName("updater")
3741
return Updater{
3842
client: client,
43+
logger: logger,
3944
}
4045
}
4146

4247
type Updater struct {
43-
isCanceled bool
44-
client client.Client
45-
updateFuncs []UpdateFunc
46-
updateStatusFuncs []UpdateStatusFunc
48+
isCanceled bool
49+
client client.Client
50+
logger logr.Logger
51+
updateFuncs []UpdateFunc
52+
updateStatusFuncs []UpdateStatusFunc
53+
externallyManagedStatusConditions map[string]struct{}
54+
enableAggressiveConflictResolution bool
55+
}
56+
57+
func (u *Updater) RegisterExternallyManagedStatusConditions(conditions map[string]struct{}) {
58+
if u.externallyManagedStatusConditions == nil {
59+
u.externallyManagedStatusConditions = make(map[string]struct{}, len(conditions))
60+
}
61+
for conditionType := range conditions {
62+
u.externallyManagedStatusConditions[conditionType] = struct{}{}
63+
}
64+
}
65+
66+
func (u *Updater) EnableAggressiveConflictResolution() {
67+
u.enableAggressiveConflictResolution = true
4768
}
4869

4970
type UpdateFunc func(*unstructured.Unstructured) bool
@@ -113,7 +134,20 @@ func (u *Updater) Apply(ctx context.Context, obj *unstructured.Unstructured) err
113134
st.updateStatusObject()
114135
obj.Object["status"] = st.StatusObject
115136
if err := retryOnRetryableUpdateError(backoff, func() error {
116-
return u.client.Status().Update(ctx, obj)
137+
updateErr := u.client.Status().Update(ctx, obj)
138+
if errors.IsConflict(updateErr) && u.enableAggressiveConflictResolution {
139+
u.logger.V(1).Info("Status update conflict detected")
140+
resolved, resolveErr := u.tryRefreshObject(ctx, obj)
141+
u.logger.V(1).Info("tryRefreshObject", "resolved", resolved, "resolveErr", resolveErr)
142+
if resolveErr != nil {
143+
return resolveErr
144+
}
145+
if !resolved {
146+
return updateErr
147+
}
148+
return fmt.Errorf("status update conflict due to externally-managed status conditions") // retriable error.
149+
}
150+
return updateErr
117151
}); err != nil {
118152
return err
119153
}
@@ -125,14 +159,149 @@ func (u *Updater) Apply(ctx context.Context, obj *unstructured.Unstructured) err
125159
}
126160
if needsUpdate {
127161
if err := retryOnRetryableUpdateError(backoff, func() error {
128-
return u.client.Update(ctx, obj)
162+
updateErr := u.client.Update(ctx, obj)
163+
if errors.IsConflict(updateErr) && u.enableAggressiveConflictResolution {
164+
u.logger.V(1).Info("Status update conflict detected")
165+
resolved, resolveErr := u.tryRefreshObject(ctx, obj)
166+
u.logger.V(1).Info("tryRefreshObject", "resolved", resolved, "resolveErr", resolveErr)
167+
if resolveErr != nil {
168+
return resolveErr
169+
}
170+
if !resolved {
171+
return updateErr
172+
}
173+
return fmt.Errorf("update conflict due to externally-managed status conditions") // retriable error.
174+
}
175+
return updateErr
129176
}); err != nil {
130177
return err
131178
}
132179
}
133180
return nil
134181
}
135182

183+
// This function tries to merge the status of obj with the current version of the status on the cluster.
184+
// The unstructured obj is expected to have been modified and to have caused a conflict error during an update attempt.
185+
// If the only differences between obj and the current version are in externally managed status conditions,
186+
// those conditions are merged from the current version into obj.
187+
// Returns true if updating shall be retried with the updated obj.
188+
// Returns false if the conflict could not be resolved.
189+
func (u *Updater) tryRefreshObject(ctx context.Context, obj *unstructured.Unstructured) (bool, error) {
190+
// Retrieve current version from the cluster.
191+
current := &unstructured.Unstructured{}
192+
current.SetGroupVersionKind(obj.GroupVersionKind())
193+
objectKey := client.ObjectKeyFromObject(obj)
194+
if err := u.client.Get(ctx, objectKey, current); err != nil {
195+
err = fmt.Errorf("refreshing object %s/%s: %w", objectKey.Namespace, objectKey.Name, err)
196+
return false, err
197+
}
198+
199+
if !reflect.DeepEqual(obj.Object["spec"], current.Object["spec"]) {
200+
// Diff in object spec. Nothing we can do about it -> Fail.
201+
u.logger.V(1).Info("Cluster resource cannot be updated due to spec mismatch",
202+
"namespace", objectKey.Namespace, "name", objectKey.Name, "gkv", obj.GroupVersionKind(),
203+
)
204+
return false, nil
205+
}
206+
207+
// Merge externally managed conditions from current into object copy.
208+
objCopy := obj.DeepCopy()
209+
u.mergeExternallyManagedConditions(objCopy, current)
210+
211+
// Overwrite metadata with the most recent in-cluster version.
212+
// This ensures we have the latest resourceVersion, annotations, labels, etc.
213+
objCopy.Object["metadata"] = current.Object["metadata"]
214+
215+
// We were able to resolve the conflict by merging external conditions.
216+
obj.Object = objCopy.Object
217+
218+
u.logger.V(1).Info("Resolved update conflict by merging externally-managed status conditions")
219+
return true, nil
220+
}
221+
222+
// mergeExternallyManagedConditions updates obj's status conditions by replacing
223+
// externally managed conditions with their values from current.
224+
// Uses current's ordering to avoid false positives in conflict detection.
225+
func (u *Updater) mergeExternallyManagedConditions(obj, current *unstructured.Unstructured) {
226+
objConditions := statusConditionsFromObject(obj)
227+
if objConditions == nil {
228+
return
229+
}
230+
231+
currentConditions := statusConditionsFromObject(current)
232+
if currentConditions == nil {
233+
return
234+
}
235+
236+
// Build a map of all conditions from obj (by type).
237+
objConditionsByType := make(map[string]map[string]interface{})
238+
for _, cond := range objConditions {
239+
if condType, ok := cond["type"].(string); ok {
240+
objConditionsByType[condType] = cond
241+
}
242+
}
243+
244+
// Build merged conditions starting from current's ordering.
245+
mergedConditions := make([]map[string]interface{}, 0, len(currentConditions))
246+
for _, cond := range currentConditions {
247+
condType, ok := cond["type"].(string)
248+
if !ok {
249+
// Shouldn't happen.
250+
continue
251+
}
252+
if _, isExternal := u.externallyManagedStatusConditions[condType]; isExternal {
253+
// Keep external condition from current.
254+
mergedConditions = append(mergedConditions, cond)
255+
} else if objCond, found := objConditionsByType[condType]; found {
256+
// Replace with non-external condition from obj.
257+
mergedConditions = append(mergedConditions, objCond)
258+
delete(objConditionsByType, condType) // Mark as used.
259+
}
260+
// Note: If condition exists in current but not in obj (and is non-external),
261+
// we skip it.
262+
}
263+
264+
// Add any remaining non-externally managed conditions from obj that weren't in current.
265+
for condType, cond := range objConditionsByType {
266+
if _, isExternal := u.externallyManagedStatusConditions[condType]; isExternal {
267+
continue
268+
}
269+
mergedConditions = append(mergedConditions, cond)
270+
}
271+
272+
// Convert to []interface{} for SetNestedField
273+
mergedConditionsInterface := make([]interface{}, len(mergedConditions))
274+
for i, cond := range mergedConditions {
275+
mergedConditionsInterface[i] = cond
276+
}
277+
278+
// Write the modified conditions back.
279+
_ = unstructured.SetNestedField(obj.Object, mergedConditionsInterface, "status", "conditions")
280+
}
281+
282+
// statusConditionsFromObject extracts status conditions from an unstructured object.
283+
// Returns nil if the conditions field is not found or is not the expected type.
284+
func statusConditionsFromObject(obj *unstructured.Unstructured) []map[string]interface{} {
285+
conditionsRaw, ok, _ := unstructured.NestedFieldNoCopy(obj.Object, "status", "conditions")
286+
if !ok {
287+
return nil
288+
}
289+
290+
conditionsSlice, ok := conditionsRaw.([]interface{})
291+
if !ok {
292+
return nil
293+
}
294+
295+
// Convert []interface{} to []map[string]interface{}
296+
result := make([]map[string]interface{}, 0, len(conditionsSlice))
297+
for _, cond := range conditionsSlice {
298+
if condMap, ok := cond.(map[string]interface{}); ok {
299+
result = append(result, condMap)
300+
}
301+
}
302+
return result
303+
}
304+
136305
func RemoveFinalizer(finalizer string) UpdateFunc {
137306
return func(obj *unstructured.Unstructured) bool {
138307
if !controllerutil.ContainsFinalizer(obj, finalizer) {

pkg/reconciler/internal/updater/updater_test.go

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"errors"
2222

23+
"github.com/go-logr/logr"
2324
. "github.com/onsi/ginkgo/v2"
2425
. "github.com/onsi/gomega"
2526

@@ -51,7 +52,7 @@ var _ = Describe("Updater", func() {
5152

5253
JustBeforeEach(func() {
5354
cl = fake.NewClientBuilder().WithInterceptorFuncs(interceptorFuncs).Build()
54-
u = New(cl)
55+
u = New(cl, logr.Discard())
5556
obj = &unstructured.Unstructured{Object: map[string]interface{}{
5657
"apiVersion": "apps/v1",
5758
"kind": "Deployment",
@@ -326,3 +327,153 @@ var _ = Describe("statusFor", func() {
326327
Expect(statusFor(obj)).To(Equal(&helmAppStatus{}))
327328
})
328329
})
330+
331+
var _ = Describe("tryMergeUpdatedObjectStatus", func() {
332+
var (
333+
cl client.Client
334+
u Updater
335+
obj *unstructured.Unstructured
336+
current *unstructured.Unstructured
337+
externalCondition map[string]interface{}
338+
externalConditionTypes map[string]struct{}
339+
nonExternalCondition map[string]interface{}
340+
nonExternalConditionB map[string]interface{}
341+
)
342+
343+
BeforeEach(func() {
344+
externalCondition = map[string]interface{}{
345+
"type": "ExternalCondition",
346+
"status": string(corev1.ConditionTrue),
347+
"reason": "ExternallyManaged",
348+
}
349+
externalConditionTypes = map[string]struct{}{
350+
externalCondition["type"].(string): {},
351+
}
352+
nonExternalCondition = map[string]interface{}{
353+
"type": "Deployed",
354+
"status": string(corev1.ConditionTrue),
355+
"reason": "InitialDeployment",
356+
}
357+
nonExternalConditionB = map[string]interface{}{
358+
"type": "Foo",
359+
"status": string(corev1.ConditionTrue),
360+
"reason": "Success",
361+
}
362+
363+
// Setup obj with initial state (version 100).
364+
obj = &unstructured.Unstructured{
365+
Object: map[string]interface{}{
366+
"apiVersion": "example.com/v1",
367+
"kind": "TestResource",
368+
"metadata": map[string]interface{}{
369+
"name": "test-obj",
370+
"namespace": "default",
371+
"resourceVersion": "100",
372+
},
373+
"status": map[string]interface{}{},
374+
},
375+
}
376+
377+
// Setup current with updated state (version 101).
378+
current = &unstructured.Unstructured{
379+
Object: map[string]interface{}{
380+
"apiVersion": "example.com/v1",
381+
"kind": "TestResource",
382+
"metadata": map[string]interface{}{
383+
"name": "test-obj",
384+
"namespace": "default",
385+
"resourceVersion": "101",
386+
},
387+
"status": map[string]interface{}{},
388+
},
389+
}
390+
})
391+
392+
// When("status empty in both obj and current", func() {
393+
// })
394+
395+
When("only external conditions differ", func() {
396+
BeforeEach(func() {
397+
obj.Object["status"] = map[string]interface{}{
398+
"conditions": []interface{}{nonExternalCondition},
399+
}
400+
current.Object["status"] = map[string]interface{}{
401+
"conditions": []interface{}{nonExternalCondition, externalCondition},
402+
}
403+
404+
cl = fake.NewClientBuilder().WithObjects(current).Build()
405+
u = New(cl, logr.Discard())
406+
u.RegisterExternallyManagedStatusConditions(externalConditionTypes)
407+
})
408+
409+
It("should merge and return resolved", func() {
410+
resolved, err := u.tryRefreshObject(context.Background(), obj)
411+
412+
Expect(err).ToNot(HaveOccurred())
413+
Expect(resolved).To(BeTrue())
414+
Expect(obj.GetResourceVersion()).To(Equal("101"))
415+
416+
// Verify external condition was merged
417+
conditions, ok, _ := unstructured.NestedSlice(obj.Object, "status", "conditions")
418+
Expect(ok).To(BeTrue())
419+
Expect(conditions).To(HaveLen(2))
420+
421+
// Verify ordering (Deployed first, ExternalCondition second from current)
422+
Expect(conditions[0].(map[string]interface{})["type"]).To(Equal("Deployed"))
423+
Expect(conditions[1].(map[string]interface{})["type"]).To(Equal("ExternalCondition"))
424+
})
425+
})
426+
427+
When("non-external condition differs", func() {
428+
BeforeEach(func() {
429+
obj.Object["status"] = map[string]interface{}{
430+
"conditions": []interface{}{nonExternalCondition},
431+
}
432+
current.Object["status"] = map[string]interface{}{
433+
"conditions": []interface{}{nonExternalCondition, nonExternalConditionB},
434+
}
435+
436+
cl = fake.NewClientBuilder().WithObjects(current).Build()
437+
u = New(cl, logr.Discard())
438+
u.RegisterExternallyManagedStatusConditions(externalConditionTypes)
439+
})
440+
441+
It("should not resolve", func() {
442+
resolved, err := u.tryRefreshObject(context.Background(), obj)
443+
444+
Expect(err).ToNot(HaveOccurred())
445+
Expect(resolved).To(BeFalse())
446+
Expect(obj.GetResourceVersion()).To(Equal("100"), "resource version should not be updated")
447+
Expect(obj.Object["status"].(map[string]interface{})["conditions"]).To(HaveLen(1))
448+
})
449+
})
450+
451+
When("no external conditions are configured", func() {
452+
BeforeEach(func() {
453+
cl = fake.NewClientBuilder().Build()
454+
u = New(cl, logr.Discard())
455+
})
456+
457+
It("should return early without calling Get", func() {
458+
resolved, err := u.tryRefreshObject(context.Background(), obj)
459+
Expect(err).ToNot(HaveOccurred())
460+
Expect(resolved).To(BeFalse())
461+
})
462+
})
463+
464+
When("Get returns an error", func() {
465+
BeforeEach(func() {
466+
// Empty client - Get will return NotFound
467+
cl = fake.NewClientBuilder().Build()
468+
u = New(cl, logr.Discard())
469+
u.RegisterExternallyManagedStatusConditions(externalConditionTypes)
470+
})
471+
472+
It("should return the error", func() {
473+
resolved, err := u.tryRefreshObject(context.Background(), obj)
474+
Expect(err).To(HaveOccurred())
475+
Expect(apierrors.IsNotFound(err)).To(BeTrue())
476+
Expect(resolved).To(BeFalse())
477+
})
478+
})
479+
})

0 commit comments

Comments
 (0)