diff --git a/pkg/extensions/search/resolver.go b/pkg/extensions/search/resolver.go index 0534cefd..beae8800 100644 --- a/pkg/extensions/search/resolver.go +++ b/pkg/extensions/search/resolver.go @@ -694,6 +694,30 @@ func globalSearch(ctx context.Context, query string, metaDB mTypes.MetaDB, filte images = imageSummaries + paginatedRepos.Page = &gql_generated.PageInfo{ + TotalCount: pageInfo.TotalCount, + ItemCount: pageInfo.ItemCount, + } + case TagTarget: + skip := convert.SkipQGLField{Vulnerabilities: canSkipField(preloads, "Images.Vulnerabilities")} + pageInput := getPageInput(requestedPage) + + expectedTag := strings.TrimPrefix(query, `:`) + matchTagName := func(repoName, actualTag string) bool { return strings.Contains(actualTag, expectedTag) } + + fullImageMetaList, err := metaDB.FilterTags(ctx, matchTagName, mTypes.AcceptAllImageMeta) + if err != nil { + return &gql_generated.PaginatedReposResult{}, []*gql_generated.ImageSummary{}, []*gql_generated.LayerSummary{}, err + } + + imageSummaries, pageInfo, err := convert.PaginatedFullImageMeta2ImageSummaries(ctx, fullImageMetaList, skip, cveInfo, + localFilter, pageInput) + if err != nil { + return &gql_generated.PaginatedReposResult{}, []*gql_generated.ImageSummary{}, []*gql_generated.LayerSummary{}, err + } + + images = imageSummaries + paginatedRepos.Page = &gql_generated.PageInfo{ TotalCount: pageInfo.TotalCount, ItemCount: pageInfo.ItemCount, @@ -745,6 +769,7 @@ const ( ImageTarget DigestTarget InvalidTarget + TagTarget ) func getSearchTarget(query string) SearchTarget { @@ -756,8 +781,12 @@ func getSearchTarget(query string) SearchTarget { return DigestTarget } - if before, _, found := strings.Cut(query, ":"); found && before != "" { - return ImageTarget + if before, after, found := strings.Cut(query, ":"); found { + if before != "" { + return ImageTarget + } else if after != "" { + return TagTarget + } } return InvalidTarget diff --git a/pkg/extensions/search/resolver_test.go b/pkg/extensions/search/resolver_test.go index bc9b4379..e264d3b5 100644 --- a/pkg/extensions/search/resolver_test.go +++ b/pkg/extensions/search/resolver_test.go @@ -122,9 +122,42 @@ func TestResolverGlobalSearch(t *testing.T) { So(repos.Results, ShouldBeEmpty) }) - Convey("Searching with a bad query", func() { + Convey("Searching by tag returns a filter error", func() { ctx := context.Background() query := ":test" + mockMetaDB := mocks.MetaDBMock{ + FilterTagsFn: func(ctx context.Context, filterRepoTag mTypes.FilterRepoTagFunc, + filterFunc mTypes.FilterFunc, + ) ([]mTypes.FullImageMeta, error) { + return []mTypes.FullImageMeta{}, ErrTestError + }, + } + + responseContext := graphql.WithResponseContext(ctx, graphql.DefaultErrorPresenter, graphql.DefaultRecover) + repos, images, layers, err := globalSearch(responseContext, query, mockMetaDB, &gql_generated.Filter{}, + &gql_generated.PageInput{}, mocks.CveInfoMock{}, log.NewLogger("debug", "")) + So(err, ShouldNotBeNil) + So(images, ShouldBeEmpty) + So(layers, ShouldBeEmpty) + So(repos.Results, ShouldBeEmpty) + }) + + Convey("Searching by tag returns a pagination error", func() { + ctx := context.Background() + query := ":test" + + responseContext := graphql.WithResponseContext(ctx, graphql.DefaultErrorPresenter, graphql.DefaultRecover) + repos, images, layers, err := globalSearch(responseContext, query, mocks.MetaDBMock{}, &gql_generated.Filter{}, + &gql_generated.PageInput{Limit: ref(-10)}, mocks.CveInfoMock{}, log.NewLogger("debug", "")) + So(err, ShouldNotBeNil) + So(images, ShouldBeEmpty) + So(layers, ShouldBeEmpty) + So(repos.Results, ShouldBeEmpty) + }) + + Convey("Searching with a bad query", func() { + ctx := context.Background() + query := ":" responseContext := graphql.WithResponseContext(ctx, graphql.DefaultErrorPresenter, graphql.DefaultRecover) repos, images, layers, err := globalSearch(responseContext, query, mocks.MetaDBMock{}, &gql_generated.Filter{}, diff --git a/pkg/extensions/search/search_test.go b/pkg/extensions/search/search_test.go index 078d0628..85f3ef12 100644 --- a/pkg/extensions/search/search_test.go +++ b/pkg/extensions/search/search_test.go @@ -3729,6 +3729,143 @@ func TestGlobalSearch(t *testing.T) { So(results.Images[0].Manifests[0].Digest, ShouldResemble, multiArch.Images[0].DigestStr()) So(results.Images[0].RepoName, ShouldResemble, "repo1") }) + + Convey("global searching by tag cross repo", t, func() { + log := log.NewLogger("debug", "") + rootDir := t.TempDir() + port := GetFreePort() + baseURL := GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = rootDir + defaultVal := true + conf.Extensions = &extconf.ExtensionConfig{ + Search: &extconf.SearchConfig{BaseConfig: extconf.BaseConfig{Enable: &defaultVal}}, + } + conf.Extensions.Search.CVE = nil + + ctlr := api.NewController(conf) + ctlrManager := NewControllerManager(ctlr) + + storeCtlr := ociutils.GetDefaultStoreController(rootDir, log) + + image11 := CreateRandomImage() + image12 := CreateRandomImage() + err := WriteImageToFileSystem(image11, "repo1", "tag1", storeCtlr) + So(err, ShouldBeNil) + err = WriteImageToFileSystem(image12, "repo1", "tag2", storeCtlr) + So(err, ShouldBeNil) + + image21 := CreateRandomImage() + image22 := CreateRandomImage() + multiArch2 := CreateRandomMultiarch() + err = WriteImageToFileSystem(image21, "repo2", "tag1", storeCtlr) + So(err, ShouldBeNil) + err = WriteImageToFileSystem(image22, "repo2", "tag2", storeCtlr) + So(err, ShouldBeNil) + err = WriteMultiArchImageToFileSystem(multiArch2, "repo2", "tag-multi", storeCtlr) + So(err, ShouldBeNil) + + image31 := CreateRandomImage() + image32 := CreateRandomImage() + err = WriteImageToFileSystem(image31, "repo3", "tag1", storeCtlr) + So(err, ShouldBeNil) + err = WriteImageToFileSystem(image32, "repo3", "tag2", storeCtlr) + So(err, ShouldBeNil) + + image41 := CreateRandomImage() + image42 := CreateRandomImage() + multiArch4 := CreateRandomMultiarch() + err = WriteImageToFileSystem(image41, "repo4", "tag1", storeCtlr) + So(err, ShouldBeNil) + err = WriteImageToFileSystem(image42, "repo4", "tag2", storeCtlr) + So(err, ShouldBeNil) + err = WriteMultiArchImageToFileSystem(multiArch4, "repo4", "tag-multi", storeCtlr) + So(err, ShouldBeNil) + + image51 := CreateRandomImage() + err = WriteImageToFileSystem(image51, "repo5", "tag1", storeCtlr) + So(err, ShouldBeNil) + + multiArch62 := CreateRandomMultiarch() + err = WriteMultiArchImageToFileSystem(multiArch62, "repo6", "tag2", storeCtlr) + So(err, ShouldBeNil) + + ctlrManager.StartAndWait(port) + defer ctlrManager.StopServer() + + // Search for a specific tag cross-repo and return single arch images + results := GlobalSearchGQL(":tag1", baseURL).GlobalSearch + So(len(results.Images), ShouldEqual, 5) + So(len(results.Repos), ShouldEqual, 0) + + expectedRepos := []string{"repo1", "repo2", "repo3", "repo4", "repo5"} + for _, image := range results.Images { + So(image.Tag, ShouldEqual, "tag1") + So(image.RepoName, ShouldBeIn, expectedRepos) + So(len(image.Manifests), ShouldEqual, 1) + } + + // Search for a specific tag cross-repo and return multi arch images + results = GlobalSearchGQL(":tag-multi", baseURL).GlobalSearch + So(len(results.Images), ShouldEqual, 2) + So(len(results.Repos), ShouldEqual, 0) + + expectedRepos = []string{"repo2", "repo4"} + for _, image := range results.Images { + So(image.Tag, ShouldEqual, "tag-multi") + So(image.RepoName, ShouldBeIn, expectedRepos) + So(len(image.Manifests), ShouldEqual, 3) + } + + // Search for a specific tag cross-repo and return mixed single and multiarch images + results = GlobalSearchGQL(":tag2", baseURL).GlobalSearch + So(len(results.Images), ShouldEqual, 5) + So(len(results.Repos), ShouldEqual, 0) + + expectedRepos = []string{"repo1", "repo2", "repo3", "repo4", "repo6"} + for _, image := range results.Images { + So(image.Tag, ShouldEqual, "tag2") + So(image.RepoName, ShouldBeIn, expectedRepos) + if image.RepoName == "repo6" { + So(len(image.Manifests), ShouldEqual, 3) + } else { + So(len(image.Manifests), ShouldEqual, 1) + } + } + + // Search for multiple tags using a partial match cross-repo and return multiarch images + results = GlobalSearchGQL(":multi", baseURL).GlobalSearch + So(len(results.Images), ShouldEqual, 2) + So(len(results.Repos), ShouldEqual, 0) + + expectedRepos = []string{"repo2", "repo4"} + for _, image := range results.Images { + So(image.Tag, ShouldContainSubstring, "multi") + So(image.RepoName, ShouldBeIn, expectedRepos) + So(len(image.Manifests), ShouldEqual, 3) + } + + // Search for multiple tags using a partial match cross-repo and return mixed single and multiarch images + results = GlobalSearchGQL(":tag", baseURL).GlobalSearch + So(len(results.Images), ShouldEqual, 12) + So(len(results.Repos), ShouldEqual, 0) + + expectedRepos = []string{"repo1", "repo2", "repo3", "repo4", "repo5", "repo6"} + for _, image := range results.Images { + So(image.Tag, ShouldContainSubstring, "tag") + So(image.RepoName, ShouldBeIn, expectedRepos) + } + + // Search for a specific tag cross-repo and return mixt single and multiarch images + result := GlobalSearchGQL(":", baseURL) + errors := result.Errors + So(len(errors), ShouldEqual, 1) + + results = result.GlobalSearch + So(len(results.Images), ShouldEqual, 0) + So(len(results.Repos), ShouldEqual, 0) + }) } func TestCleaningFilteringParamsGlobalSearch(t *testing.T) {