diff --git a/cmd/argocd/commands/app.go b/cmd/argocd/commands/app.go index 3ce81351b33a1..3e86d227b1e58 100644 --- a/cmd/argocd/commands/app.go +++ b/cmd/argocd/commands/app.go @@ -1653,6 +1653,8 @@ func NewApplicationListCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co repo string appNamespace string cluster string + path string + files []string ) command := &cobra.Command{ Use: "list", @@ -1688,6 +1690,12 @@ func NewApplicationListCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co if cluster != "" { appList = argo.FilterByCluster(appList, cluster) } + if path != "" { + appList = argo.FilterByPath(appList, path) + } + if len(files) != 0 { + appList = argo.FilterByFiles(appList, files) + } switch output { case "yaml", "json": @@ -1708,6 +1716,8 @@ func NewApplicationListCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co command.Flags().StringVarP(&repo, "repo", "r", "", "List apps by source repo URL") command.Flags().StringVarP(&appNamespace, "app-namespace", "N", "", "Only list applications in namespace") command.Flags().StringVarP(&cluster, "cluster", "c", "", "List apps by cluster name or url") + command.Flags().StringVarP(&path, "path", "P", "", "Filter applications by source path") + command.Flags().StringArrayVarP(&files, "file", "f", []string{}, "Filter applications by affected files") return command } diff --git a/docs/user-guide/commands/argocd_app_list.md b/docs/user-guide/commands/argocd_app_list.md index 9fb2b8dc32b88..6de4500a9aaba 100644 --- a/docs/user-guide/commands/argocd_app_list.md +++ b/docs/user-guide/commands/argocd_app_list.md @@ -27,8 +27,10 @@ argocd app list [flags] ``` -N, --app-namespace string Only list applications in namespace -c, --cluster string List apps by cluster name or url + -f, --file stringArray Filter applications by affected files -h, --help help for list -o, --output string Output format. One of: wide|name|json|yaml (default "wide") + -P, --path string Filter applications by source path -p, --project stringArray Filter by project name -r, --repo string List apps by source repo URL -l, --selector string List apps by label. Supports '=', '==', '!=', in, notin, exists & not exists. Matching apps must satisfy all of the specified label constraints. diff --git a/util/argo/argo.go b/util/argo/argo.go index 37b5e40331ed3..8d08a7c490387 100644 --- a/util/argo/argo.go +++ b/util/argo/argo.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "path/filepath" "regexp" "sort" "strings" @@ -115,6 +116,71 @@ func FilterByProjects(apps []argoappv1.Application, projects []string) []argoapp return items } +func FilterByPath(apps []argoappv1.Application, path string) []argoappv1.Application { + if path == "" { + return apps + } + + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + absPath = filepath.ToSlash(filepath.Clean(absPath)) + relPath := filepath.ToSlash(filepath.Clean(path)) + + items := make([]argoappv1.Application, 0) + + for _, app := range apps { + if app.Spec.Source != nil { + appPath := filepath.ToSlash(filepath.Clean(app.Spec.Source.Path)) + if appPath == absPath || appPath == relPath { + items = append(items, app) + continue + } + } + + if app.Spec.Sources != nil { + for _, source := range app.Spec.Sources { + appPath := filepath.ToSlash(filepath.Clean(source.Path)) + if appPath == absPath || appPath == relPath { + items = append(items, app) + break + } + } + } + } + + return items +} + + +func FilterByFiles(apps []argoappv1.Application, files []string) []argoappv1.Application { + fileSet := make(map[string]bool) + for _, file := range files { + fileSet[file] = true + } + var filteredApps []argoappv1.Application + for _, app := range apps { + annotationPaths := strings.Split(app.Annotations["argocd.argoproj.io/manifest-generate-paths"], ";") + matches := false + for _, annotationPath := range annotationPaths { + for file := range fileSet { + if strings.HasPrefix(file, annotationPath) { + matches = true + break + } + } + if matches { + break + } + } + if matches { + filteredApps = append(filteredApps, app) + } + } + return filteredApps +} + // FilterByProjectsP returns application pointers which belongs to the specified project func FilterByProjectsP(apps []*argoappv1.Application, projects []string) []*argoappv1.Application { if len(projects) == 0 { diff --git a/util/argo/argo_test.go b/util/argo/argo_test.go index e578f3544cbd2..4a71ecda9a196 100644 --- a/util/argo/argo_test.go +++ b/util/argo/argo_test.go @@ -628,6 +628,146 @@ func TestFilterByRepoP(t *testing.T) { }) } +func TestFilterByPath(t *testing.T) { + apps := []argoappv1.Application{ + { + Spec: argoappv1.ApplicationSpec{ + Source: &argoappv1.ApplicationSource{ + Path: "example/apps/foo/chart", + }, + }, + }, + { + Spec: argoappv1.ApplicationSpec{ + Source: &argoappv1.ApplicationSource{ + Path: "example/apps/foo/chart2", + }, + }, + }, + { + Spec: argoappv1.ApplicationSpec{ + Sources: argoappv1.ApplicationSources{ + {Path: "example/apps/multi/source1"}, + {Path: "example/apps/multi/source2"}, + }, + }, + }, + { + Spec: argoappv1.ApplicationSpec{ + Source: &argoappv1.ApplicationSource{ + Path: "example/apps/bar/chart", + }, + }, + }, + } + + t.Run("Empty path returns all apps", func(t *testing.T) { + res := FilterByPath(apps, "") + assert.Equal(t, len(apps), len(res)) + }) + + t.Run("No apps for matching path", func(t *testing.T) { + res := FilterByPath(apps, "example/apps/baz") + assert.Empty(t, res) + }) + + t.Run("Exact path match - single source", func(t *testing.T) { + res := FilterByPath(apps, "example/apps/foo/chart") + assert.Len(t, res, 1) + assert.Equal(t, filepath.ToSlash(filepath.Clean("example/apps/foo/chart")), res[0].Spec.Source.Path) + }) + + t.Run("Multiple sources - first source path match", func(t *testing.T) { + res := FilterByPath(apps, "example/apps/multi/source1") + assert.Len(t, res, 1) + assert.Equal(t, "example/apps/multi/source1", res[0].Spec.Sources[0].Path) + }) + + t.Run("Multiple sources - second source path match", func(t *testing.T) { + res := FilterByPath(apps, "example/apps/multi/source2") + assert.Len(t, res, 1) + assert.Equal(t, "example/apps/multi/source2", res[0].Spec.Sources[1].Path) + }) + + t.Run("Relative path match", func(t *testing.T) { + res := FilterByPath(apps, "./example/apps/foo/chart") + assert.Len(t, res, 1) + assert.Equal(t, "example/apps/foo/chart", res[0].Spec.Source.Path) + }) + +} + +func TestFilterByFiles(t *testing.T) { + apps := []argoappv1.Application{ + { + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "argocd.argoproj.io/manifest-generate-paths": "example/apps/foo;example/apps/bar", + }, + }, + Spec: argoappv1.ApplicationSpec{ + Source: &argoappv1.ApplicationSource{ + Path: "example/apps/foo", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "argocd.argoproj.io/manifest-generate-paths": "example/apps/foo", + }, + }, + Spec: argoappv1.ApplicationSpec{ + Source: &argoappv1.ApplicationSource{ + Path: "example/apps/foo", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "argocd.argoproj.io/manifest-generate-paths": "example/apps/baz", + }, + }, + Spec: argoappv1.ApplicationSpec{ + Source: &argoappv1.ApplicationSource{ + Path: "example/apps/baz", + }, + }, + }, + } + + t.Run("No files match", func(t *testing.T) { + files := []string{"example/apps/nonexistent/file.yaml"} + res := FilterByFiles(apps, files) + assert.Empty(t, res) + }) + + t.Run("One file matches one app", func(t *testing.T) { + files := []string{"example/apps/foo/file.yaml"} + res := FilterByFiles(apps, files) + assert.Len(t, res, 2) + }) + + t.Run("Multiple files match", func(t *testing.T) { + files := []string{"example/apps/foo/file.yaml", "example/apps/bar/file.yaml"} + res := FilterByFiles(apps, files) + assert.Len(t, res, 2) + }) + + t.Run("Some files match", func(t *testing.T) { + files := []string{"example/apps/bar/file.yaml", "example/apps/baz/file.yaml"} + res := FilterByFiles(apps, files) + assert.Len(t, res, 2) + }) + + t.Run("Multiple annotation paths", func(t *testing.T) { + files := []string{"example/apps/foo/file.yaml", "example/apps/bar/file.yaml"} + res := FilterByFiles(apps, files) + assert.Len(t, res, 2) + }) +} + func TestValidatePermissions(t *testing.T) { t.Run("Empty Repo URL result in condition", func(t *testing.T) { spec := argoappv1.ApplicationSpec{