diff --git a/docs/libs/status.md b/docs/libs/status.md index 89732e0..d510c66 100644 --- a/docs/libs/status.md +++ b/docs/libs/status.md @@ -161,6 +161,9 @@ You can then `Build()` the status updater and run `UpdateStatus()` to do the act - The `AllStatusFields()` function returns a list containing all status field keys, _except the one for the status field itself_, for convenience. - The `WithCustomUpdateFunc` method can be used to inject a function that performs custom logic on the resource's status. Note that while the function gets the complete object as an argument, only changes to its status will be updated by the status updater. - `WithConditionEvents` can be used to enable event recording for changed conditions. The events are automatically connected to the resource from the `ReconcileResult`'s `Object` field, no events will be recorded if that field is `nil`. +- By using `WithSmartRequeue`, the [smart requeuing logic](./smartrequeue.md) can be used. + - A `smartrequeue.Store` is required to be configured outside of the status updater, because it has to be persisted across multiple reconciliations. + - It is also possible to use the smart requeue logic explicitly and modify the `ReconcileResult`'s `Result` field with the returned value, but the integration should be easier to use, since both, the smart requeue logic as well as the status updater, return a `reconcile.Result` and an `error`, which are intended to be directly used as return values for the `Reconcile` method. ### The ReconcileResult diff --git a/pkg/controller/status_updater.go b/pkg/controller/status_updater.go index f57488d..7ee8bba 100644 --- a/pkg/controller/status_updater.go +++ b/pkg/controller/status_updater.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/openmcp-project/controller-utils/pkg/conditions" + "github.com/openmcp-project/controller-utils/pkg/controller/smartrequeue" "github.com/openmcp-project/controller-utils/pkg/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -120,6 +121,29 @@ func (b *StatusUpdaterBuilder[Obj]) WithCustomUpdateFunc(f func(obj Obj, rr Reco return b } +type SmartRequeueAction string + +const ( + SR_BACKOFF SmartRequeueAction = "Backoff" + SR_RESET SmartRequeueAction = "Reset" + SR_NO_REQUEUE SmartRequeueAction = "NoRequeue" +) + +// WithSmartRequeue integrates the smart requeue logic into the status updater. +// Requires a smartrequeue.Store to be passed in (this needs to be persistent across multiple reconciliations and therefore cannot be stored in the status updater itself). +// The action determines when the object should be requeued: +// - "Backoff": the object is requeued with an increasing backoff, as specified in the store. +// - "Reset": the object is requeued, but the backoff is reset to its minimal value, as specified in the store. +// - "NoRequeue": the object is not requeued. +// If the 'Result' field in the ReconcileResult has a non-zero RequeueAfter value set, that one is used if it is earlier than the one from smart requeue or if "NoRequeue" has been specified. +// This function only has an effect if the Object in the ReconcileResult is not nil, the smart requeue store is not nil, and the action is one of the known values. +// Also, if a reconciliation error occurred, the requeue interval will be reset, but no requeueAfter duration will be set, because controller-runtime will take care of requeuing the object anyway. +func (b *StatusUpdaterBuilder[Obj]) WithSmartRequeue(store *smartrequeue.Store, action SmartRequeueAction) *StatusUpdaterBuilder[Obj] { + b.internal.smartRequeueStore = store + b.internal.smartRequeueAction = action + return b +} + // Build returns the status updater. func (b *StatusUpdaterBuilder[Obj]) Build() *statusUpdater[Obj] { return b.internal @@ -158,6 +182,8 @@ type statusUpdater[Obj client.Object] struct { removeUntouchedConditions bool eventRecorder record.EventRecorder eventVerbosity conditions.EventVerbosity + smartRequeueStore *smartrequeue.Store + smartRequeueAction SmartRequeueAction } func newStatusUpdater[Obj client.Object]() *statusUpdater[Obj] { @@ -184,6 +210,8 @@ func defaultPhaseUpdateFunc[Obj client.Object](obj Obj, _ ReconcileResult[Obj]) // UpdateStatus updates the status of the object in the given ReconcileResult, using the previously set field names and functions. // The object is expected to be a pointer to a struct with the status field. // If the 'Object' field in the ReconcileResult is nil, the status update becomes a no-op. +// +//nolint:gocyclo func (s *statusUpdater[Obj]) UpdateStatus(ctx context.Context, c client.Client, rr ReconcileResult[Obj]) (ctrl.Result, error) { errs := errors.NewReasonableErrorList(rr.ReconcileError) if IsNil(rr.Object) { @@ -259,6 +287,25 @@ func (s *statusUpdater[Obj]) UpdateStatus(ctx context.Context, c client.Client, errs.Append(fmt.Errorf("error patching status: %w", err)) } + if s.smartRequeueStore != nil { + var srRes ctrl.Result + if rr.ReconcileError != nil { + srRes, _ = s.smartRequeueStore.For(rr.Object).Error(rr.ReconcileError) + } else { + switch s.smartRequeueAction { + case SR_BACKOFF: + srRes, _ = s.smartRequeueStore.For(rr.Object).Backoff() + case SR_RESET: + srRes, _ = s.smartRequeueStore.For(rr.Object).Reset() + case SR_NO_REQUEUE: + srRes, _ = s.smartRequeueStore.For(rr.Object).Never() + } + } + if srRes.RequeueAfter > 0 && (rr.Result.RequeueAfter == 0 || srRes.RequeueAfter < rr.Result.RequeueAfter) { + rr.Result.RequeueAfter = srRes.RequeueAfter + } + } + return rr.Result, errs.Aggregate() } diff --git a/pkg/controller/status_updater_test.go b/pkg/controller/status_updater_test.go index 786af50..86ff0cb 100644 --- a/pkg/controller/status_updater_test.go +++ b/pkg/controller/status_updater_test.go @@ -9,11 +9,13 @@ import ( . "github.com/onsi/gomega" "github.com/openmcp-project/controller-utils/pkg/conditions" + "github.com/openmcp-project/controller-utils/pkg/controller/smartrequeue" . "github.com/openmcp-project/controller-utils/pkg/testing/matchers" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/scheme" @@ -194,6 +196,56 @@ var _ = Describe("Status Updater", func() { } }) + Context("Smart Requeue", func() { + + It("should add a requeueAfter duration if configured", func() { + env := testutils.NewEnvironmentBuilder().WithFakeClient(coScheme).WithInitObjectPath("testdata", "test-02").WithDynamicObjectsWithStatus(&CustomObject{}).Build() + obj := &CustomObject{} + Expect(env.Client().Get(env.Ctx, controller.ObjectKey("status", "default"), obj)).To(Succeed()) + rr := controller.ReconcileResult[*CustomObject]{ + Object: obj, + Conditions: dummyConditions(), + } + store := smartrequeue.NewStore(1*time.Second, 10*time.Second, 2.0) + su := preconfiguredStatusUpdaterBuilder().WithPhaseUpdateFunc(func(obj *CustomObject, rr controller.ReconcileResult[*CustomObject]) (string, error) { + return PhaseSucceeded, nil + }).WithSmartRequeue(store, controller.SR_RESET).Build() + res, err := su.UpdateStatus(env.Ctx, env.Client(), rr) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(1 * time.Second)) + }) + + It("should keep the smaller requeueAfter duration if smart requeue and RequeueAfter in the ReconcileResult are set", func() { + env := testutils.NewEnvironmentBuilder().WithFakeClient(coScheme).WithInitObjectPath("testdata", "test-02").WithDynamicObjectsWithStatus(&CustomObject{}).Build() + obj := &CustomObject{} + Expect(env.Client().Get(env.Ctx, controller.ObjectKey("status", "default"), obj)).To(Succeed()) + rr := controller.ReconcileResult[*CustomObject]{ + Object: obj, + Conditions: dummyConditions(), + Result: ctrl.Result{ + RequeueAfter: 30 * time.Second, + }, + } + store := smartrequeue.NewStore(1*time.Second, 10*time.Second, 2.0) + su := preconfiguredStatusUpdaterBuilder().WithPhaseUpdateFunc(func(obj *CustomObject, rr controller.ReconcileResult[*CustomObject]) (string, error) { + return PhaseSucceeded, nil + }).WithSmartRequeue(store, controller.SR_RESET).Build() + res, err := su.UpdateStatus(env.Ctx, env.Client(), rr) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(1 * time.Second)) + + rr.Result.RequeueAfter = 30 * time.Second + store = smartrequeue.NewStore(1*time.Minute, 10*time.Minute, 2.0) + su = preconfiguredStatusUpdaterBuilder().WithPhaseUpdateFunc(func(obj *CustomObject, rr controller.ReconcileResult[*CustomObject]) (string, error) { + return PhaseSucceeded, nil + }).WithSmartRequeue(store, controller.SR_RESET).Build() + res, err = su.UpdateStatus(env.Ctx, env.Client(), rr) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(30 * time.Second)) + }) + + }) + Context("GenerateCreateConditionFunc", func() { It("should add the condition to the given ReconcileResult", func() {