mirror of
https://github.com/project-zot/zot.git
synced 2026-06-19 05:57:57 +08:00
3c7d5a5f1d
feat(meta): add TaggedTimestamp field and preserve during re-parsing Add TaggedTimestamp field to track when image tags were created, exposed through GraphQL API. Previously, when zot restarted and re-parsed storage, ResetRepoReferences would clear all tags, causing timestamp information to be lost and reset to the service restart time for existing images. This change adds TaggedTimestamp support and modifies ResetRepoReferences to selectively preserve tags that still exist in storage, maintaining their TaggedTimestamp values. Tags that no longer exist in storage are removed as before. Changes: - Add TaggedTimestamp field to GraphQL ImageSummary schema - Update GraphQL conversion functions to populate TaggedTimestamp with fallback to PushTimestamp when unavailable - Updated ResetRepoReferences interface to accept tagsToKeep parameter - Modified ParseRepo to collect tags from storage before resetting - Updated all backend implementations (Redis, DynamoDB, BoltDB) to preserve tags in tagsToKeep instead of clearing all tags - Updated tests and mocks to match new signature This ensures TaggedTimestamp accurately reflects when tags were originally created, and exposes this information through the GraphQL API. Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
438 lines
12 KiB
Go
438 lines
12 KiB
Go
package common
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
godigest "github.com/opencontainers/go-digest"
|
|
ispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
|
|
zerr "zotregistry.dev/zot/v2/errors"
|
|
zcommon "zotregistry.dev/zot/v2/pkg/common"
|
|
mConvert "zotregistry.dev/zot/v2/pkg/meta/convert"
|
|
proto_go "zotregistry.dev/zot/v2/pkg/meta/proto/gen"
|
|
mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
|
|
)
|
|
|
|
func SignatureAlreadyExists(signatureSlice []mTypes.SignatureInfo, sm mTypes.SignatureMetadata) bool {
|
|
return slices.ContainsFunc(signatureSlice, func(sigInfo mTypes.SignatureInfo) bool {
|
|
return sm.SignatureDigest == sigInfo.SignatureManifestDigest
|
|
})
|
|
}
|
|
|
|
func ProtoSignatureAlreadyExists(signatureSlice []*proto_go.SignatureInfo, sm mTypes.SignatureMetadata) bool {
|
|
return slices.ContainsFunc(signatureSlice, func(sigInfo *proto_go.SignatureInfo) bool {
|
|
return sm.SignatureDigest == sigInfo.SignatureManifestDigest
|
|
})
|
|
}
|
|
|
|
func ReferenceIsDigest(reference string) bool {
|
|
_, err := godigest.Parse(reference)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
func ReferenceIsTag(reference string) bool {
|
|
return !ReferenceIsDigest(reference)
|
|
}
|
|
|
|
func ValidateRepoReferenceInput(repo, reference string, manifestDigest godigest.Digest) error {
|
|
if repo == "" {
|
|
return zerr.ErrEmptyRepoName
|
|
}
|
|
|
|
if reference == "" {
|
|
return zerr.ErrEmptyTag
|
|
}
|
|
|
|
if manifestDigest == "" {
|
|
return zerr.ErrEmptyDigest
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// These constants are meant used to describe how high or low in rank a match is.
|
|
// Note that the "higher rank" relates to a lower number so ranks are sorted in a
|
|
// ascending order.
|
|
const (
|
|
lowPriority = 100
|
|
mediumPriority = 10
|
|
highPriority = 1
|
|
perfectMatchPriority = 0
|
|
)
|
|
|
|
// RankRepoName associates a rank to a given repoName given a searchText.
|
|
// The importance of the value grows inversely proportional to the int value it has.
|
|
// For example: rank(1) > rank(10) > rank(100)...
|
|
func RankRepoName(searchText string, repoName string) int {
|
|
searchText = strings.Trim(searchText, "/")
|
|
searchTextSlice := strings.Split(searchText, "/")
|
|
repoNameSlice := strings.Split(repoName, "/")
|
|
|
|
if len(searchTextSlice) > len(repoNameSlice) {
|
|
return -1
|
|
}
|
|
|
|
if searchText == repoName {
|
|
return perfectMatchPriority
|
|
}
|
|
|
|
// searchText contains just 1 directory name
|
|
if len(searchTextSlice) == 1 {
|
|
lastNameInRepoPath := repoNameSlice[len(repoNameSlice)-1]
|
|
|
|
// searchText: "bar" | repoName: "foo/bar" lastNameInRepoPath: "bar"
|
|
if index := strings.Index(lastNameInRepoPath, searchText); index != -1 {
|
|
return (index + 1) * highPriority
|
|
}
|
|
|
|
firstNameInRepoPath := repoNameSlice[0]
|
|
|
|
// searchText: "foo" | repoName: "foo/bar" firstNameInRepoPath: "foo"
|
|
if index := strings.Index(firstNameInRepoPath, searchText); index != -1 {
|
|
return (index + 1) * mediumPriority
|
|
}
|
|
}
|
|
|
|
foundPrefixInRepoName := true
|
|
|
|
// searchText: "foo/bar/rep" | repoName: "foo/bar/baz/repo" foundPrefixInRepoName: true
|
|
// searchText: "foo/baz/rep" | repoName: "foo/bar/baz/repo" foundPrefixInRepoName: false
|
|
for i := 0; i < len(searchTextSlice)-1; i++ {
|
|
if searchTextSlice[i] != repoNameSlice[i] {
|
|
foundPrefixInRepoName = false
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if foundPrefixInRepoName {
|
|
lastNameInRepoPath := repoNameSlice[len(repoNameSlice)-1]
|
|
lastNameInSearchText := searchTextSlice[len(searchTextSlice)-1]
|
|
|
|
// searchText: "foo/bar/epo" | repoName: "foo/bar/baz/repo" -> Index(repo, epo) = 1
|
|
if index := strings.Index(lastNameInRepoPath, lastNameInSearchText); index != -1 {
|
|
return (index + 1) * highPriority
|
|
}
|
|
}
|
|
|
|
// searchText: "foo/bar/b" | repoName: "foo/bar/baz/repo"
|
|
if strings.HasPrefix(repoName, searchText) {
|
|
return mediumPriority
|
|
}
|
|
|
|
// searchText: "bar/ba" | repoName: "foo/bar/baz/repo"
|
|
if index := strings.Index(repoName, searchText); index != -1 {
|
|
return (index + 1) * lowPriority
|
|
}
|
|
|
|
// no match
|
|
return -1
|
|
}
|
|
|
|
func GetRepoTag(searchText string) (string, string, error) {
|
|
const repoTagCount = 2
|
|
|
|
splitSlice := strings.Split(searchText, ":")
|
|
|
|
if len(splitSlice) != repoTagCount {
|
|
return "", "", zerr.ErrInvalidRepoRefFormat
|
|
}
|
|
|
|
repo := strings.TrimSpace(splitSlice[0])
|
|
tag := strings.TrimSpace(splitSlice[1])
|
|
|
|
return repo, tag, nil
|
|
}
|
|
|
|
func MatchesArtifactTypes(descriptorMediaType string, artifactTypes []string) bool {
|
|
if len(artifactTypes) == 0 {
|
|
return true
|
|
}
|
|
|
|
return slices.ContainsFunc(artifactTypes, func(artifactType string) bool {
|
|
return artifactType == "" || descriptorMediaType == artifactType
|
|
})
|
|
}
|
|
|
|
// CheckImageLastUpdated check if the given image is updated earlier than the current repoLastUpdated value
|
|
//
|
|
// It returns updated values for: repoLastUpdated, noImageChecked, isSigned.
|
|
func CheckImageLastUpdated(repoLastUpdated time.Time, isSigned bool, noImageChecked bool,
|
|
manifestFilterData mTypes.FilterData,
|
|
) (time.Time, bool, bool) {
|
|
if noImageChecked || repoLastUpdated.Before(manifestFilterData.LastUpdated) {
|
|
repoLastUpdated = manifestFilterData.LastUpdated
|
|
noImageChecked = false
|
|
|
|
isSigned = manifestFilterData.IsSigned
|
|
}
|
|
|
|
return repoLastUpdated, noImageChecked, isSigned
|
|
}
|
|
|
|
func AddImageMetaToRepoMeta(repoMeta *proto_go.RepoMeta, repoBlobs *proto_go.RepoBlobs, reference string,
|
|
imageMeta mTypes.ImageMeta,
|
|
) (*proto_go.RepoMeta, *proto_go.RepoBlobs) {
|
|
switch imageMeta.MediaType {
|
|
case ispec.MediaTypeImageManifest:
|
|
if len(imageMeta.Manifests) == 0 {
|
|
// Empty manifests is an invalid state for ImageManifest, but we still add basic blob info
|
|
// to avoid skipping all metadata processing (e.g., LastUpdatedImage update)
|
|
repoBlobs.Blobs[imageMeta.Digest.String()] = &proto_go.BlobInfo{
|
|
Size: imageMeta.Size,
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
manifestData := imageMeta.Manifests[0]
|
|
|
|
vendor := GetVendor(manifestData.Manifest.Annotations)
|
|
if vendor == "" {
|
|
vendor = GetVendor(manifestData.Manifest.Annotations)
|
|
}
|
|
|
|
vendors := []string{}
|
|
if vendor != "" {
|
|
vendors = append(vendors, vendor)
|
|
}
|
|
|
|
platforms := []*proto_go.Platform{GetProtoPlatform(&manifestData.Config.Platform)}
|
|
if platforms[0].OS == "" && platforms[0].Architecture == "" {
|
|
platforms = []*proto_go.Platform{}
|
|
}
|
|
|
|
subBlobs := []string{manifestData.Manifest.Config.Digest.String()}
|
|
repoBlobs.Blobs[manifestData.Manifest.Config.Digest.String()] = &proto_go.BlobInfo{
|
|
Size: manifestData.Manifest.Config.Size,
|
|
}
|
|
|
|
for _, layer := range manifestData.Manifest.Layers {
|
|
subBlobs = append(subBlobs, layer.Digest.String())
|
|
repoBlobs.Blobs[layer.Digest.String()] = &proto_go.BlobInfo{Size: layer.Size}
|
|
}
|
|
|
|
lastUpdated := zcommon.GetImageLastUpdated(manifestData.Config)
|
|
|
|
repoBlobs.Blobs[imageMeta.Digest.String()] = &proto_go.BlobInfo{
|
|
Size: imageMeta.Size,
|
|
Vendors: vendors,
|
|
Platforms: platforms,
|
|
SubBlobs: subBlobs,
|
|
LastUpdated: mConvert.GetProtoTime(&lastUpdated),
|
|
}
|
|
case ispec.MediaTypeImageIndex:
|
|
subBlobs := []string{}
|
|
lastUpdated := time.Time{}
|
|
|
|
for _, manifest := range imageMeta.Index.Manifests {
|
|
subBlobs = append(subBlobs, manifest.Digest.String())
|
|
|
|
blobInfo := repoBlobs.Blobs[manifest.Digest.String()]
|
|
|
|
if blobInfo != nil && blobInfo.LastUpdated != nil {
|
|
if lastUpdated.Before(blobInfo.LastUpdated.AsTime()) {
|
|
lastUpdated = blobInfo.LastUpdated.AsTime()
|
|
}
|
|
}
|
|
}
|
|
|
|
repoBlobs.Blobs[imageMeta.Digest.String()] = &proto_go.BlobInfo{
|
|
Size: imageMeta.Size,
|
|
SubBlobs: subBlobs,
|
|
LastUpdated: mConvert.GetProtoTime(&lastUpdated),
|
|
}
|
|
}
|
|
|
|
// update info only when a tag is added
|
|
if zcommon.IsDigest(reference) {
|
|
return repoMeta, repoBlobs
|
|
}
|
|
|
|
size, platforms, vendors := recalculateAggregateFields(repoMeta, repoBlobs)
|
|
repoMeta.Vendors = vendors
|
|
repoMeta.Platforms = platforms
|
|
repoMeta.Size = size
|
|
|
|
imageBlobInfo := repoBlobs.Blobs[imageMeta.Digest.String()]
|
|
repoMeta.LastUpdatedImage = mConvert.GetProtoEarlierUpdatedImage(repoMeta.LastUpdatedImage,
|
|
&proto_go.RepoLastUpdatedImage{
|
|
LastUpdated: imageBlobInfo.LastUpdated,
|
|
MediaType: imageMeta.MediaType,
|
|
Digest: imageMeta.Digest.String(),
|
|
Tag: reference,
|
|
})
|
|
|
|
return repoMeta, repoBlobs
|
|
}
|
|
|
|
func RemoveImageFromRepoMeta(repoMeta *proto_go.RepoMeta, repoBlobs *proto_go.RepoBlobs, ref string,
|
|
) (*proto_go.RepoMeta, *proto_go.RepoBlobs) {
|
|
var updatedLastImage *proto_go.RepoLastUpdatedImage
|
|
|
|
updatedBlobs := map[string]*proto_go.BlobInfo{}
|
|
updatedSize := int64(0)
|
|
updatedVendors := []string{}
|
|
updatedPlatforms := []*proto_go.Platform{}
|
|
|
|
for tag, descriptor := range repoMeta.Tags {
|
|
if descriptor.Digest == "" {
|
|
continue
|
|
}
|
|
|
|
queue := []string{descriptor.Digest}
|
|
|
|
// Check if blob info exists before accessing it to prevent nil pointer dereference.
|
|
// When a tag is skipped due to nil blob info, that tag remains in repoMeta.Tags but
|
|
// won't have any associated blobs in the result, creating metadata inconsistency.
|
|
// This is acceptable in GC/cleanup scenarios where data may already be inconsistent
|
|
// due to partial deletions or corruption.
|
|
descriptorBlobInfo := repoBlobs.Blobs[descriptor.Digest]
|
|
if descriptorBlobInfo == nil {
|
|
continue
|
|
}
|
|
|
|
updatedLastImage = mConvert.GetProtoEarlierUpdatedImage(updatedLastImage, &proto_go.RepoLastUpdatedImage{
|
|
LastUpdated: descriptorBlobInfo.LastUpdated,
|
|
MediaType: descriptor.MediaType,
|
|
Digest: descriptor.Digest,
|
|
Tag: tag,
|
|
})
|
|
|
|
for len(queue) > 0 {
|
|
currentBlob := queue[0]
|
|
queue = queue[1:]
|
|
|
|
if _, found := updatedBlobs[currentBlob]; !found {
|
|
blobInfo := repoBlobs.Blobs[currentBlob]
|
|
if blobInfo == nil {
|
|
continue
|
|
}
|
|
|
|
updatedBlobs[currentBlob] = blobInfo
|
|
updatedSize += blobInfo.Size
|
|
updatedVendors = mConvert.AddVendors(updatedVendors, blobInfo.Vendors)
|
|
updatedPlatforms = mConvert.AddProtoPlatforms(updatedPlatforms, blobInfo.Platforms)
|
|
|
|
queue = append(queue, blobInfo.SubBlobs...)
|
|
}
|
|
}
|
|
}
|
|
|
|
repoMeta.Size = updatedSize
|
|
repoMeta.Vendors = updatedVendors
|
|
repoMeta.Platforms = updatedPlatforms
|
|
repoMeta.LastUpdatedImage = updatedLastImage
|
|
|
|
repoBlobs.Blobs = updatedBlobs
|
|
|
|
return repoMeta, repoBlobs
|
|
}
|
|
|
|
func recalculateAggregateFields(repoMeta *proto_go.RepoMeta, repoBlobs *proto_go.RepoBlobs,
|
|
) (int64, []*proto_go.Platform, []string) {
|
|
size := int64(0)
|
|
platforms := []*proto_go.Platform{}
|
|
vendors := []string{}
|
|
blobsMap := map[string]struct{}{}
|
|
|
|
for _, descriptor := range repoMeta.Tags {
|
|
if descriptor.Digest == "" {
|
|
continue
|
|
}
|
|
|
|
queue := []string{descriptor.Digest}
|
|
|
|
for len(queue) > 0 {
|
|
currentBlob := queue[0]
|
|
queue = queue[1:]
|
|
|
|
if _, found := blobsMap[currentBlob]; !found {
|
|
blobInfo := repoBlobs.Blobs[currentBlob]
|
|
if blobInfo == nil {
|
|
continue
|
|
}
|
|
|
|
blobsMap[currentBlob] = struct{}{}
|
|
size += blobInfo.Size
|
|
vendors = mConvert.AddVendors(vendors, blobInfo.Vendors)
|
|
platforms = mConvert.AddProtoPlatforms(platforms, blobInfo.Platforms)
|
|
|
|
queue = append(queue, blobInfo.SubBlobs...)
|
|
}
|
|
}
|
|
}
|
|
|
|
return size, platforms, vendors
|
|
}
|
|
|
|
func GetProtoPlatform(platform *ispec.Platform) *proto_go.Platform {
|
|
if platform == nil {
|
|
return nil
|
|
}
|
|
|
|
return &proto_go.Platform{
|
|
Architecture: getArch(platform.Architecture, platform.Variant),
|
|
OS: platform.OS,
|
|
}
|
|
}
|
|
|
|
func getArch(arch string, variant string) string {
|
|
if variant != "" {
|
|
arch = arch + "/" + variant
|
|
}
|
|
|
|
return arch
|
|
}
|
|
|
|
func GetVendor(annotations map[string]string) string {
|
|
return GetAnnotationValue(annotations, ispec.AnnotationVendor, "org.label-schema.vendor")
|
|
}
|
|
|
|
func GetAnnotationValue(annotations map[string]string, annotationKey, labelKey string) string {
|
|
value, ok := annotations[annotationKey]
|
|
if !ok || value == "" {
|
|
value, ok = annotations[labelKey]
|
|
if !ok {
|
|
value = ""
|
|
}
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func GetPartialImageMeta(imageIndexMeta mTypes.ImageMeta, imageMeta mTypes.ImageMeta) mTypes.ImageMeta {
|
|
partialImageMeta := imageIndexMeta
|
|
partialImageMeta.Manifests = imageMeta.Manifests
|
|
|
|
partialIndex := deref(imageIndexMeta.Index, ispec.Index{})
|
|
partialIndex.Manifests = getPartialManifestList(partialIndex.Manifests, imageMeta.Digest.String())
|
|
|
|
partialImageMeta.Index = &partialIndex
|
|
|
|
return partialImageMeta
|
|
}
|
|
|
|
func getPartialManifestList(descriptors []ispec.Descriptor, manifestDigest string) []ispec.Descriptor {
|
|
result := []ispec.Descriptor{}
|
|
|
|
for i := range descriptors {
|
|
if descriptors[i].Digest.String() == manifestDigest {
|
|
result = append(result, descriptors[i])
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func deref[T any](pointer *T, defaultVal T) T {
|
|
if pointer != nil {
|
|
return *pointer
|
|
}
|
|
|
|
return defaultVal
|
|
}
|