@@ -18,7 +18,10 @@ package updater
1818
1919import (
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
4247type 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
4965type 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+
136303func RemoveFinalizer (finalizer string ) UpdateFunc {
137304 return func (obj * unstructured.Unstructured ) bool {
138305 if ! controllerutil .ContainsFinalizer (obj , finalizer ) {
0 commit comments