Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
771ef63
updater: Include logger.
Nov 17, 2025
dc56520
updater: Include new option: EnableAggressiveConflictResolution.
Nov 17, 2025
b677812
updater: Implement aggressive conflict resolution.
Nov 17, 2025
383acd4
reconciler: Prepare for aggressive conflict resolution
Nov 17, 2025
6062872
reconciler: use client.Patch() for setting uninstall finalizer
Nov 17, 2025
cbbcb62
reconciler: configure new updater
Nov 17, 2025
9eb202b
New function (for tests): conditions.go: FromUnstructured
Nov 17, 2025
db5ff47
Updater tests
Nov 17, 2025
71ab32c
transient
Nov 17, 2025
bb2eb0c
updater.go: Move backoff definition for smaller diff.
Nov 19, 2025
603f783
Renamings of function names and log messages related to refreshing of…
Nov 19, 2025
0d88dae
updater: Refactor attempt counting during retries
Nov 19, 2025
8a63583
updater tests: seperate Expect() clause after calling retryOnTransien…
Nov 19, 2025
e85b41a
New reconciler method: newUpdater
Nov 19, 2025
9e4a650
updater tests: factor our coming init code into a shared JustBeforeEach.
Nov 19, 2025
cc60c7d
Moved the code for extracting conditions from unstructured the
Nov 19, 2025
4b79554
Clean up named imports
Nov 19, 2025
f85292a
err scope
Nov 21, 2025
ebf75a7
refactor tryRefresh* (#66)
porridge Nov 21, 2025
2d6a5b3
Merge remote-tracking branch 'refs/remotes/origin/mc/no-ssa-4' into m…
Nov 21, 2025
fb30c67
Update comment about conflict retries
Nov 21, 2025
79610cd
Add tests for preserving finalizers
Nov 21, 2025
b80105f
Rename function to retryOnceOnTransientError
Nov 21, 2025
319b099
reconciler tests: Added tests for WithAggressiveConflictResolution()
Nov 21, 2025
4d79eb0
Revert change to uninstall finalizer adding
Nov 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 129 additions & 28 deletions pkg/reconciler/internal/updater/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ package updater

import (
"context"
"fmt"
"reflect"

"github.com/go-logr/logr"
"helm.sh/helm/v3/pkg/release"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
Expand All @@ -33,17 +36,26 @@ import (
"github.com/operator-framework/helm-operator-plugins/pkg/internal/status"
)

func New(client client.Client) Updater {
func New(client client.Client, logger logr.Logger) Updater {
logger = logger.WithName("updater")
return Updater{
client: client,
logger: logger,
}
}

type Updater struct {
isCanceled bool
client client.Client
logger logr.Logger
updateFuncs []UpdateFunc
updateStatusFuncs []UpdateStatusFunc

enableAggressiveConflictResolution bool
}

func (u *Updater) EnableAggressiveConflictResolution() {
u.enableAggressiveConflictResolution = true
}

type UpdateFunc func(*unstructured.Unstructured) bool
Expand Down Expand Up @@ -83,54 +95,143 @@ func isRetryableUpdateError(err error) bool {
// retryOnRetryableUpdateError retries the given function until it succeeds,
// until the given backoff is exhausted, or until the error is not retryable.
//
// In case of a Conflict error, the update cannot be retried because the underlying
// resource has been modified in the meantime, and the reconciliation loop needs
// to be restarted anew.
// In case of a Conflict error, the update is not retried by default because the
// underlying resource has been modified in the meantime, and the reconciliation loop
// needs to be restarted anew. However, when aggressive conflict resolution is enabled,
// the updater attempts to refresh the object from the cluster and retry if it's safe
// to do so (e.g., when only the status has changed).
//
// A NotFound error means that the object has been deleted, and the reconciliation loop
// needs to be restarted anew as well.
func retryOnRetryableUpdateError(backoff wait.Backoff, f func() error) error {
return retry.OnError(backoff, isRetryableUpdateError, f)
func retryOnRetryableUpdateError(backoff wait.Backoff, f func(attemptNum uint) error) error {
var attemptNum uint = 1
countingF := func() error {
err := f(attemptNum)
attemptNum++
return err
}
return retry.OnError(backoff, isRetryableUpdateError, countingF)
}

func (u *Updater) Apply(ctx context.Context, obj *unstructured.Unstructured) error {
func (u *Updater) Apply(ctx context.Context, baseObj *unstructured.Unstructured) error {
if u.isCanceled {
return nil
}

backoff := retry.DefaultRetry

st := statusFor(obj)
needsStatusUpdate := false
for _, f := range u.updateStatusFuncs {
needsStatusUpdate = f(st) || needsStatusUpdate
}

// Always update the status first. During uninstall, if
// we remove the finalizer, updating the status will fail
// because the object and its status will be garbage-collected.
if needsStatusUpdate {
err := retryOnRetryableUpdateError(backoff, func(attemptNumber uint) error {
// Note that st will also include all status conditions, also those not managed by helm-operator.
obj := baseObj.DeepCopy()
st := statusFor(obj)
needsStatusUpdate := false
for _, f := range u.updateStatusFuncs {
needsStatusUpdate = f(st) || needsStatusUpdate
}

if !needsStatusUpdate {
return nil
}
st.updateStatusObject()
obj.Object["status"] = st.StatusObject
if err := retryOnRetryableUpdateError(backoff, func() error {
return u.client.Status().Update(ctx, obj)
}); err != nil {
return err

if attemptNumber > 1 {
u.logger.V(1).Info("Retrying status update", "attempt", attemptNumber)
}
updateErr := u.client.Status().Update(ctx, obj)
if errors.IsConflict(updateErr) && u.enableAggressiveConflictResolution {
u.logger.V(1).Info("Status update conflict detected")
resolved, resolveErr := u.tryRefresh(ctx, baseObj, isSafeForStatusUpdate)
if resolveErr != nil {
return resolveErr
}
if !resolved {
return updateErr
}
return fmt.Errorf("status update conflict") // retriable error.
} else if updateErr != nil {
return updateErr
}
baseObj.Object = obj.Object
return nil
})
if err != nil {
return err
}

needsUpdate := false
for _, f := range u.updateFuncs {
needsUpdate = f(obj) || needsUpdate
}
if needsUpdate {
if err := retryOnRetryableUpdateError(backoff, func() error {
return u.client.Update(ctx, obj)
}); err != nil {
return err
err = retryOnRetryableUpdateError(backoff, func(attemptNumber uint) error {
obj := baseObj.DeepCopy()
needsUpdate := false
for _, f := range u.updateFuncs {
needsUpdate = f(obj) || needsUpdate
}
if !needsUpdate {
return nil
}
if attemptNumber > 1 {
u.logger.V(1).Info("Retrying update", "attempt", attemptNumber)
}
updateErr := u.client.Update(ctx, obj)
if errors.IsConflict(updateErr) && u.enableAggressiveConflictResolution {
u.logger.V(1).Info("Update conflict detected")
resolved, resolveErr := u.tryRefresh(ctx, baseObj, isSafeForUpdate)
if resolveErr != nil {
return resolveErr
}
if !resolved {
return updateErr
}
return fmt.Errorf("update conflict due to externally-managed status conditions") // retriable error.
} else if updateErr != nil {
return updateErr
}
baseObj.Object = obj.Object
return nil
})

return err
}

func isSafeForStatusUpdate(_ logr.Logger, _ *unstructured.Unstructured, _ *unstructured.Unstructured) bool {
return true
}

func isSafeForUpdate(logger logr.Logger, inMemory *unstructured.Unstructured, onCluster *unstructured.Unstructured) bool {
if !reflect.DeepEqual(inMemory.Object["spec"], onCluster.Object["spec"]) {
// Diff in object spec. Nothing we can do about it -> Fail.
logger.V(1).Info("Not refreshing object due to spec mismatch",
"namespace", inMemory.GetNamespace(),
"name", inMemory.GetName(),
"gkv", inMemory.GroupVersionKind(),
)
return false
}
return true
}

func (u *Updater) tryRefresh(ctx context.Context, obj *unstructured.Unstructured, isSafe func(logger logr.Logger, inMemory *unstructured.Unstructured, onCluster *unstructured.Unstructured) bool) (bool, error) {
// Re-fetch object with client.
current := &unstructured.Unstructured{}
current.SetGroupVersionKind(obj.GroupVersionKind())
objectKey := client.ObjectKeyFromObject(obj)
if err := u.client.Get(ctx, objectKey, current); err != nil {
err = fmt.Errorf("refreshing object %s/%s: %w", objectKey.Namespace, objectKey.Name, err)
return false, err
}
return nil

if !isSafe(u.logger, obj, current) {
return false, nil
}

obj.Object = current.Object
u.logger.V(1).Info("Refreshed object",
"namespace", objectKey.Namespace,
"name", objectKey.Name,
"gvk", obj.GroupVersionKind())
return true, nil
}

func RemoveFinalizer(finalizer string) UpdateFunc {
Expand Down
Loading
Loading