From af68cf3a160c353ca22eca60ff0341f025f57a5b Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 13 Nov 2025 13:43:01 -0500 Subject: [PATCH 1/3] add title annotation Signed-off-by: Austin Abro --- cmd/oras/root/backup.go | 12 ++++- cmd/oras/root/backup_test.go | 99 ++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/cmd/oras/root/backup.go b/cmd/oras/root/backup.go index 9c1e906b0..615fb9462 100644 --- a/cmd/oras/root/backup.go +++ b/cmd/oras/root/backup.go @@ -235,6 +235,14 @@ func runBackup(cmd *cobra.Command, opts *backupOptions) error { } for i, tag := range tags { + // Add the full image reference to the title annotation + root := roots[i] + fullRef := opts.repository + ":" + tag + if root.Annotations == nil { + root.Annotations = make(map[string]string, 1) + } + root.Annotations[ocispec.AnnotationTitle] = fullRef + referrerCount, err := func() (referrerCount int, retErr error) { trackedDst, err := statusHandler.StartTracking(dstOCI) if err != nil { @@ -248,9 +256,9 @@ func runBackup(cmd *cobra.Command, opts *backupOptions) error { }() if opts.includeReferrers { - return backupTagWithReferrers(ctx, srcRepo, trackedDst, tag, roots[i], extCopyGraphOpts) + return backupTagWithReferrers(ctx, srcRepo, trackedDst, tag, root, extCopyGraphOpts) } - return 0, backupTag(ctx, srcRepo, trackedDst, tag, roots[i], copyGraphOpts) + return 0, backupTag(ctx, srcRepo, trackedDst, tag, root, copyGraphOpts) }() if err != nil { return fmt.Errorf("failed to back up tag %q from %q to %q: %w", tag, opts.repository, dstRoot, oerrors.UnwrapCopyError(err)) diff --git a/cmd/oras/root/backup_test.go b/cmd/oras/root/backup_test.go index a352770ae..be9ffe235 100644 --- a/cmd/oras/root/backup_test.go +++ b/cmd/oras/root/backup_test.go @@ -35,6 +35,7 @@ import ( "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/content/oci" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/registry/remote" ) @@ -706,6 +707,104 @@ func (m *mockLogger) Errorf(format string, args ...any) {} func (m *mockLogger) Fatalf(format string, args ...any) {} func (m *mockLogger) Panicf(format string, args ...any) {} +func Test_backupTag_titleAnnotation(t *testing.T) { + // This test verifies that backupTag stores: + // - The tag in org.opencontainers.image.ref.name + // - The full image reference in org.opencontainers.image.title + ctx := context.Background() + + // Create a source memory store with a simple manifest + src := memory.New() + + // Push config blob first + configContent := []byte("{}") + configDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageConfig, configContent) + if err := src.Push(ctx, configDesc, strings.NewReader(string(configContent))); err != nil { + t.Fatalf("failed to push config: %v", err) + } + + // Create a manifest that references the config + manifestContent := fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"%s","size":%d},"layers":[]}`, configDesc.Digest, configDesc.Size) + + // Push the manifest and let the store calculate the digest + manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, []byte(manifestContent)) + if err := src.Push(ctx, manifestDesc, strings.NewReader(manifestContent)); err != nil { + t.Fatalf("failed to push manifest: %v", err) + } + + // Create a temporary directory for the OCI store + tempDir := t.TempDir() + dstOCIPath := filepath.Join(tempDir, "oci-layout") + if err := os.MkdirAll(dstOCIPath, 0755); err != nil { + t.Fatalf("failed to create temp directory: %v", err) + } + + // Create destination OCI store + dst, err := oci.New(dstOCIPath) + if err != nil { + t.Fatalf("failed to create OCI store: %v", err) + } + + // Add the full image reference to the descriptor's title annotation + tag := "v1.0" + fullRef := "localhost:5000/test/image:v1.0" + manifestDesc.Annotations = map[string]string{ + ocispec.AnnotationTitle: fullRef, + } + + if err := backupTag(ctx, src, dst, tag, manifestDesc, oras.DefaultCopyGraphOptions); err != nil { + t.Fatalf("backupTag() error = %v, want nil", err) + } + + // Save the index to write annotations + if err := dst.SaveIndex(); err != nil { + t.Fatalf("failed to save index: %v", err) + } + + // Read the index.json file to verify the annotations + indexPath := filepath.Join(dstOCIPath, "index.json") + indexBytes, err := os.ReadFile(indexPath) + if err != nil { + t.Fatalf("failed to read index.json: %v", err) + } + + var index ocispec.Index + if err := json.Unmarshal(indexBytes, &index); err != nil { + t.Fatalf("failed to unmarshal index.json: %v", err) + } + + // Verify the annotations + found := false + for _, manifest := range index.Manifests { + if manifest.Digest == manifestDesc.Digest { + // Verify ref.name contains just the tag + if refName, ok := manifest.Annotations[ocispec.AnnotationRefName]; ok { + if refName != tag { + t.Errorf("annotation %s = %q, want %q", ocispec.AnnotationRefName, refName, tag) + } + } else { + t.Errorf("annotation %s not found", ocispec.AnnotationRefName) + } + + // Verify title contains the full reference + if title, ok := manifest.Annotations[ocispec.AnnotationTitle]; ok { + if title != fullRef { + t.Errorf("annotation %s = %q, want %q", ocispec.AnnotationTitle, title, fullRef) + } + } else { + t.Errorf("annotation %s not found", ocispec.AnnotationTitle) + } + + found = true + break + } + } + + if !found { + t.Errorf("manifest with digest %s not found in index", manifestDesc.Digest) + } +} + func (m *mockLogger) Debug(args ...any) {} func (m *mockLogger) Info(args ...any) {} func (m *mockLogger) Print(args ...any) {} From d55b2db272b4d3216743505a50eb1e523115e227 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 13 Nov 2025 13:55:57 -0500 Subject: [PATCH 2/3] move to e2e test Signed-off-by: Austin Abro --- cmd/oras/root/backup_test.go | 99 -------------------------------- test/e2e/suite/command/backup.go | 38 ++++++++++++ 2 files changed, 38 insertions(+), 99 deletions(-) diff --git a/cmd/oras/root/backup_test.go b/cmd/oras/root/backup_test.go index be9ffe235..a352770ae 100644 --- a/cmd/oras/root/backup_test.go +++ b/cmd/oras/root/backup_test.go @@ -35,7 +35,6 @@ import ( "oras.land/oras-go/v2" "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" - "oras.land/oras-go/v2/content/oci" "oras.land/oras-go/v2/errdef" "oras.land/oras-go/v2/registry/remote" ) @@ -707,104 +706,6 @@ func (m *mockLogger) Errorf(format string, args ...any) {} func (m *mockLogger) Fatalf(format string, args ...any) {} func (m *mockLogger) Panicf(format string, args ...any) {} -func Test_backupTag_titleAnnotation(t *testing.T) { - // This test verifies that backupTag stores: - // - The tag in org.opencontainers.image.ref.name - // - The full image reference in org.opencontainers.image.title - ctx := context.Background() - - // Create a source memory store with a simple manifest - src := memory.New() - - // Push config blob first - configContent := []byte("{}") - configDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageConfig, configContent) - if err := src.Push(ctx, configDesc, strings.NewReader(string(configContent))); err != nil { - t.Fatalf("failed to push config: %v", err) - } - - // Create a manifest that references the config - manifestContent := fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"%s","size":%d},"layers":[]}`, configDesc.Digest, configDesc.Size) - - // Push the manifest and let the store calculate the digest - manifestDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageManifest, []byte(manifestContent)) - if err := src.Push(ctx, manifestDesc, strings.NewReader(manifestContent)); err != nil { - t.Fatalf("failed to push manifest: %v", err) - } - - // Create a temporary directory for the OCI store - tempDir := t.TempDir() - dstOCIPath := filepath.Join(tempDir, "oci-layout") - if err := os.MkdirAll(dstOCIPath, 0755); err != nil { - t.Fatalf("failed to create temp directory: %v", err) - } - - // Create destination OCI store - dst, err := oci.New(dstOCIPath) - if err != nil { - t.Fatalf("failed to create OCI store: %v", err) - } - - // Add the full image reference to the descriptor's title annotation - tag := "v1.0" - fullRef := "localhost:5000/test/image:v1.0" - manifestDesc.Annotations = map[string]string{ - ocispec.AnnotationTitle: fullRef, - } - - if err := backupTag(ctx, src, dst, tag, manifestDesc, oras.DefaultCopyGraphOptions); err != nil { - t.Fatalf("backupTag() error = %v, want nil", err) - } - - // Save the index to write annotations - if err := dst.SaveIndex(); err != nil { - t.Fatalf("failed to save index: %v", err) - } - - // Read the index.json file to verify the annotations - indexPath := filepath.Join(dstOCIPath, "index.json") - indexBytes, err := os.ReadFile(indexPath) - if err != nil { - t.Fatalf("failed to read index.json: %v", err) - } - - var index ocispec.Index - if err := json.Unmarshal(indexBytes, &index); err != nil { - t.Fatalf("failed to unmarshal index.json: %v", err) - } - - // Verify the annotations - found := false - for _, manifest := range index.Manifests { - if manifest.Digest == manifestDesc.Digest { - // Verify ref.name contains just the tag - if refName, ok := manifest.Annotations[ocispec.AnnotationRefName]; ok { - if refName != tag { - t.Errorf("annotation %s = %q, want %q", ocispec.AnnotationRefName, refName, tag) - } - } else { - t.Errorf("annotation %s not found", ocispec.AnnotationRefName) - } - - // Verify title contains the full reference - if title, ok := manifest.Annotations[ocispec.AnnotationTitle]; ok { - if title != fullRef { - t.Errorf("annotation %s = %q, want %q", ocispec.AnnotationTitle, title, fullRef) - } - } else { - t.Errorf("annotation %s not found", ocispec.AnnotationTitle) - } - - found = true - break - } - } - - if !found { - t.Errorf("manifest with digest %s not found in index", manifestDesc.Digest) - } -} - func (m *mockLogger) Debug(args ...any) {} func (m *mockLogger) Info(args ...any) {} func (m *mockLogger) Print(args ...any) {} diff --git a/test/e2e/suite/command/backup.go b/test/e2e/suite/command/backup.go index 02c73f79a..af516b20e 100644 --- a/test/e2e/suite/command/backup.go +++ b/test/e2e/suite/command/backup.go @@ -16,6 +16,7 @@ limitations under the License. package command import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -25,6 +26,7 @@ import ( . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" . "github.com/onsi/gomega" "github.com/onsi/gomega/gbytes" @@ -471,6 +473,42 @@ var _ = Describe("ORAS users:", func() { }) }) + When("verifying annotations", func() { + It("should set org.opencontainers.image.title annotation with full reference", func() { + tmpDir := GinkgoT().TempDir() + outDir := filepath.Join(tmpDir, "backup-title-annotation") + repo := backupTestRepo("title-annotation") + tag := "v1.0" + srcRef := RegistryRef(ZOTHost, repo, tag) + + // Prepare test artifact + prepare(RegistryRef(ZOTHost, ArtifactRepo, foobar.Tag), srcRef) + + // Backup the artifact + ORAS("backup", "--output", outDir, srcRef).Exec() + + // Verify backup directory structure + verifyBackupDirectoryStructure(outDir) + + // Read and verify index.json annotations + indexPath := filepath.Join(outDir, "index.json") + indexBytes, err := os.ReadFile(indexPath) + Expect(err).ToNot(HaveOccurred()) + + // Parse the index + var index ocispec.Index + err = json.Unmarshal(indexBytes, &index) + Expect(err).ToNot(HaveOccurred()) + Expect(index.Manifests).ToNot(BeEmpty()) + + // Verify annotations on the first manifest + annotations := index.Manifests[0].Annotations + Expect(annotations["org.opencontainers.image.ref.name"]).To(Equal(tag)) + expectedFullRef := fmt.Sprintf("%s/%s:%s", ZOTHost, repo, tag) + Expect(annotations[ocispec.AnnotationTitle]).To(Equal(expectedFullRef)) + }) + }) + When("handling error cases", func() { It("should fail when output directory cannot be created", func() { // Create a file that will conflict with our output path From b1328c566f5392177981272d0ff3bab1ccf642ce Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 13 Nov 2025 13:59:02 -0500 Subject: [PATCH 3/3] verify annotations Signed-off-by: Austin Abro --- test/e2e/suite/command/backup.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/e2e/suite/command/backup.go b/test/e2e/suite/command/backup.go index af516b20e..a1663845a 100644 --- a/test/e2e/suite/command/backup.go +++ b/test/e2e/suite/command/backup.go @@ -481,13 +481,10 @@ var _ = Describe("ORAS users:", func() { tag := "v1.0" srcRef := RegistryRef(ZOTHost, repo, tag) - // Prepare test artifact prepare(RegistryRef(ZOTHost, ArtifactRepo, foobar.Tag), srcRef) - // Backup the artifact ORAS("backup", "--output", outDir, srcRef).Exec() - // Verify backup directory structure verifyBackupDirectoryStructure(outDir) // Read and verify index.json annotations @@ -495,7 +492,6 @@ var _ = Describe("ORAS users:", func() { indexBytes, err := os.ReadFile(indexPath) Expect(err).ToNot(HaveOccurred()) - // Parse the index var index ocispec.Index err = json.Unmarshal(indexBytes, &index) Expect(err).ToNot(HaveOccurred())