Files
zot/pkg/meta/parse_test.go
Andrei Aaron 3c8030b2c7 fix(meta): fixes for LastUpdated and TaggedTimestamp (#3754)
1. Parse repos without metadata in ParseStorage

The timestamp check in ParseStorage was skipping repos that exist in
storage but don't have metadata. When GetRepoLastUpdated returns zero
time (no metadata), we should always parse the repo to create its
metadata. Check if metaLastUpdated is zero before comparing timestamps.
If zero, always parse regardless of storageLastUpdated.

2. Change the logic of how LastUpdated is computed in RepoSummary

It is not the latest tagged timestamp from the available images or
the last updated image created timestamp, based on whichever is the
latest.

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
2026-02-03 21:10:35 +02:00

953 lines
31 KiB
Go

package meta_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
. "github.com/smartystreets/goconvey/convey"
zerr "zotregistry.dev/zot/v2/errors"
rediscfg "zotregistry.dev/zot/v2/pkg/api/config/redis"
zcommon "zotregistry.dev/zot/v2/pkg/common"
"zotregistry.dev/zot/v2/pkg/extensions/monitoring"
"zotregistry.dev/zot/v2/pkg/log"
"zotregistry.dev/zot/v2/pkg/meta"
"zotregistry.dev/zot/v2/pkg/meta/boltdb"
"zotregistry.dev/zot/v2/pkg/meta/dynamodb"
"zotregistry.dev/zot/v2/pkg/meta/redis"
mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
"zotregistry.dev/zot/v2/pkg/storage"
"zotregistry.dev/zot/v2/pkg/storage/local"
storageTypes "zotregistry.dev/zot/v2/pkg/storage/types"
tcommon "zotregistry.dev/zot/v2/pkg/test/common"
. "zotregistry.dev/zot/v2/pkg/test/image-utils"
"zotregistry.dev/zot/v2/pkg/test/mocks"
ociutils "zotregistry.dev/zot/v2/pkg/test/oci-utils"
"zotregistry.dev/zot/v2/pkg/test/signature"
tskip "zotregistry.dev/zot/v2/pkg/test/skip"
)
const repo = "repo"
func TestParseStorageErrors(t *testing.T) {
ctx := context.Background()
Convey("ParseStorage", t, func() {
imageStore := mocks.MockedImageStore{
GetIndexContentFn: func(repo string) ([]byte, error) {
return nil, ErrTestError
},
GetRepositoriesFn: func() ([]string, error) {
return []string{"repo1", "repo2"}, nil
},
}
storeController := storage.StoreController{DefaultStore: imageStore}
metaDB := mocks.MetaDBMock{}
// sync repo fail
err := meta.ParseStorage(metaDB, storeController, log.NewTestLogger())
So(err, ShouldBeNil)
Convey("getAllRepos errors", func() {
imageStore1 := mocks.MockedImageStore{
GetRepositoriesFn: func() ([]string, error) {
return []string{"repo1", "repo2"}, nil
},
}
imageStore2 := mocks.MockedImageStore{
GetRepositoriesFn: func() ([]string, error) {
return nil, ErrTestError
},
}
storeController := storage.StoreController{
DefaultStore: imageStore1,
SubStore: map[string]storageTypes.ImageStore{
"a": imageStore2,
},
}
err := meta.ParseStorage(metaDB, storeController, log.NewTestLogger())
So(err, ShouldNotBeNil)
})
Convey("metaDB.GetAllRepoNames errors", func() {
metaDB.GetAllRepoNamesFn = func() ([]string, error) { return nil, ErrTestError }
err := meta.ParseStorage(metaDB, storeController, log.NewTestLogger())
So(err, ShouldNotBeNil)
})
Convey("metaDB.DeleteRepoMeta errors", func() {
imageStore1 := mocks.MockedImageStore{
GetRepositoriesFn: func() ([]string, error) { return []string{"repo1", "repo2"}, nil },
}
storeController := storage.StoreController{DefaultStore: imageStore1}
metaDB.GetAllRepoNamesFn = func() ([]string, error) { return []string{"deleted"}, nil }
metaDB.DeleteRepoMetaFn = func(repo string) error { return ErrTestError }
err := meta.ParseStorage(metaDB, storeController, log.NewTestLogger())
So(err, ShouldNotBeNil)
})
Convey("StatIndex errors", func() {
imageStore1 := mocks.MockedImageStore{
GetRepositoriesFn: func() ([]string, error) { return []string{"repo1", "repo2"}, nil },
}
imageStore1.StatIndexFn = func(repo string) (bool, int64, time.Time, error) {
return false, 0, time.Time{}, ErrTestError
}
storeController := storage.StoreController{DefaultStore: imageStore1}
err := meta.ParseStorage(metaDB, storeController, log.NewTestLogger())
So(err, ShouldBeNil)
})
})
Convey("Parse Repo", t, func() {
imageStore := mocks.MockedImageStore{}
storeController := storage.StoreController{DefaultStore: &imageStore}
metaDB := mocks.MetaDBMock{}
log := log.NewTestLogger()
Convey("imageStore.GetIndexContent errors", func() {
imageStore.GetIndexContentFn = func(repo string) ([]byte, error) {
return nil, ErrTestError
}
err := meta.ParseRepo("repo", metaDB, storeController, log)
So(err, ShouldNotBeNil)
})
Convey("json.Unmarshal errors", func() {
imageStore.GetIndexContentFn = func(repo string) ([]byte, error) {
return []byte("Invalid JSON"), nil
}
err := meta.ParseRepo("repo", metaDB, storeController, log)
So(err, ShouldNotBeNil)
})
Convey("resetRepoReferences errors", func() {
imageStore.GetIndexContentFn = func(repo string) ([]byte, error) {
return []byte("{}"), nil
}
metaDB.ResetRepoReferencesFn = func(repo string, tagsToKeep map[string]bool) error { return ErrTestError }
err := meta.ParseRepo("repo", metaDB, storeController, log)
So(err, ShouldNotBeNil)
})
Convey("zcommon.IsReferrersTag", func() {
imageStore.GetIndexContentFn = func(repo string) ([]byte, error) {
return getIndexBlob(ispec.Index{
Manifests: []ispec.Descriptor{
{
MediaType: ispec.MediaTypeImageManifest,
Digest: godigest.FromString("digest"),
Annotations: map[string]string{
ispec.AnnotationRefName: "sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
},
},
}), nil
}
err := meta.ParseRepo("repo", metaDB, storeController, log)
So(err, ShouldBeNil)
})
Convey("imageStore.GetBlobContent non-missing error", func() {
manifestDigest := godigest.FromString("digest")
imageStore.GetIndexContentFn = func(repo string) ([]byte, error) {
return getIndexBlob(ispec.Index{
Manifests: []ispec.Descriptor{
{
MediaType: ispec.MediaTypeImageManifest,
Digest: manifestDigest,
Annotations: map[string]string{
ispec.AnnotationRefName: "tag",
},
},
},
}), nil
}
imageStore.GetBlobContentFn = func(repo string, digest godigest.Digest) ([]byte, error) {
// Return a non-missing error (not ErrBlobNotFound or PathNotFoundError)
return nil, ErrTestError
}
err := meta.ParseRepo("repo", metaDB, storeController, log)
So(err, ShouldNotBeNil)
So(err, ShouldEqual, ErrTestError)
})
Convey("imageStore.GetImageManifest missing blob - graceful handling", func() {
digest1 := godigest.FromString("digest1")
digest2 := godigest.FromString("digest2")
imageStore.GetIndexContentFn = func(repo string) ([]byte, error) {
return getIndexBlob(ispec.Index{
Manifests: []ispec.Descriptor{
{
MediaType: ispec.MediaTypeImageManifest,
Digest: digest1,
Annotations: map[string]string{
ispec.AnnotationRefName: "tag1",
},
},
{
MediaType: ispec.MediaTypeImageManifest,
Digest: digest2,
Annotations: map[string]string{
ispec.AnnotationRefName: "tag2",
},
},
},
}), nil
}
callCount := 0
setRepoRefCount := 0
// Create a valid image for the second manifest
validImage := CreateRandomImage()
manifestBlob, _ := json.Marshal(validImage.Manifest)
configBlob, _ := json.Marshal(validImage.Config)
imageStore.GetBlobContentFn = func(repo string, digest godigest.Digest) ([]byte, error) {
callCount++
// First manifest is missing, second one succeeds
if digest == digest1 {
return nil, zerr.ErrBlobNotFound
}
if digest == digest2 {
// Return valid manifest for second one
return manifestBlob, nil
}
// Return config blob when requested
if digest == validImage.ConfigDescriptor.Digest {
return configBlob, nil
}
return nil, zerr.ErrBlobNotFound
}
metaDB.SetRepoReferenceFn = func(ctx context.Context, repo, reference string, imageMeta mTypes.ImageMeta) error {
setRepoRefCount++
// Verify it's only called for tag2 (the second manifest)
So(reference, ShouldEqual, "tag2")
return nil
}
err := meta.ParseRepo("repo", metaDB, storeController, log)
So(err, ShouldBeNil)
// Should have called GetBlobContent for both manifests (and config)
So(callCount, ShouldEqual, 3)
// Should have called SetRepoReference only once for the second manifest (first was skipped)
So(setRepoRefCount, ShouldEqual, 1)
})
Convey("manifestMetaIsPresent true", func() {
indexContent := ispec.Index{
Manifests: []ispec.Descriptor{
{
Digest: godigest.FromString("manifest1"),
MediaType: ispec.MediaTypeImageManifest,
Annotations: map[string]string{
ispec.AnnotationRefName: "tag1",
},
},
},
}
indexBlob, err := json.Marshal(indexContent)
So(err, ShouldBeNil)
imageStore.GetIndexContentFn = func(repo string) ([]byte, error) {
return indexBlob, nil
}
Convey("metaDB.SetRepoReference", func() {
metaDB.SetRepoReferenceFn = func(ctx context.Context, repo, reference string, imageMeta mTypes.ImageMeta) error {
return ErrTestError
}
err = meta.ParseRepo("repo", metaDB, storeController, log)
So(err, ShouldNotBeNil)
})
})
})
image := CreateRandomImage()
Convey("SetImageMetaFromInput errors", t, func() {
mockImageStore := mocks.MockedImageStore{}
mockedMetaDB := mocks.MetaDBMock{}
log := log.NewTestLogger()
Convey("Image Manifest errors", func() {
Convey("Get Config blob error", func() {
mockImageStore.GetBlobContentFn = func(repo string, digest godigest.Digest) ([]byte, error) {
return []byte{}, ErrTestError
}
err := meta.SetImageMetaFromInput(ctx, "repo", "tag", ispec.MediaTypeImageManifest, image.Digest(),
image.ManifestDescriptor.Data, mockImageStore, mockedMetaDB, log)
So(err, ShouldNotBeNil)
})
Convey("Unmarshal config blob error", func() {
mockImageStore.GetBlobContentFn = func(repo string, digest godigest.Digest) ([]byte, error) {
return []byte("bad-blob"), nil
}
err := meta.SetImageMetaFromInput(ctx, "repo", "tag", ispec.MediaTypeImageManifest, image.Digest(),
image.ManifestDescriptor.Data, mockImageStore, mockedMetaDB, log)
So(err, ShouldNotBeNil)
})
Convey("Is Signature", func() {
image := CreateDefaultImage()
mediaType := ispec.MediaTypeImageManifest
// it has more than 1 layer
badNotationSignature := CreateImageWith().RandomLayers(2, 10).EmptyConfig().Subject(image.DescriptorRef()).
ArtifactType(zcommon.ArtifactTypeNotation).Build()
goodNotationSignature := CreateMockNotationSignature(image.DescriptorRef())
Convey("GetSignatureLayersInfo errors", func() {
err := meta.SetImageMetaFromInput(ctx, "repo", "tag", mediaType, badNotationSignature.Digest(),
badNotationSignature.ManifestDescriptor.Data, mockImageStore, mockedMetaDB, log)
So(err, ShouldNotBeNil)
})
Convey("UpdateSignaturesValidity errors", func() {
mockedMetaDB.UpdateSignaturesValidityFn = func(ctx context.Context, repo string,
manifestDigest godigest.Digest,
) error {
return ErrTestError
}
err := meta.SetImageMetaFromInput(ctx, "repo", "tag", mediaType, goodNotationSignature.Digest(),
goodNotationSignature.ManifestDescriptor.Data, mockImageStore, mockedMetaDB, log)
So(err, ShouldNotBeNil)
})
})
})
Convey("Image Index errors", func() {
Convey("Unmarshal error", func() {
err := meta.SetImageMetaFromInput(ctx, "repo", "tag", ispec.MediaTypeImageIndex, "",
[]byte("bad-json"), mockImageStore, mockedMetaDB, log)
So(err, ShouldNotBeNil)
})
})
})
}
func getIndexBlob(index ispec.Index) []byte {
index.MediaType = ispec.MediaTypeImageIndex
blob, err := json.Marshal(index)
if err != nil {
panic("image index should always be marshable")
}
return blob
}
func TestParseStorageWithBoltDB(t *testing.T) {
Convey("Boltdb", t, func() {
rootDir := t.TempDir()
log := log.NewLogger("debug", "/dev/null")
boltDB, err := boltdb.GetBoltDriver(boltdb.DBParameters{
RootDir: rootDir,
})
So(err, ShouldBeNil)
metaDB, err := boltdb.New(boltDB, log)
So(err, ShouldBeNil)
RunParseStorageTests(rootDir, metaDB, log)
})
}
func TestParseStorageWithRedisDB(t *testing.T) {
Convey("Redis", t, func() {
miniRedis := miniredis.RunT(t)
rootDir := t.TempDir()
log := log.NewTestLogger()
params := redis.DBDriverParameters{KeyPrefix: "zot"}
driverConfig := map[string]any{"url": "redis://" + miniRedis.Addr()}
redisDriver, err := rediscfg.GetRedisClient(driverConfig, log)
So(err, ShouldBeNil)
metaDB, err := redis.New(redisDriver, params, log)
So(metaDB, ShouldNotBeNil)
So(err, ShouldBeNil)
RunParseStorageTests(rootDir, metaDB, log)
})
}
func TestParseStorageDynamoWrapper(t *testing.T) {
tskip.SkipDynamo(t)
Convey("Dynamodb", t, func() {
rootDir := t.TempDir()
log := log.NewLogger("debug", "/dev/null")
params := dynamodb.DBDriverParameters{
Endpoint: os.Getenv("DYNAMODBMOCK_ENDPOINT"),
Region: "us-east-2",
RepoMetaTablename: "RepoMetadataTable",
RepoBlobsInfoTablename: "RepoBlobsInfoTablename",
ImageMetaTablename: "ImageMetaTablename",
UserDataTablename: "UserDataTable",
APIKeyTablename: "ApiKeyTable",
VersionTablename: "Version",
}
dynamoClient, err := dynamodb.GetDynamoClient(params)
So(err, ShouldBeNil)
dynamoWrapper, err := dynamodb.New(dynamoClient, params, log)
So(err, ShouldBeNil)
err = dynamoWrapper.ResetTable(dynamoWrapper.RepoMetaTablename)
So(err, ShouldBeNil)
err = dynamoWrapper.ResetTable(dynamoWrapper.RepoBlobsTablename)
So(err, ShouldBeNil)
err = dynamoWrapper.ResetTable(dynamoWrapper.ImageMetaTablename)
So(err, ShouldBeNil)
RunParseStorageTests(rootDir, dynamoWrapper, log)
})
}
func RunParseStorageTests(rootDir string, metaDB mTypes.MetaDB, log log.Logger) {
ctx := context.Background()
Convey("Test with simple case", func() {
imageStore := local.NewImageStore(rootDir, false, false,
log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil)
storeController := storage.StoreController{DefaultStore: imageStore}
manifests := []ispec.Manifest{}
for i := range 3 {
image := CreateRandomImage() //nolint:staticcheck
manifests = append(manifests, image.Manifest)
err := WriteImageToFileSystem(
image, repo, fmt.Sprintf("tag%d", i), storeController)
So(err, ShouldBeNil)
}
// add fake signature for tag1
signatureTag, err := signature.GetCosignSignatureTagForManifest(manifests[1])
So(err, ShouldBeNil)
manifestBlob, err := json.Marshal(manifests[1])
So(err, ShouldBeNil)
signedManifestDigest := godigest.FromBytes(manifestBlob)
image := CreateRandomImage()
err = WriteImageToFileSystem(image, repo, signatureTag, storeController)
So(err, ShouldBeNil)
// remove tag2 from index.json
indexPath := path.Join(rootDir, repo, "index.json")
indexFile, err := os.Open(indexPath)
So(err, ShouldBeNil)
buf, err := io.ReadAll(indexFile)
So(err, ShouldBeNil)
var index ispec.Index
if err = json.Unmarshal(buf, &index); err == nil {
for _, manifest := range index.Manifests {
if val, ok := manifest.Annotations[ispec.AnnotationRefName]; ok && val == "tag2" {
delete(manifest.Annotations, ispec.AnnotationRefName)
break
}
}
}
buf, err = json.Marshal(index)
So(err, ShouldBeNil)
err = os.WriteFile(indexPath, buf, 0o600)
So(err, ShouldBeNil)
err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
repos, err := metaDB.GetMultipleRepoMeta(ctx,
func(repoMeta mTypes.RepoMeta) bool { return true })
So(err, ShouldBeNil)
So(len(repos), ShouldEqual, 1)
So(len(repos[0].Tags), ShouldEqual, 2)
for tag, descriptor := range repos[0].Tags {
imageManifestData, err := metaDB.GetFullImageMeta(ctx, repo, tag)
So(err, ShouldBeNil)
if descriptor.Digest == signedManifestDigest.String() {
So(imageManifestData.Signatures, ShouldNotBeEmpty)
}
}
})
Convey("Accept orphan signatures", func() {
imageStore := local.NewImageStore(rootDir, false, false,
log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil)
storeController := storage.StoreController{DefaultStore: imageStore}
// add an image
image := CreateRandomImage()
err := WriteImageToFileSystem(image, repo, "tag1", storeController)
So(err, ShouldBeNil)
// add mock cosign signature without pushing the signed image
image = CreateRandomImage()
So(err, ShouldBeNil)
signatureTag, err := signature.GetCosignSignatureTagForManifest(image.Manifest)
So(err, ShouldBeNil)
missingImageDigest := image.ManifestDescriptor.Digest
// get the body of the signature
signature := CreateRandomImage()
err = WriteImageToFileSystem(signature, repo, signatureTag, storeController)
So(err, ShouldBeNil)
err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
repos, err := metaDB.GetMultipleRepoMeta(ctx,
func(repoMeta mTypes.RepoMeta) bool { return true })
So(err, ShouldBeNil)
for _, desc := range repos[0].Tags {
So(desc.Digest, ShouldNotResemble, missingImageDigest.String())
}
So(len(repos), ShouldEqual, 1)
So(repos[0].Tags, ShouldContainKey, "tag1")
So(repos[0].Tags, ShouldNotContainKey, signatureTag)
So(repos[0].Signatures, ShouldContainKey, missingImageDigest.String())
})
Convey("Check statistics after load", func() {
imageStore := local.NewImageStore(rootDir, false, false,
log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil)
storeController := storage.StoreController{DefaultStore: imageStore}
// add an image
image := CreateRandomImage() //nolint:staticcheck
err := WriteImageToFileSystem(image, repo, "tag", storeController)
So(err, ShouldBeNil)
err = metaDB.SetRepoReference(ctx, repo, "tag", image.AsImageMeta())
So(err, ShouldBeNil)
err = metaDB.IncrementRepoStars(repo)
So(err, ShouldBeNil)
err = metaDB.UpdateStatsOnDownload(repo, "tag")
So(err, ShouldBeNil)
err = metaDB.UpdateStatsOnDownload(repo, "tag")
So(err, ShouldBeNil)
err = metaDB.UpdateStatsOnDownload(repo, "tag")
So(err, ShouldBeNil)
repoMeta, err := metaDB.GetRepoMeta(ctx, repo)
So(err, ShouldBeNil)
So(repoMeta.Statistics[image.DigestStr()].DownloadCount, ShouldEqual, 3)
So(repoMeta.StarCount, ShouldEqual, 1)
So(time.Now(), ShouldHappenAfter, repoMeta.Statistics[image.DigestStr()].LastPullTimestamp)
err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
repoMeta, err = metaDB.GetRepoMeta(ctx, repo)
So(err, ShouldBeNil)
So(repoMeta.Statistics[image.DigestStr()].DownloadCount, ShouldEqual, 3)
So(repoMeta.StarCount, ShouldEqual, 1)
})
Convey("preserve TaggedTimestamp during ParseRepo", func() {
imageStore := local.NewImageStore(rootDir, false, false,
log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil)
storeController := storage.StoreController{DefaultStore: imageStore}
// Create images with tags
for i := range 2 {
image := CreateRandomImage() //nolint:staticcheck
err := WriteImageToFileSystem(
image, repo, fmt.Sprintf("tag%d", i), storeController)
So(err, ShouldBeNil)
}
// Initial parse to set up metadata
err := meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
// Get initial TaggedTimestamp values
repoMeta, err := metaDB.GetRepoMeta(ctx, repo)
So(err, ShouldBeNil)
So(repoMeta.Tags, ShouldContainKey, "tag0")
So(repoMeta.Tags, ShouldContainKey, "tag1")
tag0Timestamp := repoMeta.Tags["tag0"].TaggedTimestamp
tag1Timestamp := repoMeta.Tags["tag1"].TaggedTimestamp
// Verify timestamps are not zero
So(tag0Timestamp.IsZero(), ShouldBeFalse)
So(tag1Timestamp.IsZero(), ShouldBeFalse)
// Re-parse the storage (simulating service restart)
err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
// Verify TaggedTimestamp values are preserved
repoMeta, err = metaDB.GetRepoMeta(ctx, repo)
So(err, ShouldBeNil)
So(repoMeta.Tags, ShouldContainKey, "tag0")
So(repoMeta.Tags, ShouldContainKey, "tag1")
So(repoMeta.Tags["tag0"].TaggedTimestamp, ShouldEqual, tag0Timestamp)
So(repoMeta.Tags["tag1"].TaggedTimestamp, ShouldEqual, tag1Timestamp)
})
// make sure pushTimestamp is always populated to not interfere with retention logic
Convey("Always update pushTimestamp if its value is 0(time.Time{})", func() {
imageStore := local.NewImageStore(rootDir, false, false,
log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil)
storeController := storage.StoreController{DefaultStore: imageStore}
// add an image
image := CreateRandomImage() //nolint:staticcheck
err := WriteImageToFileSystem(image, repo, "tag", storeController)
So(err, ShouldBeNil)
err = metaDB.SetRepoReference(ctx, repo, "tag", image.AsImageMeta())
So(err, ShouldBeNil)
err = metaDB.UpdateStatsOnDownload(repo, "tag")
So(err, ShouldBeNil)
repoMeta, err := metaDB.GetRepoMeta(ctx, repo)
So(err, ShouldBeNil)
So(repoMeta.Statistics[image.DigestStr()].DownloadCount, ShouldEqual, 1)
So(time.Now(), ShouldHappenAfter, repoMeta.Statistics[image.DigestStr()].LastPullTimestamp)
So(time.Now(), ShouldHappenAfter, repoMeta.Statistics[image.DigestStr()].PushTimestamp)
// update statistics (simulate that a metaDB has statistics, but pushTimestamp is 0)
stats := repoMeta.Statistics[image.DigestStr()]
oldPushTimestamp := stats.PushTimestamp
stats.PushTimestamp = time.Time{}
repoMeta.Statistics[image.DigestStr()] = stats
err = metaDB.SetRepoMeta(repo, repoMeta)
So(err, ShouldBeNil)
// metaDB should detect that pushTimestamp is 0 and update it.
err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
repoMeta, err = metaDB.GetRepoMeta(ctx, repo)
So(err, ShouldBeNil)
So(repoMeta.Statistics[image.DigestStr()].DownloadCount, ShouldEqual, 1)
So(repoMeta.DownloadCount, ShouldEqual, 1)
So(repoMeta.Statistics[image.DigestStr()].PushTimestamp, ShouldHappenAfter, oldPushTimestamp)
})
Convey("Parse 2 times and check correct update of the metaDB for modified and deleted repos", func() {
storeController := ociutils.GetDefaultStoreController(rootDir, log)
notModifiedRepo := "not-modified-repo"
modifiedAddImageRepo := "modified-add-image-repo"
modifiedRemoveImageRepo := "modified-remove-image-repo"
deletedRepo := "deleted-repo"
addedRepo := "added-repo"
tag := "tag"
tag2 := "tag2"
newTag := "newTag"
image := CreateRandomImage()
err := WriteImageToFileSystem(image, notModifiedRepo, tag, storeController)
So(err, ShouldBeNil)
err = WriteImageToFileSystem(image, modifiedAddImageRepo, tag, storeController)
So(err, ShouldBeNil)
err = WriteImageToFileSystem(image, modifiedRemoveImageRepo, tag, storeController)
So(err, ShouldBeNil)
err = WriteImageToFileSystem(image, modifiedRemoveImageRepo, tag2, storeController)
So(err, ShouldBeNil)
err = WriteImageToFileSystem(image, deletedRepo, tag, storeController)
So(err, ShouldBeNil)
err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
repoMetaList, err := metaDB.SearchRepos(ctx, "")
So(err, ShouldBeNil)
So(len(repoMetaList), ShouldEqual, 4)
repoNames := tcommon.AccumulateField(repoMetaList, func(rm mTypes.RepoMeta) string { return rm.Name })
So(repoNames, ShouldContain, notModifiedRepo)
So(repoNames, ShouldContain, modifiedAddImageRepo)
So(repoNames, ShouldContain, modifiedRemoveImageRepo)
So(repoNames, ShouldContain, deletedRepo)
time.Sleep(time.Second)
// Update the storage
err = WriteImageToFileSystem(image, modifiedAddImageRepo, newTag, storeController)
So(err, ShouldBeNil)
err = storeController.GetDefaultImageStore().DeleteImageManifest(modifiedRemoveImageRepo, tag2, false)
So(err, ShouldBeNil)
err = os.RemoveAll(filepath.Join(rootDir, deletedRepo))
So(err, ShouldBeNil)
err = WriteImageToFileSystem(image, addedRepo, tag, storeController)
So(err, ShouldBeNil)
// Parse again
err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
repoMetaList, err = metaDB.SearchRepos(ctx, "")
So(err, ShouldBeNil)
So(len(repoMetaList), ShouldEqual, 4)
repoNames = tcommon.AccumulateField(repoMetaList, func(rm mTypes.RepoMeta) string { return rm.Name })
So(repoNames, ShouldContain, notModifiedRepo)
So(repoNames, ShouldContain, modifiedAddImageRepo)
So(repoNames, ShouldContain, modifiedRemoveImageRepo)
So(repoNames, ShouldNotContain, deletedRepo)
So(repoNames, ShouldContain, addedRepo)
repoMeta, err := metaDB.GetRepoMeta(ctx, modifiedAddImageRepo)
So(err, ShouldBeNil)
So(repoMeta.Tags, ShouldContainKey, tag)
So(repoMeta.Tags, ShouldContainKey, newTag)
repoMeta, err = metaDB.GetRepoMeta(ctx, modifiedRemoveImageRepo)
So(err, ShouldBeNil)
So(repoMeta.Tags, ShouldContainKey, tag)
So(repoMeta.Tags, ShouldNotContainKey, tag2)
})
Convey("Test no duplicate repos with substores and nested repo names", func() {
// Create nested directories - substore is a subdirectory of default store
defaultStoreDir := rootDir
substoreDir := filepath.Join(rootDir, "a")
defaultStore := local.NewImageStore(defaultStoreDir, false, false,
log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil)
substore := local.NewImageStore(substoreDir, false, false,
log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil)
storeController := storage.StoreController{
DefaultStore: defaultStore,
SubStore: map[string]storageTypes.ImageStore{
"/a": substore,
},
}
// Create a repo in default store (regular repo name, no route prefix)
defaultRepo := "repo-in-default"
image1 := CreateRandomImage()
err := WriteImageToFileSystem(image1, defaultRepo, "tag1", storeController)
So(err, ShouldBeNil)
// Create repos in substore (these will be returned by substore.GetRepositories())
// Repos in substore should have the "a" prefix to match the substore route
substoreRepo1 := "a/repo-in-substore-1"
substoreRepo2 := "a/repo-in-substore-2"
image2 := CreateRandomImage()
err = WriteImageToFileSystem(image2, substoreRepo1, "tag1", storeController)
So(err, ShouldBeNil)
image3 := CreateRandomImage()
err = WriteImageToFileSystem(image3, substoreRepo2, "tag1", storeController)
So(err, ShouldBeNil)
// Parse storage
err = meta.ParseStorage(metaDB, storeController, log)
So(err, ShouldBeNil)
// Get all repos from metaDB
repoMetaList, err := metaDB.SearchRepos(ctx, "")
So(err, ShouldBeNil)
// Collect all repo names and count occurrences
repoNames := make(map[string]int)
for _, repoMeta := range repoMetaList {
repoNames[repoMeta.Name]++
}
// Verify expected repos are present
// Substore repos are processed first (with "a/" prefix), then default store repos
expectedRepos := []string{substoreRepo1, substoreRepo2, defaultRepo}
for _, expectedRepo := range expectedRepos {
So(repoNames, ShouldContainKey, expectedRepo)
}
// Verify no duplicates - each repo should appear exactly once
for _, count := range repoNames {
So(count, ShouldEqual, 1)
}
// Verify total count - should be 3 repos:
// - substoreRepo1, substoreRepo2 (from substore with "a/" prefix)
// - defaultRepo (from default store, no prefix)
So(len(repoMetaList), ShouldEqual, 3)
})
Convey("ParseStorage should parse repos without metadata", func() {
imageStore := local.NewImageStore(rootDir, false, false,
log, monitoring.NewMetricsServer(false, log), nil, nil, nil, nil)
storeController := storage.StoreController{DefaultStore: imageStore}
// Create a repo in storage
testRepo := "repo-without-metadata"
// Ensure repo doesn't exist in metadata (clean up from previous test runs if needed)
err := metaDB.DeleteRepoMeta(testRepo)
So(err, ShouldBeNil)
// Verify GetRepoLastUpdated returns zero (repo doesn't exist in metadata)
metaLastUpdated := metaDB.GetRepoLastUpdated(testRepo)
So(metaLastUpdated.IsZero(), ShouldBeTrue)
image := CreateRandomImage()
err = WriteImageToFileSystem(image, testRepo, "tag1", storeController)
So(err, ShouldBeNil)
// Verify repo still doesn't exist in metadata (GetRepoMeta should return ErrRepoMetaNotFound)
_, err = metaDB.GetRepoMeta(ctx, testRepo)
So(err, ShouldNotBeNil)
So(errors.Is(err, zerr.ErrRepoMetaNotFound), ShouldBeTrue)
// Verify GetRepoLastUpdated still returns zero
metaLastUpdated = metaDB.GetRepoLastUpdated(testRepo)
So(metaLastUpdated.IsZero(), ShouldBeTrue)
// Parse storage - repos without metadata (zero time) are always parsed
// Note: This behavior is the same with or without the !metaLastUpdated.IsZero() guard
// because storageLastUpdated.Before(time.Time{}) is always false for valid timestamps
err = meta.ParseStorage(metaDB, storeController, log) //nolint: contextcheck
So(err, ShouldBeNil)
// Verify repo metadata was created
repoMeta, err := metaDB.GetRepoMeta(ctx, testRepo)
So(err, ShouldBeNil)
So(repoMeta.Name, ShouldEqual, testRepo)
So(repoMeta.Tags, ShouldContainKey, "tag1")
// Verify GetRepoLastUpdated now returns a non-zero time
metaLastUpdated = metaDB.GetRepoLastUpdated(testRepo)
So(metaLastUpdated.IsZero(), ShouldBeFalse)
})
}
func TestGetSignatureLayersInfo(t *testing.T) {
Convey("wrong signature type", t, func() {
layers, err := meta.GetSignatureLayersInfo("repo", "tag", "123", "wrong signature type", []byte{},
nil, log.NewTestLogger())
So(err, ShouldBeNil)
So(layers, ShouldBeEmpty)
})
Convey("notation index", t, func() {
notationIndex := ispec.Index{
MediaType: ispec.MediaTypeImageIndex,
}
notationIndexBlob, err := json.Marshal(notationIndex)
So(err, ShouldBeNil)
layers, err := meta.GetSignatureLayersInfo("repo", "tag", "123", zcommon.NotationSignature, notationIndexBlob,
nil, log.NewTestLogger())
So(err, ShouldBeNil)
So(layers, ShouldBeEmpty)
})
Convey("GetBlobContent errors", t, func() {
mockImageStore := mocks.MockedImageStore{}
mockImageStore.GetBlobContentFn = func(repo string, digest godigest.Digest) ([]byte, error) {
return nil, ErrTestError
}
image := CreateRandomImage()
layers, err := meta.GetSignatureLayersInfo("repo", "tag", "123", zcommon.CosignSignature,
image.ManifestDescriptor.Data, mockImageStore, log.NewTestLogger())
So(err, ShouldNotBeNil)
So(layers, ShouldBeEmpty)
})
Convey("notation len(manifestContent.Layers) != 1", t, func() {
mockImageStore := mocks.MockedImageStore{}
image := CreateImageWith().RandomLayers(3, 10).RandomConfig().Build()
layers, err := meta.GetSignatureLayersInfo("repo", "tag", "123", zcommon.NotationSignature,
image.ManifestDescriptor.Data, mockImageStore, log.NewTestLogger())
So(err, ShouldNotBeNil)
So(layers, ShouldBeEmpty)
})
Convey("notation GetBlobContent errors", t, func() {
mockImageStore := mocks.MockedImageStore{}
mockImageStore.GetBlobContentFn = func(repo string, digest godigest.Digest) ([]byte, error) {
return nil, ErrTestError
}
image := CreateImageWith().RandomLayers(1, 10).RandomConfig().Build()
layers, err := meta.GetSignatureLayersInfo("repo", "tag", "123", zcommon.NotationSignature,
image.ManifestDescriptor.Data, mockImageStore, log.NewTestLogger())
So(err, ShouldNotBeNil)
So(layers, ShouldBeEmpty)
})
Convey("error while unmarshaling manifest content", t, func() {
_, err := meta.GetSignatureLayersInfo("repo", "tag", "123", zcommon.CosignSignature, []byte("bad manifest"),
nil, log.NewTestLogger())
So(err, ShouldNotBeNil)
_, err = meta.GetSignatureLayersInfo("repo", "tag", "123", zcommon.NotationSignature, []byte("bad manifest"),
nil, log.NewTestLogger())
So(err, ShouldNotBeNil)
})
}