diff --git a/applicationset/controllers/applicationset_controller.go b/applicationset/controllers/applicationset_controller.go index e2a0b7ba39384..47f9937893857 100644 --- a/applicationset/controllers/applicationset_controller.go +++ b/applicationset/controllers/applicationset_controller.go @@ -16,6 +16,7 @@ package controllers import ( "context" + "encoding/json" "errors" "fmt" "reflect" @@ -959,6 +960,189 @@ func (r *ApplicationSetReconciler) removeOwnerReferencesOnDeleteAppSet(ctx conte return nil } +// getEarliestWaitingTransitionTimeOfAppset extracts the earliest LastTransitionTime from ApplicationSet status +// for Applications in Waiting state that have had a revision change (not new apps). +// Returns nil if no Waiting applications with UNRECONCILED revision changes are found. +func getEarliestWaitingTransitionTimeOfAppset(appset *argov1alpha1.ApplicationSet, applications []argov1alpha1.Application) *metav1.Time { + // Build a map of app name to Application for quick lookup + appMap := make(map[string]*argov1alpha1.Application) + for i := range applications { + appMap[applications[i].Name] = &applications[i] + } + + var earliest *metav1.Time + for _, appStatus := range appset.Status.ApplicationStatus { + // Only consider apps in Waiting state that have a transition time + // The message "Application has pending changes" indicates a revision change (not a new app) + if appStatus.Status != argov1alpha1.ProgressiveSyncWaiting || + appStatus.LastTransitionTime == nil || + !strings.Contains(appStatus.Message, "pending changes") { + continue + } + + app, exists := appMap[appStatus.Application] + if !exists { + continue + } + + // If the app has been reconciled after transitioning to Waiting AND has the correct revision, + if app.Status.ReconciledAt != nil && + app.Status.ReconciledAt.After(appStatus.LastTransitionTime.Time) && + reflect.DeepEqual(app.Status.GetRevisions(), appStatus.TargetRevisions) { + // This app has already been reconciled with the correct revision, skip it + continue + } + + // This app needs reconciliation + if earliest == nil || appStatus.LastTransitionTime.Before(earliest) { + earliest = appStatus.LastTransitionTime + } + } + return earliest +} + +// addRefreshAnnotationToApplications adds the refresh annotation to all Applications owned by the ApplicationSet +func (r *ApplicationSetReconciler) addRefreshAnnotationToApplications(ctx context.Context, logCtx *log.Entry, appset argov1alpha1.ApplicationSet, applications []argov1alpha1.Application) error { + for _, app := range applications { + // Check if annotation already exists + if app.Annotations != nil && app.Annotations[argov1alpha1.AnnotationKeyRefresh] != "" { + logCtx.WithField("app", app.Name).Debug("Refresh annotation already present, skipping") + continue + } + + // Patch the application with the refresh annotation + patch := map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]string{ + argov1alpha1.AnnotationKeyRefresh: string(argov1alpha1.RefreshTypeNormal), + }, + }, + } + patchJSON, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("error marshaling refresh annotation patch for app %s: %w", app.Name, err) + } + + err = r.Client.Patch(ctx, &app, client.RawPatch(types.MergePatchType, patchJSON)) + if err != nil { + return fmt.Errorf("error adding refresh annotation to app %s: %w", app.Name, err) + } + logCtx.WithField("app", app.Name).Info("Added refresh annotation to Application") + } + return nil +} + +// checkAllApplicationsReconciled verifies that all Applications have been reconciled since the given time, +// that no refresh annotations are present, and that the reconciled revision matches the target revision +func checkAllApplicationsReconciled(applications []argov1alpha1.Application, logCtx *log.Entry, appset *argov1alpha1.ApplicationSet, sinceTime *metav1.Time) bool { + if sinceTime == nil { + return true + } + + for _, app := range applications { + if app.Annotations != nil && app.Annotations[argov1alpha1.AnnotationKeyRefresh] != "" { + logCtx.Debug("Application still has refresh annotation, waiting for reconciliation") + return false + } + + // Check if ReconciledAt is set and is after sinceTime + if app.Status.ReconciledAt == nil { + logCtx.Debug("Application ReconciledAt is nil, not yet reconciled") + return false + } + if !app.Status.ReconciledAt.After(sinceTime.Time) { + logCtx.WithFields(log.Fields{ + "app": app.Name, + "reconciledAt": app.Status.ReconciledAt.Time, + "sinceTime": sinceTime.Time, + "reconciledIsAfter": app.Status.ReconciledAt.After(sinceTime.Time), + }).Debug("Application not reconciled after transition time") + return false + } + + // This ensures the app actually picked up the new revision + idx := findApplicationStatusIndex(appset.Status.ApplicationStatus, app.Name) + if idx != -1 { + appStatus := appset.Status.ApplicationStatus[idx] + currentRevisions := app.Status.GetRevisions() + + if !reflect.DeepEqual(currentRevisions, appStatus.TargetRevisions) { + logCtx.WithFields(log.Fields{ + "app": app.Name, + "currentRevisions": currentRevisions, + "targetRevisions": appStatus.TargetRevisions, + }).Debug("Application reconciled but revision doesn't match target - waiting for correct revision") + return false + } + } + } + return true +} + +// ensureApplicationsReconciled ensures all Applications are reconciled before proceeding with progressive sync +// It adds refresh annotations if needed and checks if all apps have been reconciled +func (r *ApplicationSetReconciler) ensureApplicationsReconciled(ctx context.Context, logCtx *log.Entry, appset *argov1alpha1.ApplicationSet, applications []argov1alpha1.Application) (bool, error) { + // Get the earliest transition time for Waiting status (only for revision changes, not new apps) of owned Applications + earliestWaitingTime := getEarliestWaitingTransitionTimeOfAppset(appset, applications) + + if earliestWaitingTime == nil { + logCtx.Debug("No applications with pending revision changes, skipping reconciliation check") // Or a new app + return true, nil + } + + logCtx.WithField("earliest_waiting_time", earliestWaitingTime.Time).Info("Applications have pending revision changes, checking if reconciliation needed") + + // TODO: remove - Logs current state of all applications + for _, app := range applications { + hasAnnotation := app.Annotations != nil && app.Annotations[argov1alpha1.AnnotationKeyRefresh] != "" + var reconciledAt string + if app.Status.ReconciledAt != nil { + reconciledAt = app.Status.ReconciledAt.Time.Format(time.RFC3339) + } else { + reconciledAt = "nil" + } + logCtx.WithFields(log.Fields{ + "app": app.Name, + "hasRefresh": hasAnnotation, + "reconciledAt": reconciledAt, + "health": app.Status.Health.Status, + "sync": app.Status.Sync.Status, + }).Debug("Application state before reconciliation check") + } + + // Check if all applications have been reconciled since the earliestWaitingTime + allReconciled := checkAllApplicationsReconciled(applications, logCtx, appset, earliestWaitingTime) + if allReconciled { + logCtx.Info("All Applications have been reconciled, proceeding with progressive sync") + return true, nil + } + + // log apps that still have refresh annotation + appsWithAnnotation := []string{} + for _, app := range applications { + if app.Annotations != nil && app.Annotations[argov1alpha1.AnnotationKeyRefresh] != "" { + appsWithAnnotation = append(appsWithAnnotation, app.Name) + } + } + + if len(appsWithAnnotation) > 0 { + // We've already added annotations, just waiting for reconciliation + logCtx.WithField("apps_with_annotation", appsWithAnnotation).Info("Waiting for Applications with refresh annotations to be reconciled") + return false, nil + } + + // add refresh annotations to trigger reconciliation + logCtx.Info("Applications have pending changes, adding refresh annotations to all Applications to trigger reconciliation") + // TODO: check if util/argo/argo.go RefreshApp can be used instead + err := r.addRefreshAnnotationToApplications(ctx, logCtx, *appset, applications) + if err != nil { + return false, fmt.Errorf("failed to add refresh annotations: %w", err) + } + + logCtx.Info("Refresh annotations added to all applications, waiting for application controller to reconcile them") + return false, nil +} + func (r *ApplicationSetReconciler) performProgressiveSyncs(ctx context.Context, logCtx *log.Entry, appset argov1alpha1.ApplicationSet, applications []argov1alpha1.Application, desiredApplications []argov1alpha1.Application) (map[string]bool, error) { appDependencyList, appStepMap := r.buildAppDependencyList(logCtx, appset, desiredApplications) @@ -967,6 +1151,17 @@ func (r *ApplicationSetReconciler) performProgressiveSyncs(ctx context.Context, return nil, fmt.Errorf("failed to update applicationset app status: %w", err) } + // Ensure all applications are reconciled before proceeding with progressive sync + allReconciled, err := r.ensureApplicationsReconciled(ctx, logCtx, &appset, applications) + if err != nil { + return nil, fmt.Errorf("failed to ensure applications reconciled: %w", err) + } + if !allReconciled { + // Not all applications are reconciled yet, return empty sync map to prevent progression + logCtx.Debug("Progressive sync blocked until all applications are reconciled") + return map[string]bool{}, nil + } + logCtx.Infof("ApplicationSet %v step list:", appset.Name) for stepIndex, applicationNames := range appDependencyList { logCtx.Infof("step %v: %+v", stepIndex+1, applicationNames) @@ -1202,6 +1397,9 @@ func (r *ApplicationSetReconciler) updateApplicationSetApplicationStatus(ctx con newAppStatus.Message = "Application resource has synced, updating status to Progressing" } } + //TODO: add else-if condition here instead to check for earliest transition time and add refresh annotations + // Checking for transition time after updating applicationStatus' is not working - apps syncing out of order after waiting for apps to refresh. + } else { // The target revision is the same, so we need to evaluate the current revision progress if currentAppStatus.Status == argov1alpha1.ProgressiveSyncPending { diff --git a/applicationset/controllers/applicationset_controller_test.go b/applicationset/controllers/applicationset_controller_test.go index 5d8f5c20d142d..489628b57b2f8 100644 --- a/applicationset/controllers/applicationset_controller_test.go +++ b/applicationset/controllers/applicationset_controller_test.go @@ -774,7 +774,7 @@ func TestCreateOrUpdateInCluster(t *testing.T) { }, Spec: v1alpha1.ApplicationSpec{ Project: "project", - Source: &v1alpha1.ApplicationSource{ + Source: &v1alpha1.ApplicationSource{ // Directory and jsonnet block are removed }, }, @@ -7706,6 +7706,860 @@ func TestReconcileProgressiveSyncDisabled(t *testing.T) { } } +func TestGetEarliestWaitingTransitionTimeNeedingReconciliation(t *testing.T) { + now := metav1.Now() + earlierTime := metav1.NewTime(now.Add(-5 * time.Minute)) + laterTime := metav1.NewTime(now.Add(-2 * time.Minute)) + afterTransition := metav1.NewTime(laterTime.Add(1 * time.Minute)) + + tests := []struct { + name string + appset *v1alpha1.ApplicationSet + applications []v1alpha1.Application + expected *metav1.Time + }{ + { + name: "no applications in waiting state", + appset: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncHealthy, + LastTransitionTime: &now, + Message: "Application resource became Healthy", + }, + }, + }, + }, + applications: []v1alpha1.Application{}, + expected: nil, + }, + { + name: "brand new application in waiting state (no pending changes message) returns nil", + appset: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &now, + Message: "No Application status found, defaulting status to Waiting", + }, + }, + }, + }, + applications: []v1alpha1.Application{}, + expected: nil, + }, + { + name: "application with pending changes needs reconciliation", + appset: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &now, + Message: "Application has pending changes, setting status to Waiting", + TargetRevisions: []string{"new-revision"}, + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &earlierTime, // Reconciled before transition + Sync: v1alpha1.SyncStatus{ + Revision: "old-revision", + }, + }, + }, + }, + expected: &now, + }, + { + name: "multiple applications with pending changes returns earliest needing reconciliation", + appset: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &laterTime, + Message: "Application has pending changes, setting status to Waiting", + TargetRevisions: []string{"new-rev"}, + }, + { + Application: "app2", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &earlierTime, + Message: "Application has pending changes, setting status to Waiting", + TargetRevisions: []string{"new-rev"}, + }, + { + Application: "app3", + Status: v1alpha1.ProgressiveSyncHealthy, + LastTransitionTime: &now, + Message: "Healthy", + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &earlierTime, + Sync: v1alpha1.SyncStatus{Revision: "old-rev"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "app2"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &earlierTime, + Sync: v1alpha1.SyncStatus{Revision: "old-rev"}, + }, + }, + }, + expected: &earlierTime, + }, + { + name: "application already reconciled with correct revision is skipped", + appset: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &earlierTime, + Message: "Application has pending changes, setting status to Waiting", + TargetRevisions: []string{"new-rev"}, + }, + { + Application: "app2", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &laterTime, + Message: "Application has pending changes, setting status to Waiting", + TargetRevisions: []string{"new-rev"}, + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &afterTransition, // Reconciled after transition + Sync: v1alpha1.SyncStatus{Revision: "new-rev"}, // Correct revision + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "app2"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &earlierTime, // Not reconciled yet + Sync: v1alpha1.SyncStatus{Revision: "old-rev"}, + }, + }, + }, + expected: &laterTime, // Only app2 needs reconciliation + }, + { + name: "all applications reconciled after transition time, returns nil", + appset: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &earlierTime, + Message: "Application has pending changes, setting status to Waiting", + TargetRevisions: []string{"new-rev"}, + }, + { + Application: "app2", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &laterTime, + Message: "Application has pending changes, setting status to Waiting", + TargetRevisions: []string{"new-rev"}, + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &afterTransition, // Reconciled after transition + Sync: v1alpha1.SyncStatus{Revision: "new-rev"}, // Correct revision + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "app2"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &afterTransition, // Reconciled after transition + Sync: v1alpha1.SyncStatus{Revision: "new-rev"}, // Correct revision + }, + }, + }, + expected: nil, // No applications needs reconcile + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getEarliestWaitingTransitionTimeOfAppset(tt.appset, tt.applications) + if tt.expected == nil { + assert.Nil(t, result) + } else { + assert.NotNil(t, result) + assert.Equal(t, tt.expected.Time, result.Time) + } + }) + } +} + +func TestCheckAllApplicationsReconciled(t *testing.T) { + now := metav1.Now() + before := metav1.NewTime(now.Add(-5 * time.Minute)) + after := metav1.NewTime(now.Add(5 * time.Minute)) + + tests := []struct { + name string + applications []v1alpha1.Application + appset *v1alpha1.ApplicationSet + sinceTime *metav1.Time + expected bool + }{ + { + name: "nil sinceTime returns true", + applications: []v1alpha1.Application{}, + appset: &v1alpha1.ApplicationSet{}, + sinceTime: nil, + expected: true, + }, + { + name: "all applications reconciled after sinceTime with matching revisions", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &after, + Sync: v1alpha1.SyncStatus{ + Revision: "abc123", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "app2"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &after, + Sync: v1alpha1.SyncStatus{ + Revision: "def456", + }, + }, + }, + }, + appset: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + TargetRevisions: []string{"abc123"}, + }, + { + Application: "app2", + TargetRevisions: []string{"def456"}, + }, + }, + }, + }, + sinceTime: &now, + expected: true, + }, + { + name: "application with refresh annotation not reconciled", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Annotations: map[string]string{ + v1alpha1.AnnotationKeyRefresh: string(v1alpha1.RefreshTypeNormal), + }, + }, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &after, + }, + }, + }, + appset: &v1alpha1.ApplicationSet{}, + sinceTime: &now, + expected: false, + }, + { + name: "application without ReconciledAt not reconciled", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: nil, + }, + }, + }, + appset: &v1alpha1.ApplicationSet{}, + sinceTime: &now, + expected: false, + }, + { + name: "application reconciled before sinceTime not reconciled", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &before, + }, + }, + }, + appset: &v1alpha1.ApplicationSet{}, + sinceTime: &now, + expected: false, + }, + { + name: "mixed reconciliation states returns false", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &after, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "app2"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &before, + }, + }, + }, + appset: &v1alpha1.ApplicationSet{}, + sinceTime: &now, + expected: false, + }, + { + name: "application reconciled but wrong revision returns false", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{Name: "app1"}, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &after, + Sync: v1alpha1.SyncStatus{ + Revision: "old-revision", + }, + }, + }, + }, + appset: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + TargetRevisions: []string{"new-revision"}, + }, + }, + }, + }, + sinceTime: &now, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := checkAllApplicationsReconciled(tt.applications, log.NewEntry(log.StandardLogger()), tt.appset, tt.sinceTime) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestAddRefreshAnnotationToApplications(t *testing.T) { + scheme := runtime.NewScheme() + err := v1alpha1.AddToScheme(scheme) + require.NoError(t, err) + + tests := []struct { + name string + applications []v1alpha1.Application + expectError bool + }{ + { + name: "adds annotation to application without annotations", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + }, + }, + }, + expectError: false, + }, + { + name: "adds annotation to application with existing annotations", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + Annotations: map[string]string{ + "other-annotation": "value", + }, + }, + }, + }, + expectError: false, + }, + { + name: "skips application that already has refresh annotation", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + Annotations: map[string]string{ + v1alpha1.AnnotationKeyRefresh: string(v1alpha1.RefreshTypeNormal), + }, + }, + }, + }, + expectError: false, + }, + { + name: "adds annotation to multiple applications", + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app2", + Namespace: "argocd", + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + appset := v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-appset", + Namespace: "argocd", + }, + } + + initObjs := []crtclient.Object{&appset} + for i := range tt.applications { + initObjs = append(initObjs, &tt.applications[i]) + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(initObjs...).Build() + r := ApplicationSetReconciler{ + Client: client, + Scheme: scheme, + } + + err := r.addRefreshAnnotationToApplications( + t.Context(), + log.NewEntry(log.StandardLogger()), + appset, + tt.applications, + ) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + // Verify annotations were added (except for apps that already had them) + for _, app := range tt.applications { + var retrievedApp v1alpha1.Application + err := client.Get(t.Context(), crtclient.ObjectKey{ + Name: app.Name, + Namespace: app.Namespace, + }, &retrievedApp) + require.NoError(t, err) + + // Should have the refresh annotation + assert.NotNil(t, retrievedApp.Annotations) + assert.Equal(t, string(v1alpha1.RefreshTypeNormal), retrievedApp.Annotations[v1alpha1.AnnotationKeyRefresh]) + } + } + }) + } +} + +func TestEnsureApplicationsReconciled(t *testing.T) { + scheme := runtime.NewScheme() + err := v1alpha1.AddToScheme(scheme) + require.NoError(t, err) + + now := metav1.Now() + before := metav1.NewTime(now.Add(-5 * time.Minute)) + after := metav1.NewTime(now.Add(5 * time.Minute)) + + tests := []struct { + name string + appset v1alpha1.ApplicationSet + applications []v1alpha1.Application + expectedReconciled bool + expectError bool + }{ + { + name: "no applications in waiting state returns true", + appset: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-appset", + Namespace: "argocd", + }, + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncHealthy, + LastTransitionTime: &now, + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + }, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &after, + }, + }, + }, + expectedReconciled: true, + expectError: false, + }, + { + name: "applications in waiting but all reconciled returns true", + appset: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-appset", + Namespace: "argocd", + }, + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &before, + Message: "pending changes", + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + Annotations: map[string]string{ + v1alpha1.AnnotationKeyRefresh: string(v1alpha1.RefreshTypeNormal), + }, + }, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &after, + }, + }, + }, + expectedReconciled: false, + expectError: false, + }, + { + name: "applications in waiting and not reconciled adds annotations", + appset: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-appset", + Namespace: "argocd", + }, + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &now, + Message: "pending changes", + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + }, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &before, + }, + }, + }, + expectedReconciled: false, + expectError: false, + }, + { + name: "applications without ReconciledAt adds annotations and returns false", + appset: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-appset", + Namespace: "argocd", + }, + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &now, + Message: "pending changes", + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + }, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: nil, + }, + }, + }, + expectedReconciled: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initObjs := []crtclient.Object{&tt.appset} + for i := range tt.applications { + initObjs = append(initObjs, &tt.applications[i]) + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(initObjs...).Build() + r := ApplicationSetReconciler{ + Client: client, + Scheme: scheme, + } + + reconciled, err := r.ensureApplicationsReconciled( + t.Context(), + log.NewEntry(log.StandardLogger()), + &tt.appset, + tt.applications, + ) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedReconciled, reconciled) + } + }) + } +} + +func TestPerformProgressiveSyncsWithReconciliationCheck(t *testing.T) { + scheme := runtime.NewScheme() + err := v1alpha1.AddToScheme(scheme) + require.NoError(t, err) + + now := metav1.Now() + before := metav1.NewTime(now.Add(-5 * time.Minute)) + after := metav1.NewTime(now.Add(5 * time.Minute)) + + tests := []struct { + name string + appset v1alpha1.ApplicationSet + applications []v1alpha1.Application + desiredApplications []v1alpha1.Application + expectedSyncMap map[string]bool + }{ + { + name: "blocks sync when applications not reconciled", + appset: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-appset", + Namespace: "argocd", + }, + Spec: v1alpha1.ApplicationSetSpec{ + Strategy: &v1alpha1.ApplicationSetStrategy{ + Type: "RollingSync", + RollingSync: &v1alpha1.ApplicationSetRolloutStrategy{ + Steps: []v1alpha1.ApplicationSetRolloutStep{ + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"dev"}, + }, + }, + }, + }, + }, + }, + }, + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "app1", + Status: v1alpha1.ProgressiveSyncWaiting, + LastTransitionTime: &now, + TargetRevisions: []string{"revision1"}, + }, + }, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + Labels: map[string]string{ + "env": "dev", + }, + }, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &before, + Sync: v1alpha1.SyncStatus{ + Revision: "revision1", + }, + }, + }, + }, + desiredApplications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + Labels: map[string]string{ + "env": "dev", + }, + }, + }, + }, + expectedSyncMap: map[string]bool{}, + }, + { + name: "allows sync when all applications reconciled", + appset: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-appset", + Namespace: "argocd", + }, + Spec: v1alpha1.ApplicationSetSpec{ + Strategy: &v1alpha1.ApplicationSetStrategy{ + Type: "RollingSync", + RollingSync: &v1alpha1.ApplicationSetRolloutStrategy{ + Steps: []v1alpha1.ApplicationSetRolloutStep{ + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"dev"}, + }, + }, + }, + }, + }, + }, + }, + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{}, + }, + }, + applications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + Labels: map[string]string{ + "env": "dev", + }, + }, + Status: v1alpha1.ApplicationStatus{ + ReconciledAt: &after, + Health: v1alpha1.AppHealthStatus{ + Status: health.HealthStatusHealthy, + }, + Sync: v1alpha1.SyncStatus{ + Status: v1alpha1.SyncStatusCodeSynced, + Revision: "revision1", + }, + }, + }, + }, + desiredApplications: []v1alpha1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "argocd", + Labels: map[string]string{ + "env": "dev", + }, + }, + Status: v1alpha1.ApplicationStatus{ + Sync: v1alpha1.SyncStatus{ + Revision: "revision1", + }, + }, + }, + }, + expectedSyncMap: map[string]bool{"app1": true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initObjs := []crtclient.Object{&tt.appset} + for i := range tt.applications { + initObjs = append(initObjs, &tt.applications[i]) + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(initObjs...).Build() + r := ApplicationSetReconciler{ + Client: client, + Scheme: scheme, + EnableProgressiveSyncs: true, + } + + syncMap, err := r.performProgressiveSyncs( + t.Context(), + log.NewEntry(log.StandardLogger()), + tt.appset, + tt.applications, + tt.desiredApplications, + ) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedSyncMap, syncMap) + }) + } func startAndSyncInformer(t *testing.T, informer cache.SharedIndexInformer) context.CancelFunc { t.Helper() ctx, cancel := context.WithCancel(t.Context())