Skip to content

Commit d3e3427

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

File tree

3 files changed

+351
-12
lines changed

3 files changed

+351
-12
lines changed

pkg/reconciler/internal/updater/updater.go

Lines changed: 174 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,30 @@ 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+
}
55+
56+
func (u *Updater) RegisterExternallyManagedStatusConditions(conditions map[string]struct{}) {
57+
if u.externallyManagedStatusConditions == nil {
58+
u.externallyManagedStatusConditions = make(map[string]struct{}, len(conditions))
59+
}
60+
for conditionType := range conditions {
61+
u.externallyManagedStatusConditions[conditionType] = struct{}{}
62+
}
4763
}
4864

4965
type UpdateFunc func(*unstructured.Unstructured) bool
@@ -113,7 +129,18 @@ func (u *Updater) Apply(ctx context.Context, obj *unstructured.Unstructured) err
113129
st.updateStatusObject()
114130
obj.Object["status"] = st.StatusObject
115131
if err := retryOnRetryableUpdateError(backoff, func() error {
116-
return u.client.Status().Update(ctx, obj)
132+
updateErr := u.client.Status().Update(ctx, obj)
133+
if errors.IsConflict(updateErr) {
134+
resolved, resolveErr := u.tryRefreshObject(ctx, obj)
135+
if resolveErr != nil {
136+
return resolveErr
137+
}
138+
if !resolved {
139+
return updateErr
140+
}
141+
return fmt.Errorf("status update conflict due to externally-managed status conditions") // retriable error.
142+
}
143+
return updateErr
117144
}); err != nil {
118145
return err
119146
}
@@ -125,14 +152,154 @@ func (u *Updater) Apply(ctx context.Context, obj *unstructured.Unstructured) err
125152
}
126153
if needsUpdate {
127154
if err := retryOnRetryableUpdateError(backoff, func() error {
128-
return u.client.Update(ctx, obj)
155+
updateErr := u.client.Update(ctx, obj)
156+
if errors.IsConflict(updateErr) {
157+
resolved, resolveErr := u.tryRefreshObject(ctx, obj)
158+
if resolveErr != nil {
159+
return resolveErr
160+
}
161+
if !resolved {
162+
return updateErr
163+
}
164+
return fmt.Errorf("update conflict due to externally-managed status conditions") // retriable error.
165+
}
166+
return updateErr
129167
}); err != nil {
130168
return err
131169
}
132170
}
133171
return nil
134172
}
135173

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