feat(sync): add SyncLegacyCosignTags config to skip syncing legacy cosign/SBOM tags when disabled (#3842)

* feat(sync): add SyncLegacyCosignTags config to skip syncing legacy cosign/SBOM tags when disabled

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>

* fix: sync on demand with referrers API should not use recursion to sync referrers of referrers

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>

* fix: add tests SyncLegacyCosignTags and changes in /referrers on demand sync

Credit for the tests goes to @jzhn see:
https://github.com/project-zot/zot/pull/3840/changes

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>

* fix: remove redundant syncRef logic which synced referrers both with the zot inner() implementation and with regctl native implementation

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>

---------

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
This commit is contained in:
Andrei Aaron
2026-03-06 19:12:31 +02:00
committed by GitHub
parent bb121c3b76
commit 6f67fcdf8f
5 changed files with 875 additions and 50 deletions
+7
View File
@@ -32,12 +32,19 @@ type RegistryConfig struct {
MaxRetries *int
RetryDelay *time.Duration
OnlySigned *bool
SyncLegacyCosignTags *bool // when unset, defaults to true
CredentialHelper string
PreserveDigest bool // sync without converting
SyncTimeout time.Duration // overall HTTP client timeout for all sync operations
ResponseHeaderTimeout time.Duration `yaml:"-"` // response header timeout; set in root.go
}
// ShouldSyncLegacyCosignTags returns whether to sync legacy cosign tags (e.g. sha256-<digest>.sig/sbom).
// Default is true when SyncLegacyCosignTags is unset (nil).
func (r RegistryConfig) ShouldSyncLegacyCosignTags() bool {
return r.SyncLegacyCosignTags == nil || *r.SyncLegacyCosignTags
}
type Content struct {
Prefix string
Tags *Tags
+31
View File
@@ -0,0 +1,31 @@
package sync_test
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
syncconf "zotregistry.dev/zot/v2/pkg/extensions/config/sync"
)
func TestRegistryConfig_ShouldSyncLegacyCosignTags(t *testing.T) {
Convey("ShouldSyncLegacyCosignTags", t, func() {
Convey("returns true when SyncLegacyCosignTags is nil (default)", func() {
cfg := syncconf.RegistryConfig{}
So(cfg.SyncLegacyCosignTags, ShouldBeNil)
So(cfg.ShouldSyncLegacyCosignTags(), ShouldBeTrue)
})
Convey("returns true when SyncLegacyCosignTags is true", func() {
v := true
cfg := syncconf.RegistryConfig{SyncLegacyCosignTags: &v}
So(cfg.ShouldSyncLegacyCosignTags(), ShouldBeTrue)
})
Convey("returns false when SyncLegacyCosignTags is false", func() {
v := false
cfg := syncconf.RegistryConfig{SyncLegacyCosignTags: &v}
So(cfg.ShouldSyncLegacyCosignTags(), ShouldBeFalse)
})
})
}
+58 -49
View File
@@ -343,12 +343,16 @@ func (service *BaseService) SyncReferrers(ctx context.Context, repo string,
service.log.Info().Str("remote", remoteURL).Str("repository", repo).Str("subject", subjectDigestStr).
Interface("reference types", referenceTypes).Msg("syncing reference for image")
tags, err := service.getTags(ctx, remoteRepo, false)
if err != nil {
service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", repo).
Err(err).Msg("error while getting tags for repo")
var tags []string
if service.config.ShouldSyncLegacyCosignTags() {
var err error
tags, err = service.getTags(ctx, remoteRepo, false)
if err != nil {
service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", repo).
Err(err).Msg("error while getting tags for repo")
return err
return err
}
}
remoteImageRef, err := service.remote.GetImageReference(remoteRepo, subjectDigestStr)
@@ -367,7 +371,7 @@ func (service *BaseService) SyncReferrers(ctx context.Context, repo string,
return err
}
if err := service.syncReferrers(ctx, tags, repo, remoteRepo, localImageRef, remoteImageRef); err != nil {
if err := service.syncReferrers(ctx, tags, repo, remoteRepo, localImageRef, remoteImageRef, false); err != nil {
service.log.Error().Err(err).Str("errortype", common.TypeOf(err)).
Str("repo", repo).Str("reference", subjectDigestStr).Msg("failed to sync referrers")
@@ -430,7 +434,7 @@ func (service *BaseService) SyncRepo(ctx context.Context, repo string) error {
return ctx.Err()
}
// skip referrers, they are synced in syncTagAndReferrers.
// skip referrers tags and cosign tags here; they are synced via syncReferrers in syncImage.
if common.IsCosignTag(tag) || common.IsReferrersTag(tag) {
continue
}
@@ -460,7 +464,7 @@ func (service *BaseService) SyncRepo(ctx context.Context, repo string) error {
}
func (service *BaseService) syncRef(ctx context.Context, localRepo string, remoteImageRef, localImageRef ref.Ref,
remoteDigest godigest.Digest, recursive bool,
remoteDigest godigest.Digest,
) (bool, error) {
var reference string
@@ -475,9 +479,6 @@ func (service *BaseService) syncRef(ctx context.Context, localRepo string, remot
}
copyOpts := []regclient.ImageOpts{}
if recursive {
copyOpts = append(copyOpts, regclient.ImageWithReferrers())
}
// check if image is already synced
skipImage, err = service.destination.CanSkipImage(localRepo, reference, remoteDigest)
@@ -570,17 +571,6 @@ func (service *BaseService) syncImage(ctx context.Context, localRepo, remoteRepo
// if onlySigned flag true in config and the image is not itself a signature
if checkIsSigned {
// if need tags for checking signature (onlySigned option true) or needs for referrers
if len(repoTags) == 0 {
repoTags, err = service.getTags(ctx, remoteRepo, false)
if err != nil {
service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", remoteRepo).
Err(err).Msg("error while getting tags for repo")
return err
}
}
referrers, err := service.rc.ReferrerList(ctx, remoteImageRef)
if err != nil {
service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", remoteRepo).
@@ -589,11 +579,24 @@ func (service *BaseService) syncImage(ctx context.Context, localRepo, remoteRepo
return err
}
// verify repo contains a cosign signature for this manifest
hasCosignSignature := slices.Contains(repoTags, fmt.Sprintf("%s-%s.sig", remoteDigest.Algorithm(),
remoteDigest.Encoded()))
isSigned := hasSignatureReferrers(referrers)
if service.config.ShouldSyncLegacyCosignTags() {
// legacy fallback: verify repo contains a cosign signature tag for this manifest
if len(repoTags) == 0 {
repoTags, err = service.getTags(ctx, remoteRepo, false)
if err != nil {
service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", remoteRepo).
Err(err).Msg("error while getting tags for repo")
isSigned := hasSignatureReferrers(referrers) || hasCosignSignature
return err
}
}
hasCosignSignature := slices.Contains(repoTags, fmt.Sprintf("%s-%s.sig", remoteDigest.Algorithm(),
remoteDigest.Encoded()))
isSigned = isSigned || hasCosignSignature
}
if !isSigned {
// skip unsigned images
service.log.Info().Str("image", remoteImageRef.CommonName()).
@@ -617,13 +620,13 @@ func (service *BaseService) syncImage(ctx context.Context, localRepo, remoteRepo
defer service.destination.CleanupImage(localImageRef, localRepo) //nolint: errcheck
// first sync image
skipped, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, localDigest, false)
skipped, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, localDigest)
if err != nil {
return err
}
if withReferrers {
_ = service.syncReferrers(ctx, repoTags, localRepo, remoteRepo, localImageRef, remoteImageRef)
_ = service.syncReferrers(ctx, repoTags, localRepo, remoteRepo, localImageRef, remoteImageRef, true)
}
// convert image to oci if needed
@@ -681,15 +684,15 @@ func (service *BaseService) getTags(ctx context.Context, repo string, noCache bo
return tags, nil
}
// syncs all referrers recursively.
// syncs referrers of the given subject, when recursive is true also syncs referrers of those referrers.
func (service *BaseService) syncReferrers(ctx context.Context, tags []string, localRepo, remoteRepo string,
localImageRef ref.Ref, remoteImageRef ref.Ref,
localImageRef ref.Ref, remoteImageRef ref.Ref, recursive bool,
) error {
seen := []string{}
var err error
if len(tags) == 0 {
if service.config.ShouldSyncLegacyCosignTags() && len(tags) == 0 {
tags, err = service.getTags(ctx, remoteRepo, false)
if err != nil {
service.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", remoteRepo).
@@ -700,11 +703,11 @@ func (service *BaseService) syncReferrers(ctx context.Context, tags []string, lo
}
var inner func(ctx context.Context, tags []string, localRepo, remoteRepo string,
localImageRef ref.Ref, remoteImageRef ref.Ref, seen []string,
localImageRef ref.Ref, remoteImageRef ref.Ref, seen []string, recursive bool,
) error
inner = func(ctx context.Context, tags []string, localRepo, remoteRepo string,
localImageRef ref.Ref, remoteImageRef ref.Ref, seen []string,
localImageRef ref.Ref, remoteImageRef ref.Ref, seen []string, recursive bool,
) error {
var err error
@@ -737,39 +740,45 @@ func (service *BaseService) syncReferrers(ctx context.Context, tags []string, lo
localImageRef = localImageRef.SetDigest(desc.Digest.String())
_, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, desc.Digest, false)
_, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, desc.Digest)
if err != nil {
service.log.Error().Err(err).Str("errortype", common.TypeOf(err)).
Str("repo", localRepo).Str("local reference", localImageRef.Tag).
Str("remote reference", remoteImageRef.Tag).Msg("failed to sync referrer")
}
_ = inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen)
if recursive {
_ = inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen, recursive)
}
}
// try cosign
prefix := fmt.Sprintf("%s-%s.", remoteDigest.Algorithm(), remoteDigest.Encoded())
for _, tag := range tags {
if strings.Contains(tag, prefix) {
remoteImageRef = remoteImageRef.SetTag(tag)
if service.config.ShouldSyncLegacyCosignTags() {
// try legacy cosign-style tags (sha256-<subjectDigest>.<suffix>)
prefix := fmt.Sprintf("%s-%s.", remoteDigest.Algorithm(), remoteDigest.Encoded())
for _, tag := range tags {
if strings.Contains(tag, prefix) {
remoteImageRef = remoteImageRef.SetTag(tag)
localImageRef = localImageRef.SetTag(tag)
localImageRef = localImageRef.SetTag(tag)
_, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, remoteDigest, true)
if err != nil {
service.log.Error().Err(err).Str("errortype", common.TypeOf(err)).
Str("repo", localRepo).Str("local reference", localImageRef.Tag).
Str("remote reference", remoteImageRef.Tag).Msg("failed to sync referrer")
_, err := service.syncRef(ctx, localRepo, remoteImageRef, localImageRef, remoteDigest)
if err != nil {
service.log.Error().Err(err).Str("errortype", common.TypeOf(err)).
Str("repo", localRepo).Str("local reference", localImageRef.Tag).
Str("remote reference", remoteImageRef.Tag).Msg("failed to sync referrer")
}
if recursive {
_ = inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen, recursive)
}
}
_ = inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen)
}
}
return err
}
return inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen)
return inner(ctx, tags, localRepo, remoteRepo, localImageRef, remoteImageRef, seen, recursive)
}
func (service *BaseService) ResetCatalog() {
+144 -1
View File
@@ -18,6 +18,7 @@ import (
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/regclient/regclient"
"github.com/regclient/regclient/types/ref"
. "github.com/smartystreets/goconvey/convey"
@@ -93,6 +94,8 @@ func TestService(t *testing.T) {
})
Convey("test SyncReferrers ReferrerList error", t, func() {
// SyncReferrers is the on-demand API; it calls syncReferrers(..., recursive=false)
// so only direct referrers are synced (no referrers-of-referrers).
conf := syncconf.RegistryConfig{
URLs: []string{"http://localhost"},
}
@@ -250,7 +253,13 @@ func TestService(t *testing.T) {
// Create an invalid remote reference that will cause ReferrerList to fail with "ref is not set" error
remoteImageRef := ref.Ref{}
err = service.syncReferrers(ctx, []string{"tag"}, "localrepo", "remoterepo", localImageRef, remoteImageRef)
err = service.syncReferrers(ctx, []string{"tag"}, "localrepo", "remoterepo", localImageRef, remoteImageRef, false)
// The error should be "ref is not set" as defined in regclient ReferrerList function
So(err, ShouldNotBeNil)
So(err.Error(), ShouldContainSubstring, "ref is not set")
err = service.syncReferrers(ctx, []string{"tag"}, "localrepo", "remoterepo", localImageRef, remoteImageRef, true)
// The error should be "ref is not set" as defined in regclient ReferrerList function
So(err, ShouldNotBeNil)
@@ -635,6 +644,140 @@ func TestService(t *testing.T) {
})
}
func TestSyncLegacyCosignTagsSyncReferrers(t *testing.T) {
Convey("SyncLegacyCosignTags=false skips getTags and the digest-tag loop in SyncReferrers", t, func() {
getTagsCallCount := 0
syncLegacyFalse := false
conf := syncconf.RegistryConfig{
URLs: []string{"http://localhost"},
SyncLegacyCosignTags: &syncLegacyFalse,
}
service, err := New(conf, "", nil, t.TempDir(), storage.StoreController{}, mocks.MetaDBMock{}, log.NewTestLogger())
So(err, ShouldBeNil)
service.rc = regclient.New()
mockRemote := &mocks.SyncRemoteMock{
GetTagsFn: func(ctx context.Context, repo string) ([]string, error) {
getTagsCallCount++
return nil, errors.New("getTags must not be called when SyncLegacyCosignTags=false")
},
GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) {
return ref.New(repo + "@" + tag)
},
GetDigestFn: func(ctx context.Context, repo, tag string) (godigest.Digest, error) {
return godigest.Digest("sha256:" + strings.Repeat("a", 64)), nil
},
}
service.remote = mockRemote
mockDest := &mocks.SyncDestinationMock{
GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) {
return ref.New("local/" + repo + "@" + tag)
},
CommitAllFn: func(repo string, imageReference ref.Ref) error {
return nil
},
}
service.destination = mockDest
ctx := context.Background()
digest := "sha256:" + strings.Repeat("a", 64)
err = service.SyncReferrers(ctx, "repo", digest, nil)
// We expect an error from rc.ReferrerList since it's not connected to a registry,
// but what we really care about is that getTags was NOT called.
So(getTagsCallCount, ShouldEqual, 0)
})
Convey("SyncLegacyCosignTags=true (default) calls getTags in SyncReferrers", t, func() {
getTagsCallCount := 0
syncLegacyTrue := true
conf := syncconf.RegistryConfig{
URLs: []string{"http://localhost"},
SyncLegacyCosignTags: &syncLegacyTrue,
}
service, err := New(conf, "", nil, t.TempDir(), storage.StoreController{}, mocks.MetaDBMock{}, log.NewTestLogger())
So(err, ShouldBeNil)
service.rc = regclient.New()
mockRemote := &mocks.SyncRemoteMock{
GetTagsFn: func(ctx context.Context, repo string) ([]string, error) {
getTagsCallCount++
return []string{"tag1"}, nil
},
GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) {
return ref.New(repo + "@" + tag)
},
}
service.remote = mockRemote
mockDest := &mocks.SyncDestinationMock{
GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) {
return ref.New("local/" + repo + "@" + tag)
},
}
service.destination = mockDest
ctx := context.Background()
digest := "sha256:" + strings.Repeat("a", 64)
err = service.SyncReferrers(ctx, "repo", digest, nil)
// We don't care if it fails, only that getTags was called.
So(getTagsCallCount, ShouldEqual, 1)
})
}
func TestOnDemandSyncReferrersNonRecursive(t *testing.T) {
Convey("SyncReferrers (on-demand) uses non-recursive sync", t, func() {
// Verify that recursive calls are not made.
// We use a non-nil regclient to avoid panics.
syncLegacyFalse := false
conf := syncconf.RegistryConfig{
URLs: []string{"http://localhost"},
SyncLegacyCosignTags: &syncLegacyFalse,
}
service, err := New(conf, "", nil, t.TempDir(), storage.StoreController{}, mocks.MetaDBMock{}, log.NewTestLogger())
So(err, ShouldBeNil)
service.rc = regclient.New()
mockRemote := &mocks.SyncRemoteMock{
GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) {
return ref.New(repo + "@" + tag)
},
GetDigestFn: func(ctx context.Context, repo, tag string) (godigest.Digest, error) {
return godigest.Digest("sha256:" + strings.Repeat("a", 64)), nil
},
GetTagsFn: func(ctx context.Context, repo string) ([]string, error) {
return []string{}, nil
},
}
service.remote = mockRemote
mockDest := &mocks.SyncDestinationMock{
GetImageReferenceFn: func(repo, tag string) (ref.Ref, error) {
return ref.New("local/" + repo + "@" + tag)
},
}
service.destination = mockDest
ctx := context.Background()
digest := "sha256:" + strings.Repeat("a", 64)
err = service.SyncReferrers(ctx, "repo", digest, nil)
// It will fail because rc is not connected, but it shouldn't panic.
So(err, ShouldNotBeNil)
})
}
func TestDestinationRegistry(t *testing.T) {
Convey("make StoreController", t, func() {
dir := t.TempDir()
+635
View File
@@ -5425,6 +5425,304 @@ func TestSyncedSignaturesMetaDB(t *testing.T) {
})
}
func TestSyncLegacyCosignTags(t *testing.T) {
Convey("SyncLegacyCosignTags controls whether legacy cosign/SBOM tags are synced", t, func() {
sctlr, srcBaseURL, srcDir, _ := makeUpstreamServer(t, false, false)
scm := test.NewControllerManager(sctlr)
scm.StartAndWait(sctlr.Config.HTTP.Port)
defer scm.StopServer()
// Get image digest and add legacy SBOM tag on source (sha256-<digest>.sbom)
resp, err := resty.R().Get(srcBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
imageDigestStr := resp.Header().Get("Docker-Content-Digest")
So(imageDigestStr, ShouldNotBeEmpty)
imageDigest, err := godigest.Parse(imageDigestStr)
So(err, ShouldBeNil)
srcPort := getPortFromBaseURL(srcBaseURL)
attachSBOM(srcDir, srcPort, testImage, imageDigest)
legacyTag := imageDigest.Algorithm().String() + "-" + imageDigest.Encoded() + ".sbom"
tlsVerify := false
regex := ".*"
semver := false
Convey("With SyncLegacyCosignTags true (default), legacy tag is synced", func() {
syncLegacyTrue := true
syncRegistryConfig := syncconf.RegistryConfig{
Content: []syncconf.Content{
{Prefix: "**", Tags: &syncconf.Tags{Regex: &regex, Semver: &semver}},
},
URLs: []string{srcBaseURL},
TLSVerify: &tlsVerify,
OnDemand: true,
SyncLegacyCosignTags: &syncLegacyTrue,
}
defaultVal := true
syncConfig := &syncconf.Config{
Enable: &defaultVal,
Registries: []syncconf.RegistryConfig{syncRegistryConfig},
}
dctlr, destBaseURL, destDir, destClient := makeDownstreamServer(t, false, syncConfig)
dcm := test.NewControllerManager(dctlr)
dcm.StartAndWait(dctlr.Config.HTTP.Port)
defer dcm.StopServer()
// Trigger image sync
resp, err = destClient.R().Get(destBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Trigger referrers sync (pulls legacy tags when SyncLegacyCosignTags is true)
resp, err = destClient.R().Get(destBaseURL + "/v2/" + testImage + "/referrers/" + imageDigestStr)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Verify via storage (avoid GET manifest for legacy tag, which can trigger on-demand sync)
indexPath := path.Join(destDir, testImage, "index.json")
found := false
for start := time.Now(); time.Since(start) < 30*time.Second; time.Sleep(250 * time.Millisecond) {
indexBuf, err := os.ReadFile(indexPath)
if err != nil {
continue
}
var destIndex ispec.Index
if json.Unmarshal(indexBuf, &destIndex) != nil {
continue
}
for _, desc := range destIndex.Manifests {
if tag, ok := desc.Annotations[ispec.AnnotationRefName]; ok && tag == legacyTag {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Fatalf("legacy tag %q should be present on destination index", legacyTag)
}
})
Convey("With SyncLegacyCosignTags false, legacy tag is not synced", func() {
syncLegacyFalse := false
pollInterval, _ := time.ParseDuration("5s")
syncRegistryConfig := syncconf.RegistryConfig{
Content: []syncconf.Content{
{Prefix: "**", Tags: &syncconf.Tags{Regex: &regex, Semver: &semver}},
},
URLs: []string{srcBaseURL},
TLSVerify: &tlsVerify,
OnDemand: false,
PollInterval: pollInterval,
SyncLegacyCosignTags: &syncLegacyFalse,
}
defaultVal := true
syncConfig := &syncconf.Config{
Enable: &defaultVal,
Registries: []syncconf.RegistryConfig{syncRegistryConfig},
}
dctlr, destBaseURL, destDir, destClient := makeDownstreamServer(t, false, syncConfig)
dcm := test.NewControllerManager(dctlr)
dcm.StartAndWait(dctlr.Config.HTTP.Port)
defer dcm.StopServer()
// Wait for periodic sync to complete (do not call GET manifest for legacy tag to avoid on-demand sync)
found, err := test.ReadLogFileAndSearchString(dctlr.Config.Log.Output, "finished syncing repo", 30*time.Second)
So(err, ShouldBeNil)
So(found, ShouldBeTrue)
// Poll until testImage repo exists on destination (periodic sync may sync multiple repos)
var resp *resty.Response
for start := time.Now(); time.Since(start) < 30*time.Second; time.Sleep(500 * time.Millisecond) {
resp, err = destClient.R().Get(destBaseURL + "/v2/" + testImage + "/tags/list")
if err == nil && resp != nil && resp.StatusCode() == http.StatusOK {
break
}
}
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Verify via tags/list: legacy tag must not be present
var destTags TagsList
So(json.Unmarshal(resp.Body(), &destTags), ShouldBeNil)
So(destTags.Tags, ShouldNotContain, legacyTag)
// Verify via storage: index.json must not reference the legacy tag
indexPath := path.Join(destDir, testImage, "index.json")
indexBuf, err := os.ReadFile(indexPath)
So(err, ShouldBeNil)
var destIndex ispec.Index
So(json.Unmarshal(indexBuf, &destIndex), ShouldBeNil)
for _, desc := range destIndex.Manifests {
if tag, ok := desc.Annotations[ispec.AnnotationRefName]; ok && tag == legacyTag {
t.Fatalf("legacy tag %q should not be in destination index", legacyTag)
}
}
})
})
}
func TestSyncLegacyCosignTagsWithSignatures(t *testing.T) {
Convey("SyncLegacyCosignTags controls whether legacy cosign signature tags (.sig) are synced", t, func() {
sctlr, srcBaseURL, _, _ := makeUpstreamServer(t, false, false)
scm := test.NewControllerManager(sctlr)
scm.StartAndWait(sctlr.Config.HTTP.Port)
defer scm.StopServer()
// Push image and sign it (adds legacy .sig tag on source)
var digest godigest.Digest
So(func() { digest = pushRepo(srcBaseURL, testSignedImage) }, ShouldNotPanic)
srcPort := getPortFromBaseURL(srcBaseURL)
cwd, err := os.Getwd()
So(err, ShouldBeNil)
defer func() { _ = os.Chdir(cwd) }()
tdir := t.TempDir()
_ = os.Chdir(tdir)
generateKeyPairs(tdir)
So(func() { signImage(tdir, srcPort, testSignedImage, digest) }, ShouldNotPanic)
legacySigTag := digest.Algorithm().String() + "-" + digest.Encoded() + ".sig"
imageDigestStr := digest.String()
tlsVerify := false
regex := ".*"
semver := false
Convey("With SyncLegacyCosignTags true, legacy signature tag is synced", func() {
syncLegacyTrue := true
syncRegistryConfig := syncconf.RegistryConfig{
Content: []syncconf.Content{
{Prefix: "**", Tags: &syncconf.Tags{Regex: &regex, Semver: &semver}},
},
URLs: []string{srcBaseURL},
TLSVerify: &tlsVerify,
OnDemand: true,
SyncLegacyCosignTags: &syncLegacyTrue,
}
defaultVal := true
syncConfig := &syncconf.Config{
Enable: &defaultVal,
Registries: []syncconf.RegistryConfig{syncRegistryConfig},
}
dctlr, destBaseURL, destDir, destClient := makeDownstreamServer(t, false, syncConfig)
dcm := test.NewControllerManager(dctlr)
dcm.StartAndWait(dctlr.Config.HTTP.Port)
defer dcm.StopServer()
// Trigger image sync
resp, err := destClient.R().Get(destBaseURL + "/v2/" + testSignedImage + "/manifests/" + testImageTag)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Trigger referrers sync (pulls legacy .sig when SyncLegacyCosignTags is true)
resp, err = destClient.R().Get(destBaseURL + "/v2/" + testSignedImage + "/referrers/" + imageDigestStr)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Verify via storage (avoid GET manifest for legacy tag, which can trigger on-demand sync)
indexPath := path.Join(destDir, testSignedImage, "index.json")
found := false
for start := time.Now(); time.Since(start) < 30*time.Second; time.Sleep(250 * time.Millisecond) {
indexBuf, err := os.ReadFile(indexPath)
if err != nil {
continue
}
var destIndex ispec.Index
if json.Unmarshal(indexBuf, &destIndex) != nil {
continue
}
for _, desc := range destIndex.Manifests {
if tag, ok := desc.Annotations[ispec.AnnotationRefName]; ok && tag == legacySigTag {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Fatalf("legacy signature tag %q should be present on destination index", legacySigTag)
}
})
Convey("With SyncLegacyCosignTags false, legacy signature tag is not synced", func() {
syncLegacyFalse := false
pollInterval, _ := time.ParseDuration("5s")
syncRegistryConfig := syncconf.RegistryConfig{
Content: []syncconf.Content{
{Prefix: "**", Tags: &syncconf.Tags{Regex: &regex, Semver: &semver}},
},
URLs: []string{srcBaseURL},
TLSVerify: &tlsVerify,
OnDemand: false,
PollInterval: pollInterval,
SyncLegacyCosignTags: &syncLegacyFalse,
}
defaultVal := true
syncConfig := &syncconf.Config{
Enable: &defaultVal,
Registries: []syncconf.RegistryConfig{syncRegistryConfig},
}
dctlr, destBaseURL, destDir, destClient := makeDownstreamServer(t, false, syncConfig)
dcm := test.NewControllerManager(dctlr)
dcm.StartAndWait(dctlr.Config.HTTP.Port)
defer dcm.StopServer()
// Wait for periodic sync to complete for this repo (do not call GET manifest for legacy tag to avoid on-demand sync)
found, err := test.ReadLogFileAndSearchString(dctlr.Config.Log.Output, "finished syncing repo", 30*time.Second)
So(err, ShouldBeNil)
So(found, ShouldBeTrue)
// Poll until signed-repo exists on destination (periodic sync may sync multiple repos)
var resp *resty.Response
for start := time.Now(); time.Since(start) < 30*time.Second; time.Sleep(500 * time.Millisecond) {
resp, err = destClient.R().Get(destBaseURL + "/v2/" + testSignedImage + "/tags/list")
if err == nil && resp != nil && resp.StatusCode() == http.StatusOK {
break
}
}
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Verify via tags/list: legacy signature tag must not be present
var destTags TagsList
So(json.Unmarshal(resp.Body(), &destTags), ShouldBeNil)
So(destTags.Tags, ShouldNotContain, legacySigTag)
// Verify via storage: index.json must not reference the legacy signature tag
indexPath := path.Join(destDir, testSignedImage, "index.json")
indexBuf, err := os.ReadFile(indexPath)
So(err, ShouldBeNil)
var destIndex ispec.Index
So(json.Unmarshal(indexBuf, &destIndex), ShouldBeNil)
for _, desc := range destIndex.Manifests {
if tag, ok := desc.Annotations[ispec.AnnotationRefName]; ok && tag == legacySigTag {
t.Fatalf("legacy signature tag %q should not be in destination index", legacySigTag)
}
}
})
})
}
func TestOnDemandRetryGoroutine(t *testing.T) {
Convey("Verify ondemand sync retries in background on error", t, func() {
srcPort := test.GetFreePort()
@@ -7501,6 +7799,343 @@ func TestECRCredentialsHelper(t *testing.T) {
})
}
// TestOnDemandReferrerSyncFlags verifies SyncLegacyCosignTags and on-demand
// non-recursive referrer sync in a full upstream/downstream server setup.
//
// Topology pushed to the upstream:
//
// testImage:testImageTag (base image)
// ├── oci-referrer (OCI-spec referrer — attached via subject field)
// └── sha256-<hex>.sig (legacy cosign-style digest-encoded tag)
//
// With SyncLegacyCosignTags=false, referrers from the referrers API are synced,
// but the legacy .sig tag is not; the .sig tag must not appear on the downstream.
// On-demand SyncReferrers never recurses (only direct referrers are synced);
// recursive referrer sync is done only in periodic sync.
func TestOnDemandReferrerSyncFlags(t *testing.T) {
Convey("SyncLegacyCosignTags=false syncs OCI referrers but not digest-tag referrers", t, func() {
sctlr, srcBaseURL, _, _ := makeUpstreamServer(t, false, false)
scm := test.NewControllerManager(sctlr)
scm.StartAndWait(sctlr.Config.HTTP.Port)
defer scm.StopServer()
// ── 1. Fetch the subject digest ──────────────────────────────────────
resp, err := resty.R().Get(srcBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
subjectDigest := resp.Header().Get("Docker-Content-Digest")
subjectSize := len(resp.Body())
// ── 2. Push an OCI referrer (attached via subject field) ─────────────
_ = pushBlob(srcBaseURL, testImage, ispec.DescriptorEmptyJSON.Data)
ociRef := ispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
Subject: &ispec.Descriptor{
MediaType: ispec.MediaTypeImageManifest,
Digest: godigest.Digest(subjectDigest),
Size: int64(subjectSize),
},
Config: ispec.Descriptor{
MediaType: ispec.MediaTypeEmptyJSON,
Digest: ispec.DescriptorEmptyJSON.Digest,
Size: 2,
},
Layers: []ispec.Descriptor{{
MediaType: ispec.MediaTypeEmptyJSON,
Digest: ispec.DescriptorEmptyJSON.Digest,
Size: 2,
}},
MediaType: ispec.MediaTypeImageManifest,
}
ociRefBlob, err := json.Marshal(ociRef)
So(err, ShouldBeNil)
resp, err = resty.R().
SetHeader("Content-type", ispec.MediaTypeImageManifest).
SetBody(ociRefBlob).
Put(srcBaseURL + "/v2/" + testImage + "/manifests/oci.ref")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
// ── 3. Push a legacy cosign digest-tag referrer (sha256-<hex>.sig) ───
sigTag := fmt.Sprintf("%s-%s.sig",
godigest.Digest(subjectDigest).Algorithm(),
godigest.Digest(subjectDigest).Encoded())
_ = pushBlob(srcBaseURL, testImage, ispec.DescriptorEmptyJSON.Data)
sigManifest := ispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
Config: ispec.Descriptor{
MediaType: "application/vnd.dev.cosign.simplesigning.v1+json",
Digest: ispec.DescriptorEmptyJSON.Digest,
Size: 2,
},
Layers: []ispec.Descriptor{{
MediaType: ispec.MediaTypeEmptyJSON,
Digest: ispec.DescriptorEmptyJSON.Digest,
Size: 2,
}},
MediaType: ispec.MediaTypeImageManifest,
}
sigBlob, err := json.Marshal(sigManifest)
So(err, ShouldBeNil)
resp, err = resty.R().
SetHeader("Content-type", ispec.MediaTypeImageManifest).
SetBody(sigBlob).
Put(srcBaseURL + "/v2/" + testImage + "/manifests/" + sigTag)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
// Verify upstream has both the OCI referrer and the sig tag.
resp, err = resty.R().Get(srcBaseURL + "/v2/" + testImage + "/referrers/" + subjectDigest)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
var upstreamIdx ispec.Index
So(json.Unmarshal(resp.Body(), &upstreamIdx), ShouldBeNil)
So(len(upstreamIdx.Manifests), ShouldEqual, 1) // only OCI referrer in referrers API
resp, err = resty.R().Get(srcBaseURL + "/v2/" + testImage + "/tags/list")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
So(resp.String(), ShouldContainSubstring, sigTag)
// ── 4. Start downstream with SyncLegacyCosignTags=false ──────────────
var tlsVerify bool
syncLegacyFalse := false
syncRegistryConfig := syncconf.RegistryConfig{
Content: []syncconf.Content{{
Prefix: testImage,
}},
URLs: []string{srcBaseURL},
TLSVerify: &tlsVerify,
OnDemand: true,
SyncLegacyCosignTags: &syncLegacyFalse,
}
defaultVal := true
syncConfig := &syncconf.Config{
Enable: &defaultVal,
Registries: []syncconf.RegistryConfig{syncRegistryConfig},
}
dctlr, destBaseURL, _, _ := makeDownstreamServer(t, false, syncConfig)
dcm := test.NewControllerManager(dctlr)
dcm.StartAndWait(dctlr.Config.HTTP.Port)
defer dcm.StopServer()
// ── 5. Trigger on-demand sync of the base image ──────────────────────
resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// ── 6. Trigger on-demand sync of referrers ───────────────────────────
resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/referrers/" + subjectDigest)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
var downstreamIdx ispec.Index
So(json.Unmarshal(resp.Body(), &downstreamIdx), ShouldBeNil)
// OCI referrer must be present.
So(len(downstreamIdx.Manifests), ShouldEqual, 1)
// ── 7. The sig tag must NOT have been synced ──────────────────────────
resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/tags/list")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
So(resp.String(), ShouldNotContainSubstring, sigTag)
})
Convey("On-demand SyncReferrers syncs only direct referrers (no recursion)", t, func() {
sctlr, srcBaseURL, _, _ := makeUpstreamServer(t, false, false)
scm := test.NewControllerManager(sctlr)
scm.StartAndWait(sctlr.Config.HTTP.Port)
defer scm.StopServer()
// ── 1. Fetch the subject digest ──────────────────────────────────────
resp, err := resty.R().Get(srcBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
subjectDigest := resp.Header().Get("Docker-Content-Digest")
subjectSize := len(resp.Body())
// ── 2. Push a direct OCI referrer ────────────────────────────────────
_ = pushBlob(srcBaseURL, testImage, ispec.DescriptorEmptyJSON.Data)
directRef := ispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
Subject: &ispec.Descriptor{
MediaType: ispec.MediaTypeImageManifest,
Digest: godigest.Digest(subjectDigest),
Size: int64(subjectSize),
},
Config: ispec.Descriptor{
MediaType: ispec.MediaTypeEmptyJSON,
Digest: ispec.DescriptorEmptyJSON.Digest,
Size: 2,
},
Layers: []ispec.Descriptor{{
MediaType: ispec.MediaTypeEmptyJSON,
Digest: ispec.DescriptorEmptyJSON.Digest,
Size: 2,
}},
MediaType: ispec.MediaTypeImageManifest,
Annotations: map[string]string{"level": "direct"},
}
directRefBlob, err := json.Marshal(directRef)
So(err, ShouldBeNil)
resp, err = resty.R().
SetHeader("Content-type", ispec.MediaTypeImageManifest).
SetBody(directRefBlob).
Put(srcBaseURL + "/v2/" + testImage + "/manifests/direct.ref")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
directRefDigest := resp.Header().Get("Docker-Content-Digest")
// ── 3. Push a nested referrer (referrer-of-referrer) ─────────────────
nestedRef := ispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
Subject: &ispec.Descriptor{
MediaType: ispec.MediaTypeImageManifest,
Digest: godigest.Digest(directRefDigest),
Size: int64(len(directRefBlob)),
},
Config: ispec.Descriptor{
MediaType: ispec.MediaTypeEmptyJSON,
Digest: ispec.DescriptorEmptyJSON.Digest,
Size: 2,
},
Layers: []ispec.Descriptor{{
MediaType: ispec.MediaTypeEmptyJSON,
Digest: ispec.DescriptorEmptyJSON.Digest,
Size: 2,
}},
MediaType: ispec.MediaTypeImageManifest,
Annotations: map[string]string{"level": "nested"},
}
nestedRefBlob, err := json.Marshal(nestedRef)
So(err, ShouldBeNil)
resp, err = resty.R().
SetHeader("Content-type", ispec.MediaTypeImageManifest).
SetBody(nestedRefBlob).
Put(srcBaseURL + "/v2/" + testImage + "/manifests/nested.ref")
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
_ = resp.Header().Get("Docker-Content-Digest")
// Verify upstream has the referrer chain in place.
resp, err = resty.R().Get(srcBaseURL + "/v2/" + testImage + "/referrers/" + subjectDigest)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
var upstreamDirect ispec.Index
So(json.Unmarshal(resp.Body(), &upstreamDirect), ShouldBeNil)
So(len(upstreamDirect.Manifests), ShouldEqual, 1)
resp, err = resty.R().Get(srcBaseURL + "/v2/" + testImage + "/referrers/" + directRefDigest)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
var upstreamNested ispec.Index
So(json.Unmarshal(resp.Body(), &upstreamNested), ShouldBeNil)
So(len(upstreamNested.Manifests), ShouldEqual, 1)
// ── 4. Start downstream with on-demand (referrer sync never recurses) ─
var tlsVerify bool
syncLegacyFalse := false
syncRegistryConfig := syncconf.RegistryConfig{
Content: []syncconf.Content{{
Prefix: testImage,
}},
URLs: []string{srcBaseURL},
TLSVerify: &tlsVerify,
OnDemand: true,
SyncLegacyCosignTags: &syncLegacyFalse, // avoid legacy tag listing
}
defaultVal := true
syncConfig := &syncconf.Config{
Enable: &defaultVal,
Registries: []syncconf.RegistryConfig{syncRegistryConfig},
}
dctlr, destBaseURL, _, _ := makeDownstreamServer(t, false, syncConfig)
dcm := test.NewControllerManager(dctlr)
dcm.StartAndWait(dctlr.Config.HTTP.Port)
defer dcm.StopServer()
// ── 5. Trigger on-demand sync of the base image ──────────────────────
resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/manifests/" + testImageTag)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// ── 6. Trigger on-demand sync of referrers for the base image ────────
resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/referrers/" + subjectDigest)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
var downstreamDirect ispec.Index
So(json.Unmarshal(resp.Body(), &downstreamDirect), ShouldBeNil)
// The direct referrer must be synced.
So(len(downstreamDirect.Manifests), ShouldEqual, 1)
// ── 7. Verify the nested referrer is NOT synced ──────────────────────
// On-demand SyncReferrers never recurses; only direct referrers of the
// requested subject are synced. So syncing referrers for the base image
// did not recursively sync referrers of the direct referrer.
//
// We MUST NOT query the Referrers API on the downstream here, because
// that would trigger a second on-demand sync for the direct referrer.
// Instead, we check the metaDB to see if any referrers are recorded for
// the directRefDigest.
refs, err := dctlr.MetaDB.GetReferrersInfo(testImage, godigest.Digest(directRefDigest), nil)
So(err, ShouldBeNil)
// If recursive sync had happened, this would be 1.
So(len(refs), ShouldEqual, 0)
// ── 8. Verify that nested referrer is synced when explicitly requested
resp, err = resty.R().Get(destBaseURL + "/v2/" + testImage + "/referrers/" + directRefDigest)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
var downstreamNested ispec.Index
So(json.Unmarshal(resp.Body(), &downstreamNested), ShouldBeNil)
So(len(downstreamNested.Manifests), ShouldEqual, 1)
refs, err = dctlr.MetaDB.GetReferrersInfo(testImage, godigest.Digest(directRefDigest), nil)
So(err, ShouldBeNil)
So(len(refs), ShouldEqual, 1)
})
}
func generateKeyPairs(tdir string) {
// generate a keypair
os.Setenv("COSIGN_PASSWORD", "")