From 8f0bf18d7eddbfbd84c2a77693241362602987ff Mon Sep 17 00:00:00 2001 From: John Wagner Date: Wed, 11 Mar 2026 00:40:54 +0100 Subject: [PATCH] fix(search): expose LastPullTimestamp and PushedBy on index ImageSummary (#3865) ImageIndex2ImageSummary was missing LastPullTimestamp assignment, causing multi-arch image queries to always return null for this field. Also adds the PushedBy field (already stored in MetaDB) to the GraphQL schema and both conversion paths (manifest and index). Signed-off-by: cainydev --- pkg/extensions/search/convert/convert_test.go | 17 +++++- pkg/extensions/search/convert/metadb.go | 56 ++++++++++--------- .../search/gql_generated/generated.go | 52 +++++++++++++++++ .../search/gql_generated/models_gen.go | 2 + pkg/extensions/search/schema.graphql | 4 ++ 5 files changed, 103 insertions(+), 28 deletions(-) diff --git a/pkg/extensions/search/convert/convert_test.go b/pkg/extensions/search/convert/convert_test.go index d217b129..6d4eeb31 100644 --- a/pkg/extensions/search/convert/convert_test.go +++ b/pkg/extensions/search/convert/convert_test.go @@ -279,19 +279,21 @@ func TestTaggedTimestamp(t *testing.T) { Convey("TaggedTimestamp is populated from tag descriptor", func() { taggedTime := time.Date(2024, time.January, 15, 10, 30, 0, 0, time.UTC) pushTime := time.Date(2024, time.January, 10, 8, 0, 0, 0, time.UTC) + digestStr := godigest.FromString("sha256:abc123").String() repoMeta := mTypes.RepoMeta{ Name: "repo", Tags: map[string]mTypes.Descriptor{ "tag1": { - Digest: "sha256:abc123", + Digest: digestStr, MediaType: ispec.MediaTypeImageManifest, TaggedTimestamp: taggedTime, }, }, Statistics: map[string]mTypes.DescriptorStatistics{ - "sha256:abc123": { + digestStr: { PushTimestamp: pushTime, + PushedBy: "testuser", }, }, } @@ -319,6 +321,8 @@ func TestTaggedTimestamp(t *testing.T) { So(err, ShouldBeNil) So(imageSummary.TaggedTimestamp, ShouldNotBeNil) So(*imageSummary.TaggedTimestamp, ShouldEqual, taggedTime) + So(imageSummary.PushedBy, ShouldNotBeNil) + So(*imageSummary.PushedBy, ShouldEqual, "testuser") }) Convey("TaggedTimestamp falls back to PushTimestamp when zero", func() { @@ -372,6 +376,7 @@ func TestTaggedTimestamp(t *testing.T) { Convey("TaggedTimestamp is propagated to nested manifests in ImageIndex", func() { taggedTime := time.Date(2024, time.January, 15, 10, 30, 0, 0, time.UTC) pushTime := time.Date(2024, time.January, 10, 8, 0, 0, 0, time.UTC) + lastPullTime := time.Date(2024, time.February, 1, 12, 0, 0, 0, time.UTC) // Create a multiarch image multiarchImage := CreateMultiarchWith().Images([]Image{ @@ -392,7 +397,9 @@ func TestTaggedTimestamp(t *testing.T) { }, Statistics: map[string]mTypes.DescriptorStatistics{ indexDigestStr: { - PushTimestamp: pushTime, + PushTimestamp: pushTime, + LastPullTimestamp: lastPullTime, + PushedBy: "indexuser", }, }, } @@ -405,6 +412,10 @@ func TestTaggedTimestamp(t *testing.T) { So(err, ShouldBeNil) So(imageSummary.TaggedTimestamp, ShouldNotBeNil) So(*imageSummary.TaggedTimestamp, ShouldEqual, taggedTime) + So(imageSummary.LastPullTimestamp, ShouldNotBeNil) + So(*imageSummary.LastPullTimestamp, ShouldEqual, lastPullTime) + So(imageSummary.PushedBy, ShouldNotBeNil) + So(*imageSummary.PushedBy, ShouldEqual, "indexuser") // Verify that nested manifests also have the correct TaggedTimestamp So(len(imageSummary.Manifests), ShouldBeGreaterThan, 0) diff --git a/pkg/extensions/search/convert/metadb.go b/pkg/extensions/search/convert/metadb.go index 5b29b354..0124e4a1 100644 --- a/pkg/extensions/search/convert/metadb.go +++ b/pkg/extensions/search/convert/metadb.go @@ -427,10 +427,12 @@ func ImageIndex2ImageSummary(ctx context.Context, fullImageMeta mTypes.FullImage manifestSummaries = make([]*gql_generated.ManifestSummary, 0, len(fullImageMeta.Manifests)) indexBlobs = map[string]int64{} - indexDigestStr = fullImageMeta.Digest.String() - indexMediaType = ispec.MediaTypeImageIndex - pushTimestamp = fullImageMeta.Statistics.PushTimestamp - taggedTimestamp = fullImageMeta.TaggedTimestamp + indexDigestStr = fullImageMeta.Digest.String() + indexMediaType = ispec.MediaTypeImageIndex + lastPullTimestamp = fullImageMeta.Statistics.LastPullTimestamp + pushTimestamp = fullImageMeta.Statistics.PushTimestamp + pushedBy = fullImageMeta.Statistics.PushedBy + taggedTimestamp = fullImageMeta.TaggedTimestamp ) // Fallback to PushTimestamp if TaggedTimestamp is not available @@ -490,27 +492,29 @@ func ImageIndex2ImageSummary(ctx context.Context, fullImageMeta mTypes.FullImage } indexSummary := gql_generated.ImageSummary{ - RepoName: &repo, - Tag: &tag, - Digest: &indexDigestStr, - MediaType: &indexMediaType, - Manifests: manifestSummaries, - LastUpdated: imageLastUpdated, - IsSigned: &isSigned, - SignatureInfo: signaturesInfo, - Size: ref(strconv.FormatInt(indexSize, 10)), - DownloadCount: ref(fullImageMeta.Statistics.DownloadCount), - PushTimestamp: &pushTimestamp, - TaggedTimestamp: &taggedTimestamp, - Description: &annotations.Description, - Title: &annotations.Title, - Documentation: &annotations.Documentation, - Licenses: &annotations.Licenses, - Labels: &annotations.Labels, - Source: &annotations.Source, - Vendor: &annotations.Vendor, - Authors: &annotations.Authors, - Referrers: getReferrers(fullImageMeta.Referrers), + RepoName: &repo, + Tag: &tag, + Digest: &indexDigestStr, + MediaType: &indexMediaType, + Manifests: manifestSummaries, + LastUpdated: imageLastUpdated, + IsSigned: &isSigned, + SignatureInfo: signaturesInfo, + Size: ref(strconv.FormatInt(indexSize, 10)), + DownloadCount: ref(fullImageMeta.Statistics.DownloadCount), + LastPullTimestamp: &lastPullTimestamp, + PushTimestamp: &pushTimestamp, + PushedBy: &pushedBy, + TaggedTimestamp: &taggedTimestamp, + Description: &annotations.Description, + Title: &annotations.Title, + Documentation: &annotations.Documentation, + Licenses: &annotations.Licenses, + Labels: &annotations.Labels, + Source: &annotations.Source, + Vendor: &annotations.Vendor, + Authors: &annotations.Authors, + Referrers: getReferrers(fullImageMeta.Referrers), } return &indexSummary, indexBlobs, nil @@ -534,6 +538,7 @@ func ImageManifest2ImageSummary(ctx context.Context, fullImageMeta mTypes.FullIm isSigned = isImageSigned(fullImageMeta.Signatures) lastPullTimestamp = fullImageMeta.Statistics.LastPullTimestamp pushTimestamp = fullImageMeta.Statistics.PushTimestamp + pushedBy = fullImageMeta.Statistics.PushedBy taggedTimestamp = fullImageMeta.TaggedTimestamp ) @@ -594,6 +599,7 @@ func ImageManifest2ImageSummary(ctx context.Context, fullImageMeta mTypes.FullIm DownloadCount: &downloadCount, LastPullTimestamp: &lastPullTimestamp, PushTimestamp: &pushTimestamp, + PushedBy: &pushedBy, TaggedTimestamp: &taggedTimestamp, Description: &annotations.Description, Title: &annotations.Title, diff --git a/pkg/extensions/search/gql_generated/generated.go b/pkg/extensions/search/gql_generated/generated.go index 02fdd1a7..b93bcedb 100644 --- a/pkg/extensions/search/gql_generated/generated.go +++ b/pkg/extensions/search/gql_generated/generated.go @@ -100,6 +100,7 @@ type ComplexityRoot struct { Manifests func(childComplexity int) int MediaType func(childComplexity int) int PushTimestamp func(childComplexity int) int + PushedBy func(childComplexity int) int Referrers func(childComplexity int) int RepoName func(childComplexity int) int SignatureInfo func(childComplexity int) int @@ -530,6 +531,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.ImageSummary.PushTimestamp(childComplexity), true + case "ImageSummary.PushedBy": + if e.ComplexityRoot.ImageSummary.PushedBy == nil { + break + } + + return e.ComplexityRoot.ImageSummary.PushedBy(childComplexity), true case "ImageSummary.Referrers": if e.ComplexityRoot.ImageSummary.Referrers == nil { break @@ -1400,6 +1407,10 @@ type ImageSummary { """ PushTimestamp: Time """ + The user who pushed the image to the registry + """ + PushedBy: String + """ Timestamp when the image manifest was tagged (if the data is unavailable it falls back to when the image was pushed to the registry) """ TaggedTimestamp: Time @@ -3106,6 +3117,8 @@ func (ec *executionContext) fieldContext_GlobalSearchResult_Images(_ context.Con return ec.fieldContext_ImageSummary_LastPullTimestamp(ctx, field) case "PushTimestamp": return ec.fieldContext_ImageSummary_PushTimestamp(ctx, field) + case "PushedBy": + return ec.fieldContext_ImageSummary_PushedBy(ctx, field) case "TaggedTimestamp": return ec.fieldContext_ImageSummary_TaggedTimestamp(ctx, field) case "LastUpdated": @@ -3787,6 +3800,35 @@ func (ec *executionContext) fieldContext_ImageSummary_PushTimestamp(_ context.Co return fc, nil } +func (ec *executionContext) _ImageSummary_PushedBy(ctx context.Context, field graphql.CollectedField, obj *ImageSummary) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImageSummary_PushedBy, + func(ctx context.Context) (any, error) { + return obj.PushedBy, nil + }, + nil, + ec.marshalOString2áš–string, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ImageSummary_PushedBy(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImageSummary", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _ImageSummary_TaggedTimestamp(ctx context.Context, field graphql.CollectedField, obj *ImageSummary) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -5277,6 +5319,8 @@ func (ec *executionContext) fieldContext_PaginatedImagesResult_Results(_ context return ec.fieldContext_ImageSummary_LastPullTimestamp(ctx, field) case "PushTimestamp": return ec.fieldContext_ImageSummary_PushTimestamp(ctx, field) + case "PushedBy": + return ec.fieldContext_ImageSummary_PushedBy(ctx, field) case "TaggedTimestamp": return ec.fieldContext_ImageSummary_TaggedTimestamp(ctx, field) case "LastUpdated": @@ -6034,6 +6078,8 @@ func (ec *executionContext) fieldContext_Query_Image(ctx context.Context, field return ec.fieldContext_ImageSummary_LastPullTimestamp(ctx, field) case "PushTimestamp": return ec.fieldContext_ImageSummary_PushTimestamp(ctx, field) + case "PushedBy": + return ec.fieldContext_ImageSummary_PushedBy(ctx, field) case "TaggedTimestamp": return ec.fieldContext_ImageSummary_TaggedTimestamp(ctx, field) case "LastUpdated": @@ -6530,6 +6576,8 @@ func (ec *executionContext) fieldContext_RepoInfo_Images(_ context.Context, fiel return ec.fieldContext_ImageSummary_LastPullTimestamp(ctx, field) case "PushTimestamp": return ec.fieldContext_ImageSummary_PushTimestamp(ctx, field) + case "PushedBy": + return ec.fieldContext_ImageSummary_PushedBy(ctx, field) case "TaggedTimestamp": return ec.fieldContext_ImageSummary_TaggedTimestamp(ctx, field) case "LastUpdated": @@ -6813,6 +6861,8 @@ func (ec *executionContext) fieldContext_RepoSummary_NewestImage(_ context.Conte return ec.fieldContext_ImageSummary_LastPullTimestamp(ctx, field) case "PushTimestamp": return ec.fieldContext_ImageSummary_PushTimestamp(ctx, field) + case "PushedBy": + return ec.fieldContext_ImageSummary_PushedBy(ctx, field) case "TaggedTimestamp": return ec.fieldContext_ImageSummary_TaggedTimestamp(ctx, field) case "LastUpdated": @@ -9065,6 +9115,8 @@ func (ec *executionContext) _ImageSummary(ctx context.Context, sel ast.Selection out.Values[i] = ec._ImageSummary_LastPullTimestamp(ctx, field, obj) case "PushTimestamp": out.Values[i] = ec._ImageSummary_PushTimestamp(ctx, field, obj) + case "PushedBy": + out.Values[i] = ec._ImageSummary_PushedBy(ctx, field, obj) case "TaggedTimestamp": out.Values[i] = ec._ImageSummary_TaggedTimestamp(ctx, field, obj) case "LastUpdated": diff --git a/pkg/extensions/search/gql_generated/models_gen.go b/pkg/extensions/search/gql_generated/models_gen.go index 91b024a5..ccc64afe 100644 --- a/pkg/extensions/search/gql_generated/models_gen.go +++ b/pkg/extensions/search/gql_generated/models_gen.go @@ -152,6 +152,8 @@ type ImageSummary struct { LastPullTimestamp *time.Time `json:"LastPullTimestamp,omitempty"` // Timestamp when the image was pushed to the registry PushTimestamp *time.Time `json:"PushTimestamp,omitempty"` + // The user who pushed the image to the registry + PushedBy *string `json:"PushedBy,omitempty"` // Timestamp when the image manifest was tagged (if the data is unavailable it falls back to when the image was pushed to the registry) TaggedTimestamp *time.Time `json:"TaggedTimestamp,omitempty"` // Timestamp of the last modification done to the image (from config or the last updated layer) diff --git a/pkg/extensions/search/schema.graphql b/pkg/extensions/search/schema.graphql index 88820c23..7c760956 100644 --- a/pkg/extensions/search/schema.graphql +++ b/pkg/extensions/search/schema.graphql @@ -213,6 +213,10 @@ type ImageSummary { """ PushTimestamp: Time """ + The user who pushed the image to the registry + """ + PushedBy: String + """ Timestamp when the image manifest was tagged (if the data is unavailable it falls back to when the image was pushed to the registry) """ TaggedTimestamp: Time