From 91da6a0b4c39e1ca7317896225a02561b0d09b31 Mon Sep 17 00:00:00 2001 From: Michael Crenshaw <350466+crenshaw-dev@users.noreply.github.com> Date: Fri, 17 Apr 2026 04:42:52 -0400 Subject: [PATCH] Revert "fix: prevent automatic refreshes from informer resync and status updates (cherry-pick #25290 for 3.2)" (#27399) --- controller/appcontroller.go | 145 +++++------------ controller/appcontroller_test.go | 247 ----------------------------- test/e2e/app_management_ns_test.go | 93 ----------- 3 files changed, 37 insertions(+), 448 deletions(-) diff --git a/controller/appcontroller.go b/controller/appcontroller.go index c49f922639..afdbbccddd 100644 --- a/controller/appcontroller.go +++ b/controller/appcontroller.go @@ -6,6 +6,7 @@ import ( stderrors "errors" "fmt" "math" + "math/rand" "net/http" "reflect" "runtime/debug" @@ -25,7 +26,6 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sync/semaphore" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -124,6 +124,7 @@ type ApplicationController struct { stateCache statecache.LiveStateCache statusRefreshTimeout time.Duration statusHardRefreshTimeout time.Duration + statusRefreshJitter time.Duration selfHealTimeout time.Duration selfHealBackoff *wait.Backoff selfHealBackoffCooldown time.Duration @@ -202,6 +203,7 @@ func NewApplicationController( db: db, statusRefreshTimeout: appResyncPeriod, statusHardRefreshTimeout: appHardResyncPeriod, + statusRefreshJitter: appResyncJitter, refreshRequestedApps: make(map[string]CompareWith), refreshRequestedAppsMutex: &sync.Mutex{}, auditLogger: argo.NewAuditLogger(kubeClientset, common.ApplicationController, enableK8sEvent), @@ -1013,54 +1015,17 @@ func (ctrl *ApplicationController) processAppOperationQueueItem() (processNext b log.Errorf("Failed to get application '%s' from informer index: %+v", appKey, err) return } - - var app *appv1.Application - var logCtx *log.Entry - if !exists { - parts := strings.Split(appKey, "/") - if len(parts) != 2 { - log.WithField("appkey", appKey).Warn("Unexpected appKey format, expected namespace/name") - return processNext - } - appNamespace, appName := parts[0], parts[1] - freshApp, apiErr := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(appNamespace).Get(context.Background(), appName, metav1.GetOptions{}) - if apiErr != nil { - if apierrors.IsNotFound(apiErr) { - return processNext - } - log.WithField("appkey", appKey).WithError(apiErr).Error("Failed to retrieve application from API server") - return processNext - } - if freshApp.Operation == nil { - return processNext - } - app = freshApp - logCtx = log.WithFields(applog.GetAppLogFields(app)) - } else { - origApp, ok := obj.(*appv1.Application) - if !ok { - log.WithField("appkey", appKey).Warn("Key in index is not an application") - return processNext - } - app = origApp.DeepCopy() - logCtx = log.WithFields(applog.GetAppLogFields(app)) - - if app.Operation != nil { - freshApp, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(app.ObjectMeta.Namespace).Get(context.Background(), app.Name, metav1.GetOptions{}) - if err != nil { - if !apierrors.IsNotFound(err) { - logCtx.WithError(err).Error("Failed to retrieve latest application state") - } - return processNext - } - if freshApp.Operation == nil { - return processNext - } - app = freshApp - } + // This happens after app was deleted, but the work queue still had an entry for it. + return } - + origApp, ok := obj.(*appv1.Application) + if !ok { + log.Warnf("Key '%s' in index is not an application", appKey) + return + } + app := origApp.DeepCopy() + logCtx := log.WithFields(applog.GetAppLogFields(app)) ts := stats.NewTimingStats() defer func() { for k, v := range ts.Timings() { @@ -1069,6 +1034,18 @@ func (ctrl *ApplicationController) processAppOperationQueueItem() (processNext b logCtx = logCtx.WithField("time_ms", time.Since(ts.StartTime).Milliseconds()) logCtx.Debug("Finished processing app operation queue item") }() + + if app.Operation != nil { + // If we get here, we are about to process an operation, but we cannot rely on informer since it might have stale data. + // So always retrieve the latest version to ensure it is not stale to avoid unnecessary syncing. + // We cannot rely on informer since applications might be updated by both application controller and api server. + freshApp, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(app.ObjectMeta.Namespace).Get(context.Background(), app.Name, metav1.GetOptions{}) + if err != nil { + logCtx.Errorf("Failed to retrieve latest application state: %v", err) + return + } + app = freshApp + } ts.AddCheckpoint("get_fresh_app_ms") if app.Operation != nil { @@ -2406,29 +2383,6 @@ func (ctrl *ApplicationController) canProcessApp(obj any) bool { return ctrl.clusterSharding.IsManagedCluster(destCluster) } -func operationChanged(oldApp, newApp *appv1.Application) bool { - return (oldApp.Operation == nil && newApp.Operation != nil) || - (oldApp.Operation != nil && newApp.Operation != nil && !equality.Semantic.DeepEqual(oldApp.Operation, newApp.Operation)) -} - -func deletionTimestampChanged(oldApp, newApp *appv1.Application) bool { - return (oldApp.DeletionTimestamp == nil && newApp.DeletionTimestamp != nil) || - (oldApp.DeletionTimestamp != nil && newApp.DeletionTimestamp != nil && !oldApp.DeletionTimestamp.Equal(newApp.DeletionTimestamp)) -} - -func isStatusOnlyUpdate(oldApp, newApp *appv1.Application) bool { - if !equality.Semantic.DeepEqual(oldApp.Spec, newApp.Spec) { - return false - } - if operationChanged(oldApp, newApp) { - return false - } - if deletionTimestampChanged(oldApp, newApp) || newApp.DeletionTimestamp != nil { - return false - } - return true -} - func (ctrl *ApplicationController) newApplicationInformerAndLister() (cache.SharedIndexInformer, applisters.ApplicationLister) { watchNamespace := ctrl.namespace // If we have at least one additional namespace configured, we need to @@ -2521,59 +2475,34 @@ func (ctrl *ApplicationController) newApplicationInformerAndLister() (cache.Shar } }, UpdateFunc: func(old, new any) { + if !ctrl.canProcessApp(new) { + return + } + key, err := cache.MetaNamespaceKeyFunc(new) if err != nil { return } - oldApp, oldOK := old.(*appv1.Application) - newApp, newOK := new.(*appv1.Application) - - if !ctrl.canProcessApp(new) { - return - } - - if newOK && newApp.Operation != nil { - ctrl.appOperationQueue.AddRateLimited(key) - } - var compareWith *CompareWith var delay *time.Duration + oldApp, oldOK := old.(*appv1.Application) + newApp, newOK := new.(*appv1.Application) if oldOK && newOK { - if oldApp.ResourceVersion == newApp.ResourceVersion { - if ctrl.hydrator != nil { - ctrl.appHydrateQueue.AddRateLimited(newApp.QualifiedName()) - } - ctrl.clusterSharding.UpdateApp(newApp) - return - } - - if isStatusOnlyUpdate(oldApp, newApp) { - oldAnnotations := oldApp.GetAnnotations() - newAnnotations := newApp.GetAnnotations() - refreshAdded := (oldAnnotations == nil || oldAnnotations[appv1.AnnotationKeyRefresh] == "") && - (newAnnotations != nil && newAnnotations[appv1.AnnotationKeyRefresh] != "") - hydrateAdded := (oldAnnotations == nil || oldAnnotations[appv1.AnnotationKeyHydrate] == "") && - (newAnnotations != nil && newAnnotations[appv1.AnnotationKeyHydrate] != "") - - if !refreshAdded && !hydrateAdded { - if ctrl.hydrator != nil { - ctrl.appHydrateQueue.AddRateLimited(newApp.QualifiedName()) - } - ctrl.clusterSharding.UpdateApp(newApp) - return - } - } - if automatedSyncEnabled(oldApp, newApp) { log.WithFields(applog.GetAppLogFields(newApp)).Info("Enabled automated sync") compareWith = CompareWithLatest.Pointer() } + if ctrl.statusRefreshJitter != 0 && oldApp.ResourceVersion == newApp.ResourceVersion { + // Handler is refreshing the apps, add a random jitter to spread the load and avoid spikes + jitter := time.Duration(float64(ctrl.statusRefreshJitter) * rand.Float64()) + delay = &jitter + } } ctrl.requestAppRefresh(newApp.QualifiedName(), compareWith, delay) - if !newOK { + if !newOK || (delay != nil && *delay != time.Duration(0)) { ctrl.appOperationQueue.AddRateLimited(key) } if ctrl.hydrator != nil { @@ -2586,7 +2515,7 @@ func (ctrl *ApplicationController) newApplicationInformerAndLister() (cache.Shar return } // IndexerInformer uses a delta queue, therefore for deletes we have to use this - // Key function. + // key function. key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) if err == nil { // for deletes, we immediately add to the refresh queue diff --git a/controller/appcontroller_test.go b/controller/appcontroller_test.go index 6a76f1702e..723eb1f8e7 100644 --- a/controller/appcontroller_test.go +++ b/controller/appcontroller_test.go @@ -13,7 +13,6 @@ import ( "github.com/argoproj/gitops-engine/pkg/utils/kube/kubetest" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/wait" @@ -1694,252 +1693,6 @@ func TestUnchangedManagedNamespaceMetadata(t *testing.T) { assert.Equal(t, CompareWithLatest, compareWith) } -func TestApplicationInformerUpdateFunc(t *testing.T) { - // Test that UpdateFunc correctly handles: - // 1. Status-only updates (no annotation) - should NOT trigger refresh - // 2. Status-only updates WITH refresh annotation - should trigger refresh - // 3. Spec changes - should trigger refresh - // 4. Informer resync (same ResourceVersion) - should NOT trigger refresh - - app := newFakeApp() - app.Spec.Destination.Namespace = test.FakeArgoCDNamespace - app.Spec.Destination.Server = v1alpha1.KubernetesInternalAPIServerAddr - proj := defaultProj.DeepCopy() - proj.Spec.SourceNamespaces = []string{test.FakeArgoCDNamespace} - - ctrl := newFakeController(&fakeData{apps: []runtime.Object{app, proj}}, nil) - - simulateUpdateFunc := func(oldApp, newApp *v1alpha1.Application) { - if !ctrl.canProcessApp(newApp) { - return - } - - key, err := cache.MetaNamespaceKeyFunc(newApp) - if err != nil { - return - } - - var compareWith *CompareWith - var delay *time.Duration - - oldOK := oldApp != nil - newOK := newApp != nil - if oldOK && newOK { - if oldApp.ResourceVersion == newApp.ResourceVersion { - if ctrl.hydrator != nil { - ctrl.appHydrateQueue.AddRateLimited(newApp.QualifiedName()) - } - ctrl.clusterSharding.UpdateApp(newApp) - return - } - - // Check if operation was added or changed - always process operations - operationChanged := (oldApp.Operation == nil && newApp.Operation != nil) || - (oldApp.Operation != nil && newApp.Operation != nil && !equality.Semantic.DeepEqual(oldApp.Operation, newApp.Operation)) - - deletionTimestampChanged := (oldApp.DeletionTimestamp == nil && newApp.DeletionTimestamp != nil) || - (oldApp.DeletionTimestamp != nil && newApp.DeletionTimestamp != nil && !oldApp.DeletionTimestamp.Equal(newApp.DeletionTimestamp)) - appBeingDeleted := newApp.DeletionTimestamp != nil - - if equality.Semantic.DeepEqual(oldApp.Spec, newApp.Spec) && !operationChanged && !deletionTimestampChanged && !appBeingDeleted { - oldAnnotations := oldApp.GetAnnotations() - newAnnotations := newApp.GetAnnotations() - refreshAdded := (oldAnnotations == nil || oldAnnotations[v1alpha1.AnnotationKeyRefresh] == "") && - (newAnnotations != nil && newAnnotations[v1alpha1.AnnotationKeyRefresh] != "") - hydrateAdded := (oldAnnotations == nil || oldAnnotations[v1alpha1.AnnotationKeyHydrate] == "") && - (newAnnotations != nil && newAnnotations[v1alpha1.AnnotationKeyHydrate] != "") - - if !refreshAdded && !hydrateAdded { - if ctrl.hydrator != nil { - ctrl.appHydrateQueue.AddRateLimited(newApp.QualifiedName()) - } - ctrl.clusterSharding.UpdateApp(newApp) - return - } - } - - if automatedSyncEnabled(oldApp, newApp) { - compareWith = CompareWithLatest.Pointer() - } - if compareWith == nil { - compareWith = CompareWithRecent.Pointer() - } - } - - ctrl.requestAppRefresh(newApp.QualifiedName(), compareWith, delay) - if !newOK { - ctrl.appOperationQueue.AddRateLimited(key) - } - if ctrl.hydrator != nil { - ctrl.appHydrateQueue.AddRateLimited(newApp.QualifiedName()) - } - ctrl.clusterSharding.UpdateApp(newApp) - } - - checkRefreshRequested := func(appName string, shouldBeRequested bool, msg string) { - key := ctrl.toAppKey(appName) - ctrl.refreshRequestedAppsMutex.Lock() - _, isRequested := ctrl.refreshRequestedApps[key] - ctrl.refreshRequestedAppsMutex.Unlock() - assert.Equal(t, shouldBeRequested, isRequested, "%s: Refresh request state mismatch for app %s (key: %s)", msg, appName, key) - } - - t.Run("Status-only update without annotation should NOT trigger refresh", func(_ *testing.T) { - ctrl.refreshRequestedAppsMutex.Lock() - ctrl.refreshRequestedApps = make(map[string]CompareWith) - ctrl.refreshRequestedAppsMutex.Unlock() - - oldApp := app.DeepCopy() - oldApp.ResourceVersion = "1" - oldApp.Status.ReconciledAt = &metav1.Time{Time: time.Now().Add(-1 * time.Hour)} - - newApp := oldApp.DeepCopy() - newApp.ResourceVersion = "2" - newApp.Status.ReconciledAt = &metav1.Time{Time: time.Now()} - - simulateUpdateFunc(oldApp, newApp) - checkRefreshRequested(app.QualifiedName(), false, "Status-only update without annotation") - }) - - t.Run("Status-only update WITH refresh annotation SHOULD trigger refresh", func(_ *testing.T) { - ctrl.refreshRequestedAppsMutex.Lock() - ctrl.refreshRequestedApps = make(map[string]CompareWith) - ctrl.refreshRequestedAppsMutex.Unlock() - - oldApp := app.DeepCopy() - oldApp.ResourceVersion = "3" - oldApp.Status.ReconciledAt = &metav1.Time{Time: time.Now().Add(-1 * time.Hour)} - - newApp := oldApp.DeepCopy() - newApp.ResourceVersion = "4" - newApp.Status.ReconciledAt = &metav1.Time{Time: time.Now()} - if newApp.Annotations == nil { - newApp.Annotations = make(map[string]string) - } - newApp.Annotations[v1alpha1.AnnotationKeyRefresh] = string(v1alpha1.RefreshTypeNormal) - - simulateUpdateFunc(oldApp, newApp) - checkRefreshRequested(app.QualifiedName(), true, "Status-only update WITH refresh annotation") - }) - - t.Run("Status-only update WITH hydrate annotation SHOULD trigger refresh", func(_ *testing.T) { - ctrl.refreshRequestedAppsMutex.Lock() - ctrl.refreshRequestedApps = make(map[string]CompareWith) - ctrl.refreshRequestedAppsMutex.Unlock() - - oldApp := app.DeepCopy() - oldApp.ResourceVersion = "5" - oldApp.Status.ReconciledAt = &metav1.Time{Time: time.Now().Add(-1 * time.Hour)} - - newApp := oldApp.DeepCopy() - newApp.ResourceVersion = "6" - newApp.Status.ReconciledAt = &metav1.Time{Time: time.Now()} - if newApp.Annotations == nil { - newApp.Annotations = make(map[string]string) - } - newApp.Annotations[v1alpha1.AnnotationKeyHydrate] = "true" - - simulateUpdateFunc(oldApp, newApp) - checkRefreshRequested(app.QualifiedName(), true, "Status-only update WITH hydrate annotation") - }) - - t.Run("Status-only update WITH both refresh and hydrate annotations SHOULD trigger refresh", func(_ *testing.T) { - ctrl.refreshRequestedAppsMutex.Lock() - ctrl.refreshRequestedApps = make(map[string]CompareWith) - ctrl.refreshRequestedAppsMutex.Unlock() - - oldApp := app.DeepCopy() - oldApp.ResourceVersion = "7" - oldApp.Status.ReconciledAt = &metav1.Time{Time: time.Now().Add(-1 * time.Hour)} - - newApp := oldApp.DeepCopy() - newApp.ResourceVersion = "8" - newApp.Status.ReconciledAt = &metav1.Time{Time: time.Now()} - if newApp.Annotations == nil { - newApp.Annotations = make(map[string]string) - } - newApp.Annotations[v1alpha1.AnnotationKeyRefresh] = string(v1alpha1.RefreshTypeNormal) - newApp.Annotations[v1alpha1.AnnotationKeyHydrate] = "true" - - simulateUpdateFunc(oldApp, newApp) - checkRefreshRequested(app.QualifiedName(), true, "Status-only update WITH both refresh and hydrate annotations") - }) - - t.Run("Status-only update with annotation REMOVAL should NOT trigger refresh", func(_ *testing.T) { - ctrl.refreshRequestedAppsMutex.Lock() - ctrl.refreshRequestedApps = make(map[string]CompareWith) - ctrl.refreshRequestedAppsMutex.Unlock() - - oldApp := app.DeepCopy() - oldApp.ResourceVersion = "9" - oldApp.Status.ReconciledAt = &metav1.Time{Time: time.Now().Add(-1 * time.Hour)} - if oldApp.Annotations == nil { - oldApp.Annotations = make(map[string]string) - } - oldApp.Annotations[v1alpha1.AnnotationKeyRefresh] = string(v1alpha1.RefreshTypeNormal) - - newApp := oldApp.DeepCopy() - newApp.ResourceVersion = "10" - newApp.Status.ReconciledAt = &metav1.Time{Time: time.Now()} - delete(newApp.Annotations, v1alpha1.AnnotationKeyRefresh) - - simulateUpdateFunc(oldApp, newApp) - checkRefreshRequested(app.QualifiedName(), false, "Status-only update with annotation REMOVAL") - }) - - t.Run("Spec change SHOULD trigger refresh", func(_ *testing.T) { - ctrl.refreshRequestedAppsMutex.Lock() - ctrl.refreshRequestedApps = make(map[string]CompareWith) - ctrl.refreshRequestedAppsMutex.Unlock() - - oldApp := app.DeepCopy() - oldApp.ResourceVersion = "11" - - newApp := oldApp.DeepCopy() - newApp.ResourceVersion = "12" - newApp.Spec.Destination.Namespace = "different-namespace" - - simulateUpdateFunc(oldApp, newApp) - checkRefreshRequested(app.QualifiedName(), true, "Spec change") - }) - - t.Run("Informer resync (same ResourceVersion) should NOT trigger refresh", func(_ *testing.T) { - ctrl.refreshRequestedAppsMutex.Lock() - ctrl.refreshRequestedApps = make(map[string]CompareWith) - ctrl.refreshRequestedAppsMutex.Unlock() - - oldApp := app.DeepCopy() - oldApp.ResourceVersion = "13" - - newApp := oldApp.DeepCopy() - newApp.ResourceVersion = "13" - newApp.Status.ReconciledAt = &metav1.Time{Time: time.Now()} - - simulateUpdateFunc(oldApp, newApp) - checkRefreshRequested(app.QualifiedName(), false, "Informer resync") - }) - - t.Run("DeletionTimestamp added SHOULD trigger refresh", func(_ *testing.T) { - // Reset refresh state - ctrl.refreshRequestedAppsMutex.Lock() - ctrl.refreshRequestedApps = make(map[string]CompareWith) - ctrl.refreshRequestedAppsMutex.Unlock() - - oldApp := app.DeepCopy() - oldApp.ResourceVersion = "14" - oldApp.DeletionTimestamp = nil - - newApp := oldApp.DeepCopy() - newApp.ResourceVersion = "15" - newApp.DeletionTimestamp = &metav1.Time{Time: time.Now()} - newApp.Status.ReconciledAt = &metav1.Time{Time: time.Now()} - - simulateUpdateFunc(oldApp, newApp) - - checkRefreshRequested(app.QualifiedName(), true, "DeletionTimestamp added") - }) -} - func TestRefreshAppConditions(t *testing.T) { defaultProj := v1alpha1.AppProject{ ObjectMeta: metav1.ObjectMeta{ diff --git a/test/e2e/app_management_ns_test.go b/test/e2e/app_management_ns_test.go index 95e7eb44d8..0ce3054cd6 100644 --- a/test/e2e/app_management_ns_test.go +++ b/test/e2e/app_management_ns_test.go @@ -2412,96 +2412,3 @@ func TestCreateAppInNotAllowedNamespace(t *testing.T) { Expect(DoesNotExist()). Expect(Error("", "namespace 'default' is not permitted")) } - -// TestZeroReconciliationTimeoutNoExcessiveRefreshes verifies that when timeout.reconciliation=0s, -// applications do not trigger excessive automatic refreshes from status-only updates. -func TestZeroReconciliationTimeoutNoExcessiveRefreshes(t *testing.T) { - ctx := t.Context() - namespace := fixture.TestNamespace() - - require.NoError(t, fixture.SetParamInSettingConfigMap("timeout.reconciliation", "0s")) - require.NoError(t, fixture.SetParamInSettingConfigMap("timeout.reconciliation.jitter", "0s")) - defer func() { - _ = fixture.SetParamInSettingConfigMap("timeout.reconciliation", "") - _ = fixture.SetParamInSettingConfigMap("timeout.reconciliation.jitter", "") - }() - - configMap, err := fixture.KubeClientset.CoreV1().ConfigMaps(namespace).Get(ctx, common.ArgoCDConfigMapName, metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, "0s", configMap.Data["timeout.reconciliation"]) - require.Equal(t, "0s", configMap.Data["timeout.reconciliation.jitter"]) - configMapResourceVersion := configMap.ResourceVersion - configMapUpdateTime := time.Now() - - require.Eventually(t, func() bool { - currentConfigMap, err := fixture.KubeClientset.CoreV1().ConfigMaps(namespace).Get(ctx, common.ArgoCDConfigMapName, metav1.GetOptions{}) - if err != nil { - return false - } - if currentConfigMap.ResourceVersion != configMapResourceVersion { - configMapResourceVersion = currentConfigMap.ResourceVersion - configMapUpdateTime = time.Now() - return false - } - - timeSinceUpdate := time.Since(configMapUpdateTime) - if timeSinceUpdate < 5*time.Second { - return false - } - - apps, err := fixture.AppClientset.ArgoprojV1alpha1().Applications(fixture.AppNamespace()).List(ctx, metav1.ListOptions{}) - if err != nil { - return false - } - - now := time.Now() - for _, app := range apps.Items { - if app.Status.ReconciledAt != nil { - reconciledTime := app.Status.ReconciledAt.Time - if now.Sub(reconciledTime) < 30*time.Second { - return true - } - } - } - - return true - }, 30*time.Second, 1*time.Second, "controller did not sync ConfigMap in time") - - Given(t). - Path(guestbookPath). - SetTrackingMethod("annotation"). - SetAppNamespace(fixture.AppNamespace()). - When(). - CreateApp(). - Sync(). - Then(). - Expect(OperationPhaseIs(OperationSucceeded)). - Expect(SyncStatusIs(SyncStatusCodeSynced)). - And(func(a *Application) { - time.Sleep(5 * time.Second) - - app, err := fixture.AppClientset.ArgoprojV1alpha1().Applications(fixture.AppNamespace()).Get(context.Background(), a.Name, metav1.GetOptions{}) - require.NoError(t, err) - initialReconciledAt := app.Status.ReconciledAt - require.NotNil(t, initialReconciledAt) - - ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) - defer cancel() - - refreshCount := 0 - lastReconciledAt := initialReconciledAt.DeepCopy() - - for event := range fixture.ArgoCDClientset.WatchApplicationWithRetry(ctx, a.QualifiedName(), app.ResourceVersion) { - reconciledAt := event.Application.Status.ReconciledAt - if reconciledAt == nil { - continue - } - if !lastReconciledAt.Equal(reconciledAt) { - refreshCount++ - lastReconciledAt = reconciledAt.DeepCopy() - } - } - - assert.LessOrEqual(t, refreshCount, 1, "application refreshed %d times (expected ≤1) with timeout.reconciliation=0s", refreshCount) - }) -}