diff --git a/pkg/extensions/search/common/common_test.go b/pkg/extensions/search/common/common_test.go index 88ee3d78..e26d865d 100644 --- a/pkg/extensions/search/common/common_test.go +++ b/pkg/extensions/search/common/common_test.go @@ -87,15 +87,17 @@ type ImageSummary struct { Platform OsArch `json:"platform"` Vendor string `json:"vendor"` Score int `json:"score"` + IsSigned bool `json:"isSigned"` } type RepoSummary struct { - Name string `json:"name"` - LastUpdated time.Time `json:"lastUpdated"` - Size string `json:"size"` - Platforms []OsArch `json:"platforms"` - Vendors []string `json:"vendors"` - Score int `json:"score"` + Name string `json:"name"` + LastUpdated time.Time `json:"lastUpdated"` + Size string `json:"size"` + Platforms []OsArch `json:"platforms"` + Vendors []string `json:"vendors"` + Score int `json:"score"` + NewestTag ImageSummary `json:"newestTag"` } type LayerSummary struct { @@ -797,18 +799,37 @@ func TestGlobalSearch(t *testing.T) { Tag LastUpdated Size + IsSigned + Vendor Score + Platform { + Os + Arch + } } Repos { Name LastUpdated Size Platforms { - Os - Arch + Os + Arch } Vendors Score + NewestTag { + RepoName + Tag + LastUpdated + Size + IsSigned + Vendor + Score + Platform { + Os + Arch + } + } } Layers { Digest @@ -826,11 +847,65 @@ func TestGlobalSearch(t *testing.T) { err = json.Unmarshal(resp.Body(), responseStruct) So(err, ShouldBeNil) + // There are 2 repos: zot-cve-test and zot-test, each having an image with tag 0.0.1 + imageStore := ctlr.StoreController.DefaultStore + + repos, err := imageStore.GetRepositories() + So(err, ShouldBeNil) + expectedRepoCount := len(repos) + + allExpectedTagMap := make(map[string][]string, expectedRepoCount) + expectedImageCount := 0 + for _, repo := range repos { + tags, err := imageStore.GetImageTags(repo) + So(err, ShouldBeNil) + + allExpectedTagMap[repo] = tags + expectedImageCount += len(tags) + } + + // Make sure the repo/image counts match before comparing actual content So(responseStruct.GlobalSearchResult.GlobalSearch.Images, ShouldNotBeNil) - So(len(responseStruct.GlobalSearchResult.GlobalSearch.Images), ShouldNotBeEmpty) - So(len(responseStruct.GlobalSearchResult.GlobalSearch.Repos), ShouldNotBeEmpty) + t.Logf("returned images: %v", responseStruct.GlobalSearchResult.GlobalSearch.Images) + So(len(responseStruct.GlobalSearchResult.GlobalSearch.Images), ShouldEqual, expectedImageCount) + t.Logf("returned repos: %v", responseStruct.GlobalSearchResult.GlobalSearch.Repos) + So(len(responseStruct.GlobalSearchResult.GlobalSearch.Repos), ShouldEqual, expectedRepoCount) + t.Logf("returned layers: %v", responseStruct.GlobalSearchResult.GlobalSearch.Layers) So(len(responseStruct.GlobalSearchResult.GlobalSearch.Layers), ShouldNotBeEmpty) + newestImageMap := make(map[string]ImageSummary) + for _, image := range responseStruct.GlobalSearchResult.GlobalSearch.Images { + // Make sure all returned results are supposed to be in the repo + So(allExpectedTagMap[image.RepoName], ShouldContain, image.Tag) + // Identify the newest image in each repo + if newestImage, ok := newestImageMap[image.RepoName]; ok { + if newestImage.LastUpdated.Before(image.LastUpdated) { + newestImageMap[image.RepoName] = image + } + } else { + newestImageMap[image.RepoName] = image + } + } + t.Logf("expected results for newest images in repos: %v", newestImageMap) + + for _, repo := range responseStruct.GlobalSearchResult.GlobalSearch.Repos { + image := newestImageMap[repo.Name] + So(repo.Name, ShouldEqual, image.RepoName) + So(repo.LastUpdated, ShouldEqual, image.LastUpdated) + So(repo.Size, ShouldEqual, image.Size) + So(repo.Vendors[0], ShouldEqual, image.Vendor) + So(repo.Platforms[0].Os, ShouldEqual, image.Platform.Os) + So(repo.Platforms[0].Arch, ShouldEqual, image.Platform.Arch) + So(repo.NewestTag.RepoName, ShouldEqual, image.RepoName) + So(repo.NewestTag.Tag, ShouldEqual, image.Tag) + So(repo.NewestTag.LastUpdated, ShouldEqual, image.LastUpdated) + So(repo.NewestTag.Size, ShouldEqual, image.Size) + So(repo.NewestTag.IsSigned, ShouldEqual, image.IsSigned) + So(repo.NewestTag.Vendor, ShouldEqual, image.Vendor) + So(repo.NewestTag.Platform.Os, ShouldEqual, image.Platform.Os) + So(repo.NewestTag.Platform.Arch, ShouldEqual, image.Platform.Arch) + } + // GetRepositories fail err = os.Chmod(rootDir, 0o333) diff --git a/pkg/extensions/search/common/oci_layout.go b/pkg/extensions/search/common/oci_layout.go index 40d1d2b7..a9223b3c 100644 --- a/pkg/extensions/search/common/oci_layout.go +++ b/pkg/extensions/search/common/oci_layout.go @@ -30,7 +30,7 @@ type OciLayoutUtils interface { GetImagePlatform(imageInfo ispec.Image) (string, string) GetImageVendor(imageInfo ispec.Image) string GetImageManifestSize(repo string, manifestDigest godigest.Digest) int64 - GetRepoLastUpdated(repo string) (time.Time, error) + GetRepoLastUpdated(repo string) (TagInfo, error) GetExpandedRepoInfo(name string) (RepoInfo, error) GetImageConfigInfo(repo string, manifestDigest godigest.Digest) (ispec.Image, error) } @@ -323,15 +323,15 @@ func (olu BaseOciLayoutUtils) GetImageConfigSize(repo string, manifestDigest god return imageBlobManifest.Config.Size } -func (olu BaseOciLayoutUtils) GetRepoLastUpdated(repo string) (time.Time, error) { +func (olu BaseOciLayoutUtils) GetRepoLastUpdated(repo string) (TagInfo, error) { tagsInfo, err := olu.GetImageTagsWithTimestamp(repo) if err != nil || len(tagsInfo) == 0 { - return time.Time{}, err + return TagInfo{}, err } latestTag := GetLatestTag(tagsInfo) - return latestTag.Timestamp, nil + return latestTag, nil } func (olu BaseOciLayoutUtils) GetExpandedRepoInfo(name string) (RepoInfo, error) { diff --git a/pkg/extensions/search/gql_generated/generated.go b/pkg/extensions/search/gql_generated/generated.go index 0261ee6f..40916282 100644 --- a/pkg/extensions/search/gql_generated/generated.go +++ b/pkg/extensions/search/gql_generated/generated.go @@ -143,6 +143,7 @@ type ComplexityRoot struct { RepoSummary struct { LastUpdated func(childComplexity int) int Name func(childComplexity int) int + NewestTag func(childComplexity int) int Platforms func(childComplexity int) int Score func(childComplexity int) int Size func(childComplexity int) int @@ -596,6 +597,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.RepoSummary.Name(childComplexity), true + case "RepoSummary.NewestTag": + if e.complexity.RepoSummary.NewestTag == nil { + break + } + + return e.complexity.RepoSummary.NewestTag(childComplexity), true + case "RepoSummary.Platforms": if e.complexity.RepoSummary.Platforms == nil { break @@ -794,6 +802,7 @@ type RepoSummary { Platforms: [OsArch] Vendors: [String] Score: Int + NewestTag: ImageSummary } # Currently the same as LayerInfo, we can refactor later @@ -1392,6 +1401,8 @@ func (ec *executionContext) fieldContext_GlobalSearchResult_Repos(ctx context.Co return ec.fieldContext_RepoSummary_Vendors(ctx, field) case "Score": return ec.fieldContext_RepoSummary_Score(ctx, field) + case "NewestTag": + return ec.fieldContext_RepoSummary_NewestTag(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type RepoSummary", field.Name) }, @@ -3740,6 +3751,65 @@ func (ec *executionContext) fieldContext_RepoSummary_Score(ctx context.Context, return fc, nil } +func (ec *executionContext) _RepoSummary_NewestTag(ctx context.Context, field graphql.CollectedField, obj *RepoSummary) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_RepoSummary_NewestTag(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.NewestTag, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ImageSummary) + fc.Result = res + return ec.marshalOImageSummary2ᚖzotregistryᚗioᚋzotᚋpkgᚋextensionsᚋsearchᚋgql_generatedᚐImageSummary(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_RepoSummary_NewestTag(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "RepoSummary", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "RepoName": + return ec.fieldContext_ImageSummary_RepoName(ctx, field) + case "Tag": + return ec.fieldContext_ImageSummary_Tag(ctx, field) + case "LastUpdated": + return ec.fieldContext_ImageSummary_LastUpdated(ctx, field) + case "IsSigned": + return ec.fieldContext_ImageSummary_IsSigned(ctx, field) + case "Size": + return ec.fieldContext_ImageSummary_Size(ctx, field) + case "Platform": + return ec.fieldContext_ImageSummary_Platform(ctx, field) + case "Vendor": + return ec.fieldContext_ImageSummary_Vendor(ctx, field) + case "Score": + return ec.fieldContext_ImageSummary_Score(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImageSummary", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _TagInfo_Name(ctx context.Context, field graphql.CollectedField, obj *TagInfo) (ret graphql.Marshaler) { fc, err := ec.fieldContext_TagInfo_Name(ctx, field) if err != nil { @@ -6338,6 +6408,10 @@ func (ec *executionContext) _RepoSummary(ctx context.Context, sel ast.SelectionS out.Values[i] = ec._RepoSummary_Score(ctx, field, obj) + case "NewestTag": + + out.Values[i] = ec._RepoSummary_NewestTag(ctx, field, obj) + default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/pkg/extensions/search/gql_generated/models_gen.go b/pkg/extensions/search/gql_generated/models_gen.go index fc637222..dd80da46 100644 --- a/pkg/extensions/search/gql_generated/models_gen.go +++ b/pkg/extensions/search/gql_generated/models_gen.go @@ -95,12 +95,13 @@ type RepoInfo struct { } type RepoSummary struct { - Name *string `json:"Name"` - LastUpdated *time.Time `json:"LastUpdated"` - Size *string `json:"Size"` - Platforms []*OsArch `json:"Platforms"` - Vendors []*string `json:"Vendors"` - Score *int `json:"Score"` + Name *string `json:"Name"` + LastUpdated *time.Time `json:"LastUpdated"` + Size *string `json:"Size"` + Platforms []*OsArch `json:"Platforms"` + Vendors []*string `json:"Vendors"` + Score *int `json:"Score"` + NewestTag *ImageSummary `json:"NewestTag"` } type TagInfo struct { diff --git a/pkg/extensions/search/resolver.go b/pkg/extensions/search/resolver.go index 6b21939b..38874ce8 100644 --- a/pkg/extensions/search/resolver.go +++ b/pkg/extensions/search/resolver.go @@ -211,9 +211,9 @@ func globalSearch(repoList []string, name, tag string, olu common.OciLayoutUtils // made up of all manifests, configs and image layers repoSize := int64(0) - lastUpdate, err := olu.GetRepoLastUpdated(repo) + lastUpdatedTag, err := olu.GetRepoLastUpdated(repo) if err != nil { - log.Error().Err(err).Msgf("can't find latest update timestamp for repo: %s", repo) + log.Error().Err(err).Msgf("can't find latest updated tag for repo: %s", repo) } tagsInfo, err := olu.GetImageTagsWithTimestamp(repo) @@ -230,6 +230,8 @@ func globalSearch(repoList []string, name, tag string, olu common.OciLayoutUtils continue } + var lastUpdatedImageSummary gql_generated.ImageSummary + repoPlatforms := make([]*gql_generated.OsArch, 0, len(tagsInfo)) repoVendors := make([]*string, 0, len(repoInfo.Manifests)) @@ -307,7 +309,7 @@ func globalSearch(repoList []string, name, tag string, olu common.OciLayoutUtils repoPlatforms = append(repoPlatforms, osArch) repoVendors = append(repoVendors, &vendor) - images = append(images, &gql_generated.ImageSummary{ + imageSummary := gql_generated.ImageSummary{ RepoName: &repo, Tag: &tag, LastUpdated: &lastUpdated, @@ -316,7 +318,13 @@ func globalSearch(repoList []string, name, tag string, olu common.OciLayoutUtils Platform: osArch, Vendor: &vendor, Score: &score, - }) + } + + if tagsInfo[i].Digest == lastUpdatedTag.Digest { + lastUpdatedImageSummary = imageSummary + } + + images = append(images, &imageSummary) } } @@ -329,11 +337,12 @@ func globalSearch(repoList []string, name, tag string, olu common.OciLayoutUtils repos = append(repos, &gql_generated.RepoSummary{ Name: &repo, - LastUpdated: &lastUpdate, + LastUpdated: &lastUpdatedTag.Timestamp, Size: &repoSize, Platforms: repoPlatforms, Vendors: repoVendors, Score: &index, + NewestTag: &lastUpdatedImageSummary, }) } } diff --git a/pkg/extensions/search/resolver_test.go b/pkg/extensions/search/resolver_test.go index cc5dd6fa..b094e918 100644 --- a/pkg/extensions/search/resolver_test.go +++ b/pkg/extensions/search/resolver_test.go @@ -4,7 +4,6 @@ import ( "errors" "strings" "testing" - "time" godigest "github.com/opencontainers/go-digest" . "github.com/smartystreets/goconvey/convey" @@ -19,8 +18,8 @@ func TestGlobalSearch(t *testing.T) { Convey("globalSearch", t, func() { Convey("GetRepoLastUpdated fail", func() { mockOlum := mocks.OciLayoutUtilsMock{ - GetRepoLastUpdatedFn: func(repo string) (time.Time, error) { - return time.Time{}, ErrTestError + GetRepoLastUpdatedFn: func(repo string) (common.TagInfo, error) { + return common.TagInfo{}, ErrTestError }, } diff --git a/pkg/extensions/search/schema.graphql b/pkg/extensions/search/schema.graphql index 9db10c4b..4a223b44 100644 --- a/pkg/extensions/search/schema.graphql +++ b/pkg/extensions/search/schema.graphql @@ -95,6 +95,7 @@ type RepoSummary { Platforms: [OsArch] Vendors: [String] Score: Int + NewestTag: ImageSummary } # Currently the same as LayerInfo, we can refactor later diff --git a/pkg/test/mocks/oci_mock.go b/pkg/test/mocks/oci_mock.go index 965017cd..98e6c515 100644 --- a/pkg/test/mocks/oci_mock.go +++ b/pkg/test/mocks/oci_mock.go @@ -20,7 +20,7 @@ type OciLayoutUtilsMock struct { GetImageVendorFn func(imageInfo ispec.Image) string GetImageManifestSizeFn func(repo string, manifestDigest godigest.Digest) int64 GetImageConfigSizeFn func(repo string, manifestDigest godigest.Digest) int64 - GetRepoLastUpdatedFn func(repo string) (time.Time, error) + GetRepoLastUpdatedFn func(repo string) (common.TagInfo, error) GetExpandedRepoInfoFn func(name string) (common.RepoInfo, error) GetImageConfigInfoFn func(repo string, manifestDigest godigest.Digest) (ispec.Image, error) } @@ -105,12 +105,12 @@ func (olum OciLayoutUtilsMock) GetImageConfigSize(repo string, manifestDigest go return 0 } -func (olum OciLayoutUtilsMock) GetRepoLastUpdated(repo string) (time.Time, error) { +func (olum OciLayoutUtilsMock) GetRepoLastUpdated(repo string) (common.TagInfo, error) { if olum.GetRepoLastUpdatedFn != nil { return olum.GetRepoLastUpdatedFn(repo) } - return time.Time{}, nil + return common.TagInfo{}, nil } func (olum OciLayoutUtilsMock) GetExpandedRepoInfo(name string) (common.RepoInfo, error) {