diff --git a/applicationset/controllers/applicationset_controller.go b/applicationset/controllers/applicationset_controller.go index 82e1ed52037c8..eb40672490d1d 100644 --- a/applicationset/controllers/applicationset_controller.go +++ b/applicationset/controllers/applicationset_controller.go @@ -231,7 +231,11 @@ func (r *ApplicationSetReconciler) Reconcile(ctx context.Context, req ctrl.Reque } // appSyncMap tracks which apps will be synced during this reconciliation. - appSyncMap := map[string]bool{} + var ( + appSyncMap = map[string]bool{} + appDependencyList [][]string + appStepMap map[string]int + ) if r.EnableProgressiveSyncs { if !isRollingSyncStrategy(&applicationSetInfo) && len(applicationSetInfo.Status.ApplicationStatus) > 0 { @@ -243,7 +247,7 @@ func (r *ApplicationSetReconciler) Reconcile(ctx context.Context, req ctrl.Reque return ctrl.Result{}, fmt.Errorf("failed to clear previous AppSet application statuses for %v: %w", applicationSetInfo.Name, err) } } else if isRollingSyncStrategy(&applicationSetInfo) { - appSyncMap, err = r.performProgressiveSyncs(ctx, logCtx, applicationSetInfo, currentApplications, generatedApplications) + appSyncMap, appDependencyList, appStepMap, err = r.performProgressiveSyncs(ctx, logCtx, applicationSetInfo, currentApplications, generatedApplications) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to perform progressive sync reconciliation for application set: %w", err) } @@ -298,6 +302,8 @@ func (r *ApplicationSetReconciler) Reconcile(ctx context.Context, req ctrl.Reque // trigger appropriate application syncs if RollingSync strategy is enabled if progressiveSyncsRollingSyncStrategyEnabled(&applicationSetInfo) { validApps = r.syncDesiredApplications(logCtx, &applicationSetInfo, appSyncMap, validApps) + // Filter applications to only create/update those in the current eligible step + validApps = r.filterApplicationsByProgressiveStep(logCtx, &applicationSetInfo, validApps, appDependencyList, appStepMap) } } @@ -944,12 +950,20 @@ func (r *ApplicationSetReconciler) removeOwnerReferencesOnDeleteAppSet(ctx conte return nil } -func (r *ApplicationSetReconciler) performProgressiveSyncs(ctx context.Context, logCtx *log.Entry, appset argov1alpha1.ApplicationSet, applications []argov1alpha1.Application, desiredApplications []argov1alpha1.Application) (map[string]bool, error) { +// performProgressiveSyncs orchestrates the progressive sync of applications in an ApplicationSet. +// It builds a dependency graph to determine the sync order, updates application status, and syncs applications +// according to their step order and rollout strategy. +// Returns: +// - map[string]bool: appSyncMap - tracks which applications will be synced during this reconciliation +// - [][]string: appDependencyList - slice of slices representing sync steps, where each inner slice contains application names in that step +// - map[string]int: appStepMap - maps application names to their step index in the progressive sync sequence +// - error: any error encountered during progressive sync reconciliation +func (r *ApplicationSetReconciler) performProgressiveSyncs(ctx context.Context, logCtx *log.Entry, appset argov1alpha1.ApplicationSet, applications []argov1alpha1.Application, desiredApplications []argov1alpha1.Application) (map[string]bool, [][]string, map[string]int, error) { appDependencyList, appStepMap := r.buildAppDependencyList(logCtx, appset, desiredApplications) _, err := r.updateApplicationSetApplicationStatus(ctx, logCtx, &appset, applications, appStepMap) if err != nil { - return nil, fmt.Errorf("failed to update applicationset app status: %w", err) + return nil, appDependencyList, appStepMap, fmt.Errorf("failed to update applicationset app status: %w", err) } logCtx.Infof("ApplicationSet %v step list:", appset.Name) @@ -962,12 +976,12 @@ func (r *ApplicationSetReconciler) performProgressiveSyncs(ctx context.Context, _, err = r.updateApplicationSetApplicationStatusProgress(ctx, logCtx, &appset, appsToSync, appStepMap) if err != nil { - return nil, fmt.Errorf("failed to update applicationset application status progress: %w", err) + return appsToSync, appDependencyList, appStepMap, fmt.Errorf("failed to update applicationset application status progress: %w", err) } _ = r.updateApplicationSetApplicationStatusConditions(ctx, &appset) - return appsToSync, nil + return appsToSync, appDependencyList, appStepMap, nil } // this list tracks which Applications belong to each RollingUpdate step @@ -1590,6 +1604,65 @@ func (r *ApplicationSetReconciler) syncDesiredApplications(logCtx *log.Entry, ap return rolloutApps } +// filterApplicationsByProgressiveStep filters applications to only include those in the current eligible step. +// Applications are only created/updated if all applications in their current step's prerequisite steps are healthy. +func (r *ApplicationSetReconciler) filterApplicationsByProgressiveStep(logCtx *log.Entry, appset *argov1alpha1.ApplicationSet, applications []argov1alpha1.Application, appDependencyList [][]string, appStepMap map[string]int) []argov1alpha1.Application { + if len(appDependencyList) == 0 || len(appStepMap) == 0 { + // No progressive sync configuration, return all apps + return applications + } + + // Find the highest step index that is complete (all apps in that step are healthy or non-existent but needed) + // Step 0 is always enabled for creation + // Step N is enabled if all apps in step N-1 exist and are healthy + maxCreateableStep := 0 // Start with step 0 always creatable + + for stepIndex := 1; stepIndex < len(appDependencyList); stepIndex++ { + // Check if all apps in the previous step are healthy + previousStepComplete := true + for _, appName := range appDependencyList[stepIndex-1] { + idx := findApplicationStatusIndex(appset.Status.ApplicationStatus, appName) + if idx == -1 { + // App status not found or doesn't exist yet + previousStepComplete = false + break + } + + appStatus := appset.Status.ApplicationStatus[idx] + if appStatus.Status != argov1alpha1.ProgressiveSyncHealthy { + previousStepComplete = false + break + } + } + + if !previousStepComplete { + break // Stop checking further steps if current step's prerequisite isn't complete + } + maxCreateableStep = stepIndex + } + + logCtx.Infof("Progressive sync: max creatable step is %d (out of %d)", maxCreateableStep, len(appDependencyList)-1) + + // Only include apps from steps up to and including maxCreateableStep + filteredApps := []argov1alpha1.Application{} + for _, app := range applications { + if stepNum, ok := appStepMap[app.Name]; ok { + // Application belongs to a step - only include if within creatable steps + if stepNum <= maxCreateableStep { + filteredApps = append(filteredApps, app) + logCtx.Debugf("Including app %s from step %d (max creatable: %d)", app.Name, stepNum, maxCreateableStep) + } else { + logCtx.Debugf("Excluding app %s from step %d (max creatable: %d)", app.Name, stepNum, maxCreateableStep) + } + } else { + // Application doesn't belong to any step, include it + filteredApps = append(filteredApps, app) + } + } + + return filteredApps +} + // used by the RollingSync Progressive Sync strategy to trigger a sync of a particular Application resource func syncApplication(application argov1alpha1.Application, prune bool) argov1alpha1.Application { operation := argov1alpha1.Operation{ diff --git a/applicationset/controllers/applicationset_controller_test.go b/applicationset/controllers/applicationset_controller_test.go index 025e9288f4659..4055b11efd1c9 100644 --- a/applicationset/controllers/applicationset_controller_test.go +++ b/applicationset/controllers/applicationset_controller_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strconv" "testing" "time" @@ -7355,3 +7356,160 @@ func TestReconcileProgressiveSyncDisabled(t *testing.T) { }) } } + +func TestFilterApplicationsByProgressiveStep(t *testing.T) { + tests := []struct { + name string + applications []v1alpha1.Application + appSet *v1alpha1.ApplicationSet + appDependencyList [][]string + appStepMap map[string]int + expectedApps []string + expectedLogMsg string + }{ + { + name: "empty dependency list returns all applications", + applications: generateApplications([]string{"app1", "app2", "app3"}), + appSet: &v1alpha1.ApplicationSet{}, + appDependencyList: [][]string{}, + appStepMap: map[string]int{}, + expectedApps: []string{"app1", "app2", "app3"}, + }, + { + name: "empty step map returns all applications", + applications: generateApplications([]string{"app1", "app2", "app3"}), + appSet: &v1alpha1.ApplicationSet{}, + appDependencyList: [][]string{{"app1"}, {"app2"}, {"app3"}}, + appStepMap: map[string]int{}, + expectedApps: []string{"app1", "app2", "app3"}, + }, + { + name: "step 0 applications are always creatable when all prerequisites met", + applications: generateApplications([]string{"step0-app", "step1-app"}), + appSet: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{}, + }, + }, + appDependencyList: [][]string{{"step0-app"}, {"step1-app"}}, + appStepMap: map[string]int{"step0-app": 0, "step1-app": 1}, + expectedApps: []string{"step0-app"}, + }, + { + name: "step 1 is only created when step 0 is healthy", + applications: generateApplications([]string{"step0-app", "step1-app"}), + appSet: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "step0-app", + Status: v1alpha1.ProgressiveSyncHealthy, + }, + }, + }, + }, + appDependencyList: [][]string{{"step0-app"}, {"step1-app"}}, + appStepMap: map[string]int{"step0-app": 0, "step1-app": 1}, + expectedApps: []string{"step0-app", "step1-app"}, + }, + { + name: "step 1 is blocked if step 0 is not healthy", + applications: generateApplications([]string{"step0-app", "step1-app", "step2-app"}), + appSet: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "step0-app", + Status: "Progressing", + }, + }, + }, + }, + appDependencyList: [][]string{{"step0-app"}, {"step1-app"}, {"step2-app"}}, + appStepMap: map[string]int{"step0-app": 0, "step1-app": 1, "step2-app": 2}, + expectedApps: []string{"step0-app"}, + }, + { + name: "multiple apps in step 0 all must be healthy for step 1", + applications: generateApplications([]string{"step0-app1", "step0-app2", "step1-app"}), + appSet: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "step0-app1", + Status: v1alpha1.ProgressiveSyncHealthy, + }, + { + Application: "step0-app2", + Status: v1alpha1.ProgressiveSyncHealthy, + }, + }, + }, + }, + appDependencyList: [][]string{{"step0-app1", "step0-app2"}, {"step1-app"}}, + appStepMap: map[string]int{"step0-app1": 0, "step0-app2": 0, "step1-app": 1}, + expectedApps: []string{"step0-app1", "step0-app2", "step1-app"}, + }, + { + name: "apps without step assignment are always included", + applications: generateApplications([]string{"step0-app", "unmanaged-app", "step1-app"}), + appSet: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{ + { + Application: "step0-app", + Status: v1alpha1.ProgressiveSyncHealthy, + }, + }, + }, + }, + appDependencyList: [][]string{{"step0-app"}, {"step1-app"}}, + appStepMap: map[string]int{"step0-app": 0, "step1-app": 1}, + expectedApps: []string{"step0-app", "unmanaged-app", "step1-app"}, + }, + { + name: "step 0 apps are creatable even if status not yet created", + applications: generateApplications([]string{"step0-app", "step1-app"}), + appSet: &v1alpha1.ApplicationSet{ + Status: v1alpha1.ApplicationSetStatus{ + ApplicationStatus: []v1alpha1.ApplicationSetApplicationStatus{}, + }, + }, + appDependencyList: [][]string{{"step0-app"}, {"step1-app"}}, + appStepMap: map[string]int{"step0-app": 0, "step1-app": 1}, + expectedApps: []string{"step0-app"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &ApplicationSetReconciler{} + logCtx := log.WithFields(log.Fields{}) + filtered := r.filterApplicationsByProgressiveStep(logCtx, tt.appSet, tt.applications, tt.appDependencyList, tt.appStepMap) + + // Extract app names from filtered result + filteredNames := make([]string, len(filtered)) + for i, app := range filtered { + filteredNames[i] = app.Name + } + sort.Strings(filteredNames) + sort.Strings(tt.expectedApps) + + assert.Equal(t, tt.expectedApps, filteredNames, "filtered applications should match expected") + }) + } +} + +// Helper function to generate test applications +func generateApplications(names []string) []v1alpha1.Application { + apps := make([]v1alpha1.Application, len(names)) + for i, name := range names { + apps[i] = v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "argocd", + }, + } + } + return apps +} diff --git a/test/e2e/app_management_test.go b/test/e2e/app_management_test.go index 638b1c2c8db72..8daadbcb0a6f4 100644 --- a/test/e2e/app_management_test.go +++ b/test/e2e/app_management_test.go @@ -3079,11 +3079,6 @@ func TestDeletionConfirmation(t *testing.T) { When().ConfirmDeletion(). Then().Expect(OperationPhaseIs(OperationSucceeded)). When().Delete(true). - Then(). - And(func(app *Application) { - assert.NotNil(t, app.DeletionTimestamp) - }). - When().ConfirmDeletion(). Then().Expect(DoesNotExist()) } diff --git a/test/e2e/applicationset_progressive_sync_test.go b/test/e2e/applicationset_progressive_sync_test.go new file mode 100644 index 0000000000000..f2b1518b79fa0 --- /dev/null +++ b/test/e2e/applicationset_progressive_sync_test.go @@ -0,0 +1,788 @@ +package e2e + +import ( + "log" + "testing" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" + "github.com/argoproj/argo-cd/v3/test/e2e/fixture" + . "github.com/argoproj/argo-cd/v3/test/e2e/fixture/applicationsets" +) + +func init() { + // Enable progressive sync feature for all tests in this file + if err := fixture.SetParamInSettingConfigMap("applicationsetcontroller.enable.progressive.syncs", "true"); err != nil { + log.Fatalf("failed to enable progressive sync: %v", err) + } +} + +func TestProgressiveSyncBasicTwoStepRollout(t *testing.T) { + expectedConditionSuccess := []v1alpha1.ApplicationSetCondition{ + { + Type: v1alpha1.ApplicationSetConditionErrorOccurred, + Status: v1alpha1.ApplicationSetConditionStatusFalse, + Message: "All applications have been generated successfully", + Reason: v1alpha1.ApplicationSetReasonApplicationSetUpToDate, + }, + { + Type: v1alpha1.ApplicationSetConditionParametersGenerated, + Status: v1alpha1.ApplicationSetConditionStatusTrue, + Message: "Successfully generated parameters for all Applications", + Reason: v1alpha1.ApplicationSetReasonParametersGenerated, + }, + { + Type: v1alpha1.ApplicationSetConditionResourcesUpToDate, + Status: v1alpha1.ApplicationSetConditionStatusTrue, + Message: "All applications have been generated successfully", + Reason: v1alpha1.ApplicationSetReasonApplicationSetUpToDate, + }, + } + + appStep1 := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "progressive-sync-dev", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + appStep2 := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "progressive-sync-prod", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + Given(t). + When(). + Create(v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "progressive-sync-basic", + Namespace: "argocd-e2e", + }, + Spec: v1alpha1.ApplicationSetSpec{ + GoTemplate: true, + Generators: []v1alpha1.ApplicationSetGenerator{ + { + List: &v1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + { + Raw: []byte(`{"name":"progressive-sync-dev","env":"dev","index":"1"}`), + }, + { + Raw: []byte(`{"name":"progressive-sync-prod","env":"prod","index":"2"}`), + }, + }, + }, + }, + }, + Strategy: &v1alpha1.ApplicationSetStrategy{ + Type: "RollingSync", + RollingSync: &v1alpha1.ApplicationSetRolloutStrategy{ + Steps: []v1alpha1.ApplicationSetRolloutStep{ + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"dev"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"prod"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + }, + }, + }, + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{ + Name: "{{.name}}", + Labels: map[string]string{ + "env": "{{.env}}", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + }, + }, + }). + Then(). + Expect(OnlyApplicationsExist([]v1alpha1.Application{appStep1}, []v1alpha1.Application{appStep2})). + Expect(ApplicationSetHasProgressiveStatus("progressive-sync-basic", "progressive-sync-dev", v1alpha1.ProgressiveSyncHealthy)). + Expect(ApplicationSetHasConditions("progressive-sync-basic", expectedConditionSuccess)). + When(). + Then(). + Expect(ApplicationsExist([]v1alpha1.Application{appStep1, appStep2})). + Expect(ApplicationSetHasProgressiveStatus("progressive-sync-basic", "progressive-sync-prod", v1alpha1.ProgressiveSyncHealthy)) +} + +func TestProgressiveSyncThreeStepRollout(t *testing.T) { + expectedConditionSuccess := []v1alpha1.ApplicationSetCondition{ + { + Type: v1alpha1.ApplicationSetConditionErrorOccurred, + Status: v1alpha1.ApplicationSetConditionStatusFalse, + Message: "All applications have been generated successfully", + Reason: v1alpha1.ApplicationSetReasonApplicationSetUpToDate, + }, + { + Type: v1alpha1.ApplicationSetConditionParametersGenerated, + Status: v1alpha1.ApplicationSetConditionStatusTrue, + Message: "Successfully generated parameters for all Applications", + Reason: v1alpha1.ApplicationSetReasonParametersGenerated, + }, + { + Type: v1alpha1.ApplicationSetConditionResourcesUpToDate, + Status: v1alpha1.ApplicationSetConditionStatusTrue, + Message: "All applications have been generated successfully", + Reason: v1alpha1.ApplicationSetReasonApplicationSetUpToDate, + }, + } + + appStep1 := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "three-step-dev", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + appStep2 := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "three-step-staging", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + appStep3 := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "three-step-prod", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + Given(t). + When(). + Create(v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "progressive-sync-three-step", + Namespace: "argocd-e2e", + }, + Spec: v1alpha1.ApplicationSetSpec{ + GoTemplate: true, + Generators: []v1alpha1.ApplicationSetGenerator{ + { + List: &v1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + { + Raw: []byte(`{"name":"three-step-dev","env":"dev","index":"1"}`), + }, + { + Raw: []byte(`{"name":"three-step-staging","env":"staging","index":"2"}`), + }, + { + Raw: []byte(`{"name":"three-step-prod","env":"prod","index":"3"}`), + }, + }, + }, + }, + }, + Strategy: &v1alpha1.ApplicationSetStrategy{ + Type: "RollingSync", + RollingSync: &v1alpha1.ApplicationSetRolloutStrategy{ + Steps: []v1alpha1.ApplicationSetRolloutStep{ + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"dev"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"staging"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"prod"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + }, + }, + }, + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{ + Name: "{{.name}}", + Labels: map[string]string{ + "env": "{{.env}}", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + }, + }, + }). + Then(). + Expect(OnlyApplicationsExist([]v1alpha1.Application{appStep1}, []v1alpha1.Application{appStep2, appStep3})). + Expect(ApplicationSetHasProgressiveStatus("progressive-sync-three-step", "three-step-dev", v1alpha1.ProgressiveSyncHealthy)). + When(). + Then(). + Expect(OnlyApplicationsExist([]v1alpha1.Application{appStep1, appStep2}, []v1alpha1.Application{appStep3})). + Expect(ApplicationSetHasProgressiveStatus("progressive-sync-three-step", "three-step-staging", v1alpha1.ProgressiveSyncHealthy)). + When(). + Then(). + Expect(ApplicationsExist([]v1alpha1.Application{appStep1, appStep2, appStep3})). + Expect(ApplicationSetHasProgressiveStatus("progressive-sync-three-step", "three-step-prod", v1alpha1.ProgressiveSyncHealthy)). + Expect(ApplicationSetHasConditions("progressive-sync-three-step", expectedConditionSuccess)) +} + +func TestProgressiveSyncWithMaxUpdatePercentage(t *testing.T) { + expectedConditionSuccess := []v1alpha1.ApplicationSetCondition{ + { + Type: v1alpha1.ApplicationSetConditionErrorOccurred, + Status: v1alpha1.ApplicationSetConditionStatusFalse, + Message: "All applications have been generated successfully", + Reason: v1alpha1.ApplicationSetReasonApplicationSetUpToDate, + }, + { + Type: v1alpha1.ApplicationSetConditionParametersGenerated, + Status: v1alpha1.ApplicationSetConditionStatusTrue, + Message: "Successfully generated parameters for all Applications", + Reason: v1alpha1.ApplicationSetReasonParametersGenerated, + }, + { + Type: v1alpha1.ApplicationSetConditionResourcesUpToDate, + Status: v1alpha1.ApplicationSetConditionStatusTrue, + Message: "All applications have been generated successfully", + Reason: v1alpha1.ApplicationSetReasonApplicationSetUpToDate, + }, + } + + app1 := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "max-update-percent-1", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + app2 := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "max-update-percent-2", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + app3 := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "max-update-percent-3", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + Given(t). + When(). + Create(v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "progressive-sync-max-update-percent", + Namespace: "argocd-e2e", + }, + Spec: v1alpha1.ApplicationSetSpec{ + GoTemplate: true, + Generators: []v1alpha1.ApplicationSetGenerator{ + { + List: &v1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + { + Raw: []byte(`{"name":"max-update-percent-1","env":"prod"}`), + }, + { + Raw: []byte(`{"name":"max-update-percent-2","env":"prod"}`), + }, + { + Raw: []byte(`{"name":"max-update-percent-3","env":"prod"}`), + }, + }, + }, + }, + }, + Strategy: &v1alpha1.ApplicationSetStrategy{ + Type: "RollingSync", + RollingSync: &v1alpha1.ApplicationSetRolloutStrategy{ + Steps: []v1alpha1.ApplicationSetRolloutStep{ + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"prod"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.String, StrVal: "50%"}, + }, + }, + }, + }, + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{ + Name: "{{.name}}", + Labels: map[string]string{ + "env": "{{.env}}", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + }, + }, + }). + Then(). + Expect(ApplicationsExist([]v1alpha1.Application{app1, app2, app3})). + Expect(ApplicationSetHasConditions("progressive-sync-max-update-percent", expectedConditionSuccess)) +} + +func TestProgressiveSyncDeletionAllAtOnce(t *testing.T) { + appDev := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "deletion-allatonce-dev", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + appProd := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "deletion-allatonce-prod", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + Given(t). + When(). + Create(v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "progressive-sync-deletion-allatonce", + Namespace: "argocd-e2e", + }, + Spec: v1alpha1.ApplicationSetSpec{ + GoTemplate: true, + Generators: []v1alpha1.ApplicationSetGenerator{ + { + List: &v1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + { + Raw: []byte(`{"name":"deletion-allatonce-dev","env":"dev"}`), + }, + { + Raw: []byte(`{"name":"deletion-allatonce-prod","env":"prod"}`), + }, + }, + }, + }, + }, + Strategy: &v1alpha1.ApplicationSetStrategy{ + Type: "RollingSync", + RollingSync: &v1alpha1.ApplicationSetRolloutStrategy{ + Steps: []v1alpha1.ApplicationSetRolloutStep{ + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"dev"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"prod"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + }, + }, + }, + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{ + Name: "{{.name}}", + Labels: map[string]string{ + "env": "{{.env}}", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + }, + }, + }). + Then(). + Expect(ApplicationsExist([]v1alpha1.Application{appDev, appProd})). + When(). + Delete(). + Then(). + Expect(ApplicationsDoNotExist([]v1alpha1.Application{appDev, appProd})) +} + +func TestProgressiveSyncDeletionReverse(t *testing.T) { + appDev := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "deletion-reverse-dev", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + appProd := v1alpha1.Application{ + TypeMeta: metav1.TypeMeta{ + Kind: "Application", + APIVersion: "argoproj.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "deletion-reverse-prod", + Namespace: "argocd", + Finalizers: []string{v1alpha1.ResourcesFinalizerName}, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + } + + Given(t). + When(). + Create(v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "progressive-sync-deletion-reverse", + Namespace: "argocd-e2e", + }, + Spec: v1alpha1.ApplicationSetSpec{ + GoTemplate: true, + Generators: []v1alpha1.ApplicationSetGenerator{ + { + List: &v1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + { + Raw: []byte(`{"name":"deletion-reverse-dev","env":"dev"}`), + }, + { + Raw: []byte(`{"name":"deletion-reverse-prod","env":"prod"}`), + }, + }, + }, + }, + }, + Strategy: &v1alpha1.ApplicationSetStrategy{ + Type: "RollingSync", + RollingSync: &v1alpha1.ApplicationSetRolloutStrategy{ + Steps: []v1alpha1.ApplicationSetRolloutStep{ + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"dev"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + { + MatchExpressions: []v1alpha1.ApplicationMatchExpression{ + { + Key: "env", + Operator: "In", + Values: []string{"prod"}, + }, + }, + MaxUpdate: &intstr.IntOrString{Type: intstr.Int, IntVal: 1}, + }, + }, + }, + }, + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{ + Name: "{{.name}}", + Labels: map[string]string{ + "env": "{{.env}}", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Project: "default", + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps.git", + TargetRevision: "HEAD", + Path: "guestbook", + }, + Destination: v1alpha1.ApplicationDestination{ + Server: "https://kubernetes.default.svc", + Namespace: "guestbook", + }, + }, + }, + }, + }). + Then(). + Expect(ApplicationsExist([]v1alpha1.Application{appDev, appProd})). + When(). + Delete(). + Then(). + Expect(ApplicationsDoNotExist([]v1alpha1.Application{appDev, appProd})) +} diff --git a/test/e2e/fixture/applicationsets/expectation.go b/test/e2e/fixture/applicationsets/expectation.go index 45b7a6591b6ce..daa6894540818 100644 --- a/test/e2e/fixture/applicationsets/expectation.go +++ b/test/e2e/fixture/applicationsets/expectation.go @@ -225,3 +225,49 @@ func appsAreEqual(one v1alpha1.Application, two v1alpha1.Application) bool { func conditionsAreEqual(one, two *[]v1alpha1.ApplicationSetCondition) bool { return reflect.DeepEqual(filterConditionFields(one), filterConditionFields(two)) } + +// ApplicationSetHasProgressiveStatus checks if an application in the ApplicationSet has a specific progressive sync status +func ApplicationSetHasProgressiveStatus(applicationSetName, appName string, expectedStatus v1alpha1.ProgressiveSyncStatusCode) Expectation { + return func(c *Consequences) (state, string) { + // retrieve the application set + foundApplicationSet := c.applicationSet(applicationSetName) + if foundApplicationSet == nil { + return pending, fmt.Sprintf("application set '%s' not found", applicationSetName) + } + + // find the application in the status + for _, appStatus := range foundApplicationSet.Status.ApplicationStatus { + if appStatus.Application == appName { + if appStatus.Status == expectedStatus { + return succeeded, fmt.Sprintf("application '%s' has status '%s'", appName, expectedStatus) + } + return pending, fmt.Sprintf("application '%s' has status '%s', expected '%s'", appName, appStatus.Status, expectedStatus) + } + } + + return pending, fmt.Sprintf("application '%s' not found in ApplicationSet status", appName) + } +} + +// OnlyApplicationsExist verifies that only the provided applications exist, and the notYetCreated applications do not exist +func OnlyApplicationsExist(expectedApps []v1alpha1.Application, notYetCreated []v1alpha1.Application) Expectation { + return func(c *Consequences) (state, string) { + // Verify expected apps exist + for _, expectedApp := range expectedApps { + foundApp := c.app(expectedApp.Name) + if foundApp == nil { + return pending, fmt.Sprintf("expected app '%s' does not exist", expectedApp.QualifiedName()) + } + } + + // Verify apps that shouldn't exist yet do not exist + for _, notCreatedApp := range notYetCreated { + foundApp := c.app(notCreatedApp.Name) + if foundApp != nil { + return pending, fmt.Sprintf("app '%s' exists but should not exist yet", notCreatedApp.QualifiedName()) + } + } + + return succeeded, "all expected apps exist and unexpected apps do not exist" + } +}