diff --git a/pkg/argoapplication/application_sets.go b/pkg/argoapplication/application_sets.go index 76f12bea..4667d73d 100644 --- a/pkg/argoapplication/application_sets.go +++ b/pkg/argoapplication/application_sets.go @@ -36,7 +36,7 @@ func ConvertAppSetsToAppsInBothBranches( baseTempFolder := fmt.Sprintf("%s/%s", tempFolder, git.Base) targetTempFolder := fmt.Sprintf("%s/%s", tempFolder, git.Target) - baseApps, err := processAppSets( + baseApps, _, err := processAppSets( argocd, baseApps, baseBranch, @@ -46,13 +46,12 @@ func ConvertAppSetsToAppsInBothBranches( repo, redirectRevisions, ) - if err != nil { log.Error().Str("branch", baseBranch.Name).Msg("❌ Failed to generate base apps") return nil, nil, time.Since(startTime), err } - targetApps, err = processAppSets( + targetApps, targetIgnoredApps, err := processAppSets( argocd, targetApps, targetBranch, @@ -67,6 +66,10 @@ func ConvertAppSetsToAppsInBothBranches( return nil, nil, time.Since(startTime), err } + // Filter out apps from base branch that are ignored on target branch. + // This prevents apps with argocd-diff-preview/ignore annotation from showing as "deleted". + baseApps = RemoveIgnoredApps(baseApps, targetIgnoredApps, baseBranch.Name) + return baseApps, targetApps, time.Since(startTime), nil } @@ -79,12 +82,12 @@ func processAppSets( filterOptions FilterOptions, repo string, redirectRevisions []string, -) ([]ArgoResource, error) { +) ([]ArgoResource, []IgnoredApp, error) { appSetTempFolder := fmt.Sprintf("%s/app-sets", tempFolder) if err := utils.CreateFolder(appSetTempFolder, true); err != nil { log.Error().Msgf("❌ Failed to create temp folder: %s", appSetTempFolder) - return nil, err + return nil, nil, err } apps, err := convertAppSetsToApps( @@ -96,19 +99,20 @@ func processAppSets( ) if err != nil { log.Error().Str("branch", branch.Name).Msg("❌ Failed to generate apps") - return nil, err + return nil, nil, err } if len(apps) == 0 { - return apps, nil + return apps, nil, nil } log.Info().Str("branch", branch.Name).Msgf("🤖 Filtering %d Applications", len(apps)) - apps = FilterAll(apps, filterOptions) + filterResult := FilterAll(apps, filterOptions) + apps = filterResult.Apps if len(apps) == 0 { log.Info().Str("branch", branch.Name).Msg("🤖 No applications left after filtering") - return apps, nil + return apps, filterResult.IgnoredApps, nil } log.Info().Str("branch", branch.Name).Msgf("🤖 Patching %d Applications", len(apps)) @@ -121,7 +125,7 @@ func processAppSets( ) if err != nil { log.Error().Str("branch", branch.Name).Msgf("❌ Failed to patch Applications on branch: %s", branch.Name) - return nil, err + return nil, nil, err } if debug { @@ -138,7 +142,7 @@ func processAppSets( } } - return apps, nil + return apps, filterResult.IgnoredApps, nil } func convertAppSetsToApps( diff --git a/pkg/argoapplication/applications.go b/pkg/argoapplication/applications.go index ad93c53c..20b01fd5 100644 --- a/pkg/argoapplication/applications.go +++ b/pkg/argoapplication/applications.go @@ -68,7 +68,9 @@ func (a *ArgoResource) WriteToFolder(folder string) (string, error) { return randomFileName, nil } -// GetApplicationsForBranches gets applications for both base and target branches +// GetApplicationsForBranches gets applications for both base and target branches. +// Apps that are ignored via the argocd-diff-preview/ignore annotation on the target branch +// will also be filtered out from the base branch to avoid showing them as "deleted". func GetApplicationsForBranches( argocdNamespace string, baseBranch *git.Branch, @@ -77,7 +79,7 @@ func GetApplicationsForBranches( repo string, redirectRevisions []string, ) ([]ArgoResource, []ArgoResource, error) { - baseApps, err := getApplications( + baseApps, _, err := getApplications( argocdNamespace, baseBranch, filterOptions, @@ -88,7 +90,7 @@ func GetApplicationsForBranches( return nil, nil, err } - targetApps, err := getApplications( + targetApps, targetIgnoredApps, err := getApplications( argocdNamespace, targetBranch, filterOptions, @@ -99,17 +101,23 @@ func GetApplicationsForBranches( return nil, nil, err } + // Filter out apps from base branch that are ignored on target branch. + // This prevents apps with argocd-diff-preview/ignore annotation from showing as "deleted". + baseApps = RemoveIgnoredApps(baseApps, targetIgnoredApps, baseBranch.Name) + return baseApps, targetApps, nil } -// getApplications gets applications for a single branch +// getApplications gets applications for a single branch. +// Returns (apps, ignoredApps, error) where ignoredApps contains the apps +// that were filtered out due to the argocd-diff-preview/ignore annotation. func getApplications( argocdNamespace string, branch *git.Branch, filterOptions FilterOptions, repo string, redirectRevisions []string, -) ([]ArgoResource, error) { +) ([]ArgoResource, []IgnoredApp, error) { log.Info().Str("branch", branch.Name).Msg("🤖 Fetching all files for branch") yamlFiles := fileparsing.GetYamlFiles(branch.FolderName(), filterOptions.FileRegex) @@ -121,33 +129,33 @@ func getApplications( applications := FromResourceToApplication(k8sResources) if len(applications) == 0 { - return []ArgoResource{}, nil + return []ArgoResource{}, nil, nil } // filter applications log.Info().Str("branch", branch.Name).Msgf("🤖 Filtering %d Application[Sets]", len(applications)) - applications = FilterAllWithLogging(applications, filterOptions, branch) + filterResult := FilterAllWithLogging(applications, filterOptions, branch) - if len(applications) == 0 { - return []ArgoResource{}, nil + if len(filterResult.Apps) == 0 { + return []ArgoResource{}, filterResult.IgnoredApps, nil } - log.Info().Str("branch", branch.Name).Msgf("🤖 Patching %d Application[Sets]", len(applications)) + log.Info().Str("branch", branch.Name).Msgf("🤖 Patching %d Application[Sets]", len(filterResult.Apps)) - applications, err := patchApplications( + patchedApps, err := patchApplications( argocdNamespace, - applications, + filterResult.Apps, branch, repo, redirectRevisions, ) if err != nil { - return nil, err + return nil, nil, err } - log.Debug().Str("branch", branch.Name).Msgf("Patched %d Application[Sets]", len(applications)) + log.Debug().Str("branch", branch.Name).Msgf("Patched %d Application[Sets]", len(patchedApps)) - return applications, nil + return patchedApps, filterResult.IgnoredApps, nil } // PatchApplication patches a single ArgoResource diff --git a/pkg/argoapplication/filter.go b/pkg/argoapplication/filter.go index 40343894..6ca67494 100644 --- a/pkg/argoapplication/filter.go +++ b/pkg/argoapplication/filter.go @@ -28,7 +28,47 @@ type FilterOptions struct { WatchIfNoWatchPatternFound bool } -func FilterAllWithLogging(apps []ArgoResource, filterOptions FilterOptions, branch *git.Branch) []ArgoResource { +// IgnoredApp represents an app that was ignored via annotation, tracking both ID and source file +type IgnoredApp struct { + Id string + FileName string +} + +// FilterResult contains the filtered apps and any apps that were ignored via annotation +type FilterResult struct { + Apps []ArgoResource + IgnoredApps []IgnoredApp // Apps filtered out due to argocd-diff-preview/ignore annotation +} + +// RemoveIgnoredApps filters out apps from baseApps that match any app in ignoredApps. +// Matching is done by both ID and FileName to avoid filtering apps with same name from different sources. +func RemoveIgnoredApps(baseApps []ArgoResource, ignoredApps []IgnoredApp, branchName string) []ArgoResource { + if len(ignoredApps) == 0 { + return baseApps + } + + ignoredSet := make(map[string]struct{}, len(ignoredApps)) + for _, ignored := range ignoredApps { + key := fmt.Sprintf("%s|%s", ignored.Id, ignored.FileName) + ignoredSet[key] = struct{}{} + } + + var filtered []ArgoResource + for _, app := range baseApps { + key := fmt.Sprintf("%s|%s", app.Id, app.FileName) + if _, exists := ignoredSet[key]; !exists { + filtered = append(filtered, app) + } else { + log.Debug().Str("branch", branchName).Msgf( + "Skipping %s '%s' because it is ignored on target branch", + app.Kind.ShortName(), app.GetLongName(), + ) + } + } + return filtered +} + +func FilterAllWithLogging(apps []ArgoResource, filterOptions FilterOptions, branch *git.Branch) FilterResult { // Log selector and files changed info switch { case len(filterOptions.Selector) > 0 && len(filterOptions.FilesChanged) > 0: @@ -60,17 +100,17 @@ func FilterAllWithLogging(apps []ArgoResource, filterOptions FilterOptions, bran numberOfAppsBeforeFiltering := len(apps) // Filter applications - filteredApps := FilterAll(apps, filterOptions) + result := FilterAll(apps, filterOptions) // Log filtering results - if numberOfAppsBeforeFiltering != len(filteredApps) { + if numberOfAppsBeforeFiltering != len(result.Apps) { log.Info().Str("branch", branch.Name).Msgf( "🤖 Found %d Application[Sets] before filtering", numberOfAppsBeforeFiltering, ) log.Info().Str("branch", branch.Name).Msgf( "🤖 Found %d Application[Sets] after filtering", - len(filteredApps), + len(result.Apps), ) } else { log.Info().Str("branch", branch.Name).Msgf( @@ -79,32 +119,41 @@ func FilterAllWithLogging(apps []ArgoResource, filterOptions FilterOptions, bran ) } - return filteredApps + return result } func FilterAll( apps []ArgoResource, filterOptions FilterOptions, -) []ArgoResource { +) FilterResult { var filteredApps []ArgoResource + var ignoredApps []IgnoredApp for _, app := range apps { - if app.Filter(filterOptions) { + selected, ignoredByAnnotation := app.Filter(filterOptions) + if selected { filteredApps = append(filteredApps, app) + } else if ignoredByAnnotation { + ignoredApps = append(ignoredApps, IgnoredApp{Id: app.Id, FileName: app.FileName}) } } - return filteredApps + return FilterResult{ + Apps: filteredApps, + IgnoredApps: ignoredApps, + } } -// Filter checks if the application matches the given selectors and watches the given files +// Filter checks if the application matches the given selectors and watches the given files. +// Returns (selected bool, ignoredByAnnotation bool) where ignoredByAnnotation is true +// if the app was filtered out specifically due to the argocd-diff-preview/ignore annotation. func (a *ArgoResource) Filter( filterOptions FilterOptions, -) bool { +) (bool, bool) { // First check selected annotation selected, reason := a.filterByIgnoreAnnotation() if !selected { log.Debug().Str(a.Kind.ShortName(), a.GetLongName()).Msgf("%s is not selected because: %s", a.Kind.ShortName(), reason) - return false + return false, true // ignoredByAnnotation = true } // Then check selectors @@ -112,7 +161,7 @@ func (a *ArgoResource) Filter( selected, reason := a.filterBySelectors(filterOptions.Selector) if !selected { log.Debug().Str(a.Kind.ShortName(), a.GetLongName()).Msgf("%s is not selected because: %s", a.Kind.ShortName(), reason) - return false + return false, false } } @@ -121,17 +170,15 @@ func (a *ArgoResource) Filter( selected, reason := a.filterByFilesChanged(filterOptions.FilesChanged, filterOptions.IgnoreInvalidWatchPattern, filterOptions.WatchIfNoWatchPatternFound) if !selected { log.Debug().Str(a.Kind.ShortName(), a.GetLongName()).Msgf("%s is not selected because: %s", a.Kind.ShortName(), reason) - return false + return false, false } log.Debug().Str(a.Kind.ShortName(), a.GetLongName()).Msgf("%s is selected because: %s", a.Kind.ShortName(), reason) } - return true + return true, false } func (a *ArgoResource) filterByIgnoreAnnotation() (bool, string) { - - // get annotations annotations, found, err := unstructured.NestedStringMap(a.Yaml.Object, "metadata", "annotations") if err != nil || !found || len(annotations) == 0 { return true, "no 'argocd-diff-preview/ignore' annotation found" diff --git a/pkg/argoapplication/filter_test.go b/pkg/argoapplication/filter_test.go index 17f6b5e1..150996cc 100644 --- a/pkg/argoapplication/filter_test.go +++ b/pkg/argoapplication/filter_test.go @@ -977,7 +977,7 @@ metadata: } // Run filter - got := app.Filter(FilterOptions{ + got, _ := app.Filter(FilterOptions{ Selector: tt.selectors, FilesChanged: tt.filesChanged, IgnoreInvalidWatchPattern: tt.ignoreInvalidWatchPattern, @@ -1097,3 +1097,143 @@ spec: } return &node } + +// TestFilterReturnsIgnoredByAnnotation tests that Filter returns ignoredByAnnotation = true +// when an app is filtered out due to the argocd-diff-preview/ignore annotation +func TestFilterReturnsIgnoredByAnnotation(t *testing.T) { + zerolog.SetGlobalLevel(zerolog.FatalLevel) + + tests := []struct { + name string + annotations map[string]any + labels map[string]any + selectors []selector.Selector + wantSelected bool + wantIgnoredByAnnotation bool + }{ + { + name: "app with ignore annotation returns ignoredByAnnotation=true", + annotations: map[string]any{ + "argocd-diff-preview/ignore": "true", + }, + labels: map[string]any{}, + selectors: []selector.Selector{}, + wantSelected: false, + wantIgnoredByAnnotation: true, + }, + { + name: "app without ignore annotation returns ignoredByAnnotation=false", + annotations: map[string]any{}, + labels: map[string]any{"app": "test"}, + selectors: []selector.Selector{}, + wantSelected: true, + wantIgnoredByAnnotation: false, + }, + { + name: "app filtered by selector returns ignoredByAnnotation=false", + annotations: map[string]any{}, + labels: map[string]any{"app": "test"}, + selectors: []selector.Selector{{Key: "app", Operator: selector.Eq, Value: "other"}}, + wantSelected: false, + wantIgnoredByAnnotation: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resource := &ArgoResource{ + Id: "test-app", + Kind: Application, + Yaml: &unstructured.Unstructured{ + Object: map[string]any{ + "metadata": map[string]any{ + "annotations": tt.annotations, + "labels": tt.labels, + }, + }, + }, + } + + gotSelected, gotIgnoredByAnnotation := resource.Filter(FilterOptions{ + Selector: tt.selectors, + }) + assert.Equal(t, tt.wantSelected, gotSelected, "selected mismatch") + assert.Equal(t, tt.wantIgnoredByAnnotation, gotIgnoredByAnnotation, "ignoredByAnnotation mismatch") + }) + } +} + +// TestFilterAllReturnsIgnoredIDs tests that FilterAll returns the IDs of apps +// that were filtered out due to the argocd-diff-preview/ignore annotation +func TestFilterAllReturnsIgnoredIDs(t *testing.T) { + zerolog.SetGlobalLevel(zerolog.FatalLevel) + + apps := []ArgoResource{ + { + Id: "app-1", + Kind: Application, + Yaml: &unstructured.Unstructured{ + Object: map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{"app": "test"}, + }, + }, + }, + }, + { + Id: "app-2-ignored", + Kind: Application, + Yaml: &unstructured.Unstructured{ + Object: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "argocd-diff-preview/ignore": "true", + }, + "labels": map[string]any{"app": "test"}, + }, + }, + }, + }, + { + Id: "app-3", + Kind: Application, + Yaml: &unstructured.Unstructured{ + Object: map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{"app": "test"}, + }, + }, + }, + }, + { + Id: "app-4-ignored", + Kind: Application, + Yaml: &unstructured.Unstructured{ + Object: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "argocd-diff-preview/ignore": "true", + }, + "labels": map[string]any{"app": "test"}, + }, + }, + }, + }, + } + + result := FilterAll(apps, FilterOptions{}) + + // Should have 2 apps (app-1 and app-3) + assert.Len(t, result.Apps, 2) + assert.Equal(t, "app-1", result.Apps[0].Id) + assert.Equal(t, "app-3", result.Apps[1].Id) + + // Should have 2 ignored apps (app-2-ignored and app-4-ignored) + assert.Len(t, result.IgnoredApps, 2) + ignoredIDs := make([]string, len(result.IgnoredApps)) + for i, ignored := range result.IgnoredApps { + ignoredIDs[i] = ignored.Id + } + assert.Contains(t, ignoredIDs, "app-2-ignored") + assert.Contains(t, ignoredIDs, "app-4-ignored") +}