From 57dfe55e701f9c51fb2cef3f2f8487c5cfc70ea5 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Tue, 7 Apr 2026 18:25:51 -0400 Subject: [PATCH] fix: prevent automatic refreshes from informer resync and status updates (#25290) Signed-off-by: Atif Ali --- controller/appcontroller.go | 145 ++++++++++++----- controller/appcontroller_test.go | 247 +++++++++++++++++++++++++++++ test/e2e/app_management_ns_test.go | 93 +++++++++++ 3 files changed, 448 insertions(+), 37 deletions(-) diff --git a/controller/appcontroller.go b/controller/appcontroller.go index 62c819f122..a9ae59265a 100644 --- a/controller/appcontroller.go +++ b/controller/appcontroller.go @@ -7,7 +7,6 @@ import ( "fmt" "maps" "math" - "math/rand" "net/http" "reflect" "runtime/debug" @@ -27,6 +26,7 @@ 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" @@ -125,7 +125,6 @@ type ApplicationController struct { stateCache statecache.LiveStateCache statusRefreshTimeout time.Duration statusHardRefreshTimeout time.Duration - statusRefreshJitter time.Duration selfHealTimeout time.Duration selfHealBackoff *wait.Backoff syncTimeout time.Duration @@ -202,7 +201,6 @@ func NewApplicationController( db: db, statusRefreshTimeout: appResyncPeriod, statusHardRefreshTimeout: appHardResyncPeriod, - statusRefreshJitter: appResyncJitter, refreshRequestedApps: make(map[string]CompareWith), refreshRequestedAppsMutex: &sync.Mutex{}, auditLogger: argo.NewAuditLogger(kubeClientset, namespace, common.CommandApplicationController, enableK8sEvent), @@ -1016,17 +1014,54 @@ func (ctrl *ApplicationController) processAppOperationQueueItem() (processNext b log.WithField("appkey", appKey).WithError(err).Error("Failed to get application from informer index") return processNext } + + var app *appv1.Application + var logCtx *log.Entry + if !exists { - // This happens after app was deleted, but the work queue still had an entry for it. - return processNext + 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 + } } - 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)) + ts := stats.NewTimingStats() defer func() { for k, v := range ts.Timings() { @@ -1035,18 +1070,6 @@ 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.WithError(err).Error("Failed to retrieve latest application state") - return processNext - } - app = freshApp - } ts.AddCheckpoint("get_fresh_app_ms") if app.Operation != nil { @@ -2441,6 +2464,29 @@ 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 @@ -2533,34 +2579,59 @@ 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 || (delay != nil && *delay != time.Duration(0)) { + if !newOK { ctrl.appOperationQueue.AddRateLimited(key) } if ctrl.hydrator != nil { @@ -2573,7 +2644,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 ace1780879..0456f71982 100644 --- a/controller/appcontroller_test.go +++ b/controller/appcontroller_test.go @@ -15,6 +15,7 @@ import ( "github.com/argoproj/argo-cd/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" @@ -1992,6 +1993,252 @@ 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(t.Context(), &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 3a90a2ae2b..f9b2198eef 100644 --- a/test/e2e/app_management_ns_test.go +++ b/test/e2e/app_management_ns_test.go @@ -2410,3 +2410,96 @@ 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) + }) +}