Repository: regclient/regclient
Pull request #874 (2024-12-04 22:02:12)

Feat: Copy image with external referrers
<!--

Commits must be signed indicating your agreement to the [DCO](https://developercertificate.org/).
See [DCO missing](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md) for steps to fix a missing signoff.

-->

### Fixes issue

<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

Add the ability to copy images with external source and/or target referrer repositories.
<!-- Include the type of change: bug fix, new feature, breaking change, documentation update -->
<!-- Describe what was changed, why the change was made, and how it was implemented -->

### How to verify it

```shell
echo "hello external artifact" | regctl artifact put --subject alpine --external ocidir://test/external --artifact-type application/example.test
regctl image copy alpine localhost:5002/library/alpine --referrers --referrers-src ocidir://test/external --referrers-tgt localhost:5002/external
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Feat: Copy image with external referrers.
<!-- If the release changelog should have an entry for this, include it here -->

### Please verify and check that the pull request fulfills the following requirements

<!-- Mark the following with an [X] to verify they are included -->

- [X] Tests have been added or not applicable
- [X] Documentation has been added, updated, or not applicable
- [X] Changes have been rebased to main
- [X] Multiple commits to the same code have been squashed

<!-- markdownlint-disable-file MD041 -->



Patch:
diff --git a/cmd/regctl/image.go b/cmd/regctl/image.go
index f477d08..b2b52cd 100644
--- a/cmd/regctl/image.go
+++ b/cmd/regctl/image.go
@@ -65,6 +65,8 @@ type imageCmd struct {
 	platform        string
 	platforms       []string
 	referrers       bool
+	referrerSrc     string
+	referrerTgt     string
 	replace         bool
 }
 
@@ -325,8 +327,10 @@ regctl image ratelimit alpine --format '{{.Remain}}'`,
 	imageCopyCmd.Flags().StringArrayVarP(&imageOpts.platforms, "platforms", "", []string{}, "Copy only specific platforms, registry validation must be disabled")
 	// platforms should be treated as experimental since it will break many registries
 	_ = imageCopyCmd.Flags().MarkHidden("platforms")
-	imageCopyCmd.Flags().BoolVarP(&imageOpts.digestTags, "digest-tags", "", false, "Include digest tags (\"sha256-<digest>.*\") when copying manifests")
-	imageCopyCmd.Flags().BoolVarP(&imageOpts.referrers, "referrers", "", false, "Include referrers")
+	imageCopyCmd.Flags().BoolVar(&imageOpts.digestTags, "digest-tags", false, "Include digest tags (\"sha256-<digest>.*\") when copying manifests")
+	imageCopyCmd.Flags().BoolVar(&imageOpts.referrers, "referrers", false, "Include referrers")
+	imageCopyCmd.Flags().StringVar(&imageOpts.referrerSrc, "referrers-src", "", "External source for referrers")
+	imageCopyCmd.Flags().StringVar(&imageOpts.referrerTgt, "referrers-tgt", "", "External target for referrers")
 
 	imageCreateCmd.Flags().StringArrayVar(&imageOpts.annotations, "annotation", []string{}, "Annotation to set on manifest")
 	imageCreateCmd.Flags().BoolVar(&imageOpts.byDigest, "by-digest", false, "Push manifest by digest instead of tag")
@@ -1058,6 +1062,9 @@ func (imageOpts *imageCmd) runImageCopy(cmd *cobra.Command, args []string) error
 	if err != nil {
 		return err
 	}
+	if (imageOpts.referrerSrc != "" || imageOpts.referrerTgt != "") && !imageOpts.referrers {
+		return fmt.Errorf("referrers must be enabled to specify an external referrers source or target%.0w", errs.ErrUnsupported)
+	}
 	rc := imageOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, rSrc)
 	defer rc.Close(ctx, rTgt)
@@ -1093,6 +1100,20 @@ func (imageOpts *imageCmd) runImageCopy(cmd *cobra.Command, args []string) error
 	if imageOpts.referrers {
 		opts = append(opts, regclient.ImageWithReferrers())
 	}
+	if imageOpts.referrerSrc != "" {
+		referrerSrc, err := ref.New(imageOpts.referrerSrc)
+		if err != nil {
+			return fmt.Errorf("failed parsing referrer external source: %w", err)
+		}
+		opts = append(opts, regclient.ImageWithReferrerSrc(referrerSrc))
+	}
+	if imageOpts.referrerTgt != "" {
+		referrerTgt, err := ref.New(imageOpts.referrerTgt)
+		if err != nil {
+			return fmt.Errorf("failed parsing referrer external target: %w", err)
+		}
+		opts = append(opts, regclient.ImageWithReferrerTgt(referrerTgt))
+	}
 	if len(imageOpts.platforms) > 0 {
 		opts = append(opts, regclient.ImageWithPlatforms(imageOpts.platforms))
 	}
diff --git a/image.go b/image.go
index 9372df7..89f3e0a 100644
--- a/image.go
+++ b/image.go
@@ -96,6 +96,8 @@ type imageOpt struct {
 	platform        string
 	platforms       []string
 	referrerConfs   []scheme.ReferrerConfig
+	referrerSrc     ref.Ref
+	referrerTgt     ref.Ref
 	tagList         []string
 	mu              sync.Mutex
 	seen            map[string]*imageSeen
@@ -225,6 +227,20 @@ func ImageWithReferrers(rOpts ...scheme.ReferrerOpts) ImageOpts {
 	}
 }
 
+// ImageWithReferrerSrc specifies an alternate repository to pull referrers from.
+func ImageWithReferrerSrc(src ref.Ref) ImageOpts {
+	return func(opts *imageOpt) {
+		opts.referrerSrc = src
+	}
+}
+
+// ImageWithReferrerTgt specifies an alternate repository to pull referrers from.
+func ImageWithReferrerTgt(tgt ref.Ref) ImageOpts {
+	return func(opts *imageOpt) {
+		opts.referrerTgt = tgt
+	}
+}
+
 // ImageCheckBase returns nil if the base image is unchanged.
 // A base image mismatch returns an error that wraps errs.ErrMismatch.
 func (rc *RegClient) ImageCheckBase(ctx context.Context, r ref.Ref, opts ...ImageOpts) error {
@@ -774,11 +790,27 @@ func (rc *RegClient) imageCopyOpt(ctx context.Context, refSrc ref.Ref, refTgt re
 	// copy referrers
 	referrerTags := []string{}
 	if opt.referrerConfs != nil {
-		rl, err := rc.ReferrerList(ctx, refSrc)
+		referrerOpts := []scheme.ReferrerOpts{}
+		rSubject := refSrc
+		referrerSrc := refSrc
+		referrerTgt := refTgt
+		if opt.referrerSrc.IsSet() {
+			referrerOpts = append(referrerOpts, scheme.WithReferrerSource(opt.referrerSrc))
+			referrerSrc = opt.referrerSrc
+		}
+		if opt.referrerTgt.IsSet() {
+			referrerTgt = opt.referrerTgt
+		}
+		if sDig != "" {
+			rSubject = rSubject.SetDigest(sDig.String())
+		}
+		rl, err := rc.ReferrerList(ctx, rSubject, referrerOpts...)
 		if err != nil {
 			return err
 		}
-		referrerTags = append(referrerTags, rl.Tags...)
+		if !rl.Source.IsSet() || ref.EqualRepository(refSrc, rl.Source) {
+			referrerTags = append(referrerTags, rl.Tags...)
+		}
 		descList := []descriptor.Descriptor{}
 		if len(opt.referrerConfs) == 0 {
 			descList = rl.Descriptors
@@ -795,8 +827,8 @@ func (rc *RegClient) imageCopyOpt(ctx context.Context, refSrc ref.Ref, refTgt re
 			if seen != nil {
 				continue // skip referrers that have been seen
 			}
-			referrerSrc := refSrc.SetDigest(rDesc.Digest.String())
-			referrerTgt := refTgt.SetDigest(rDesc.Digest.String())
+			referrerSrc := referrerSrc.SetDigest(rDesc.Digest.String())
+			referrerTgt := referrerTgt.SetDigest(rDesc.Digest.String())
 			rDesc := rDesc
 			waitCount++
 			go func() {


Test patch:
diff --git a/cmd/regctl/image_test.go b/cmd/regctl/image_test.go
index 23b07b4..bb5e78f 100644
--- a/cmd/regctl/image_test.go
+++ b/cmd/regctl/image_test.go
@@ -62,6 +62,11 @@ func TestImageCopy(t *testing.T) {
 			args:      []string{"image", "copy", "--platform", "linux/amd64", tsHost + "/testrepo:v3", tsHost + "/newrepo:v3"},
 			expectOut: tsHost + "/newrepo:v3",
 		},
+		{
+			name:      "ocidir-to-reg-external-referrers",
+			args:      []string{"image", "copy", srcRef, tsHost + "/newrepo:v4", "--referrers", "--referrers-src", "ocidir://../../testdata/external", "--referrers-tgt", tsHost + "/external"},
+			expectOut: tsHost + "/newrepo:v4",
+		},
 	}
 	for _, tc := range tt {
 		t.Run(tc.name, func(t *testing.T) {
diff --git a/image_test.go b/image_test.go
index 1313112..3741118 100644
--- a/image_test.go
+++ b/image_test.go
@@ -290,6 +290,14 @@ func TestCopy(t *testing.T) {
 			TLS:      config.TLSDisabled,
 		},
 	}
+	rReferrerSrc, err := ref.New("ocidir://./testdata/external")
+	if err != nil {
+		t.Fatalf("failed to parse referrer src repo: %v", err)
+	}
+	rReferrerTgt, err := ref.New(tsHost + "/dest-external")
+	if err != nil {
+		t.Fatalf("failed to parse referrer tgt repo: %v", err)
+	}
 	log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
 	delayInit, _ := time.ParseDuration("0.05s")
 	delayMax, _ := time.ParseDuration("0.10s")
@@ -349,6 +357,12 @@ func TestCopy(t *testing.T) {
 			tgt:  tsHost + "/dest-reg:v2",
 			opts: []ImageOpts{ImageWithReferrers(), ImageWithDigestTags()},
 		},
+		{
+			name: "ocidir to registry with external referrers and digest tags",
+			src:  "ocidir://./testdata/testrepo:v2",
+			tgt:  tsHost + "/dest-ocidir:v2",
+			opts: []ImageOpts{ImageWithReferrers(), ImageWithDigestTags(), ImageWithReferrerSrc(rReferrerSrc), ImageWithReferrerTgt(rReferrerTgt)},
+		},
 		{
 			name: "ocidir to registry with fast check",
 			src:  "ocidir://./testdata/testrepo:v3",