Skip to content

Commit 979b44e

Browse files
committed
fix: add --oci-layout flag for ocm download artifact
<!-- markdownlint-disable MD041 --> This change adds an optional --oci-layout flag to the `ocm download artifacts` command to store blobs at blobs/<algorithm>/<encoded> per OCI Image Layout Specification instead of the default blobs/<algorithm>.<encoded> format. This enables compatibility with tools that expect OCI-compliant blob paths. <!-- Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> Fixes #1668 Signed-off-by: Piotr Janik <piotr.janik@sap.com>
1 parent 8b5cd7f commit 979b44e

File tree

8 files changed

+284
-56
lines changed

8 files changed

+284
-56
lines changed

api/oci/extensions/repositories/artifactset/artifactset_test.go

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,26 @@ var _ = Describe("artifact management", func() {
8181
for _, fi := range infos {
8282
blobs = append(blobs, fi.Name())
8383
}
84-
Expect(blobs).To(ContainElements(
85-
"sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
86-
"sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
87-
"sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"))
84+
if format == artifactset.FORMAT_OCI {
85+
// OCI format uses nested directory structure: blobs/sha256/DIGEST
86+
Expect(blobs).To(ContainElement("sha256"))
87+
subInfos, err := vfs.ReadDir(tempfs, "test/"+artifactset.BlobsDirectoryName+"/sha256")
88+
Expect(err).To(Succeed())
89+
subBlobs := []string{}
90+
for _, fi := range subInfos {
91+
subBlobs = append(subBlobs, fi.Name())
92+
}
93+
Expect(subBlobs).To(ContainElements(
94+
"3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
95+
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
96+
"810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"))
97+
} else {
98+
// OCM format uses flat structure: blobs/sha256.DIGEST
99+
Expect(blobs).To(ContainElements(
100+
"sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
101+
"sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
102+
"sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"))
103+
}
88104
})
89105

90106
TestForAllFormats("instantiate tgz artifact", func(format string) {
@@ -108,6 +124,7 @@ var _ = Describe("artifact management", func() {
108124
tr := tar.NewReader(zip)
109125

110126
files := []string{}
127+
dirs := []string{}
111128
for {
112129
header, err := tr.Next()
113130
if err != nil {
@@ -119,19 +136,33 @@ var _ = Describe("artifact management", func() {
119136

120137
switch header.Typeflag {
121138
case tar.TypeDir:
122-
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
139+
dirs = append(dirs, header.Name)
123140
case tar.TypeReg:
124141
files = append(files, header.Name)
125142
}
126143
}
144+
145+
// Check directories
146+
Expect(dirs).To(ContainElement(artifactset.BlobsDirectoryName))
147+
if format == artifactset.FORMAT_OCI {
148+
Expect(dirs).To(ContainElement("blobs/sha256"))
149+
}
150+
151+
// Check files based on format
127152
elems := []interface{}{
128153
artifactset.DescriptorFileName(format),
129-
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
130-
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
131-
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
132154
}
133155
if format == artifactset.FORMAT_OCI {
134156
elems = append(elems, artifactset.OCILayouFileName)
157+
elems = append(elems,
158+
"blobs/sha256/3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
159+
"blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
160+
"blobs/sha256/810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
161+
} else {
162+
elems = append(elems,
163+
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
164+
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
165+
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
135166
}
136167
Expect(files).To(ContainElements(elems))
137168
})
@@ -160,6 +191,7 @@ var _ = Describe("artifact management", func() {
160191
tr := tar.NewReader(zip)
161192

162193
files := []string{}
194+
dirs := []string{}
163195
for {
164196
header, err := tr.Next()
165197
if err != nil {
@@ -171,19 +203,33 @@ var _ = Describe("artifact management", func() {
171203

172204
switch header.Typeflag {
173205
case tar.TypeDir:
174-
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
206+
dirs = append(dirs, header.Name)
175207
case tar.TypeReg:
176208
files = append(files, header.Name)
177209
}
178210
}
211+
212+
// Check directories
213+
Expect(dirs).To(ContainElement(artifactset.BlobsDirectoryName))
214+
if format == artifactset.FORMAT_OCI {
215+
Expect(dirs).To(ContainElement("blobs/sha256"))
216+
}
217+
218+
// Check files based on format
179219
elems := []interface{}{
180220
artifactset.DescriptorFileName(format),
181-
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
182-
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
183-
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
184221
}
185222
if format == artifactset.FORMAT_OCI {
186223
elems = append(elems, artifactset.OCILayouFileName)
224+
elems = append(elems,
225+
"blobs/sha256/3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
226+
"blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
227+
"blobs/sha256/810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
228+
} else {
229+
elems = append(elems,
230+
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
231+
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
232+
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
187233
}
188234
Expect(files).To(ContainElements(elems))
189235
})

api/oci/extensions/repositories/artifactset/ctf_roundtrip_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ var _ = Describe("CTF to CTF-with-resource to OCI roundtrip", Ordered, func() {
138138
resourceName,
139139
)).To(Succeed())
140140
Expect(verifyOCILayoutStructure(resourcesOciDir)).To(Succeed())
141+
141142
store, err := oci.New(resourcesOciDir)
142143
Expect(err).To(Succeed(), "ORAS failed to open OCI layout: %w", err)
143144

api/oci/extensions/repositories/artifactset/format.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package artifactset
22

33
import (
4+
"path/filepath"
5+
"strings"
46
"sync"
57

68
"github.com/mandelsoft/goutils/errors"
@@ -42,6 +44,7 @@ func DescriptorFileName(format string) string {
4244

4345
type accessObjectInfo struct {
4446
accessobj.DefaultAccessObjectInfo
47+
ociFormat bool
4548
}
4649

4750
var _ accessobj.AccessObjectInfo = (*accessObjectInfo)(nil)
@@ -61,7 +64,7 @@ func validateDescriptor(data []byte) error {
6164

6265
func NewAccessObjectInfo(fmts ...string) accessobj.AccessObjectInfo {
6366
a := &accessObjectInfo{
64-
baseInfo,
67+
DefaultAccessObjectInfo: baseInfo,
6568
}
6669
oci := IsOCIDefaultFormat()
6770
if len(fmts) > 0 {
@@ -84,6 +87,19 @@ func NewAccessObjectInfo(fmts ...string) accessobj.AccessObjectInfo {
8487
func (a *accessObjectInfo) setOCI() {
8588
a.DescriptorFileName = OCIArtifactSetDescriptorFileName
8689
a.AdditionalFiles = []string{OCILayouFileName}
90+
a.ociFormat = true
91+
}
92+
93+
// SubPath returns the path for a blob. For OCI format, converts "sha256.DIGEST"
94+
// to "blobs/sha256/DIGEST" per OCI Image Layout Specification.
95+
func (a *accessObjectInfo) SubPath(name string) string {
96+
if a.ociFormat {
97+
// Convert sha256.DIGEST to sha256/DIGEST for OCI compliance
98+
if algo, dig, ok := strings.Cut(name, "."); ok {
99+
return filepath.Join(a.ElementDirectoryName, algo, dig)
100+
}
101+
}
102+
return filepath.Join(a.ElementDirectoryName, name)
87103
}
88104

89105
func (a *accessObjectInfo) setOCM() {

api/ocm/extensions/download/handlers/ocilayout/handler.go

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package ocilayout
22

33
import (
4-
"path/filepath"
54
"strings"
65

76
"github.com/mandelsoft/goutils/errors"
@@ -71,22 +70,19 @@ func (h *Handler) Download(p common.Printer, racc cpi.ResourceAccess, path strin
7170
return true, "", errors.Wrapf(err, "create OCI layout")
7271
}
7372

74-
// Step 6: Transfer all manifests and blobs to target with resource version as tag
75-
version := racc.Meta().GetVersion()
76-
if err := artifactset.TransferArtifact(art, target, version); err != nil {
77-
target.Close()
73+
// Step 6: Transfer all manifests and blobs to target with hybrid tagging:
74+
// - Original tags from source (e.g., "latest", "linux")
75+
// - Resource version (e.g., "1.0.0")
76+
tags := collectTags(src, racc.Meta().GetVersion())
77+
if err := artifactset.TransferArtifact(art, target, tags...); err != nil {
78+
err = errors.Join(err, target.Close())
7879
return true, "", errors.Wrapf(err, "transfer artifact")
7980
}
8081

8182
if err := target.Close(); err != nil {
8283
return true, "", errors.Wrapf(err, "close target")
8384
}
8485

85-
// Step 7: Convert blob paths from sha256.DIGEST to sha256/DIGEST
86-
if err := convertBlobPaths(fs, path); err != nil {
87-
return true, "", err
88-
}
89-
9086
p.Printf("%s: downloaded to OCI layout\n", path)
9187
return true, path, nil
9288
}
@@ -96,36 +92,34 @@ func isOCIArtifact(mime string) bool {
9692
(strings.HasSuffix(mime, "+tar") || strings.HasSuffix(mime, "+tar+gzip"))
9793
}
9894

99-
// convertBlobPaths converts blob paths from artifactset format (sha256.DIGEST)
100-
// to OCI Image Layout format (sha256/DIGEST).
101-
//
102-
// This is needed because artifactset uses DigestToFileName which always produces
103-
// "sha256.DIGEST" format. The FORMAT_OCI option only controls the descriptor file
104-
// (index.json) and oci-layout file creation, not the blob path structure.
105-
//
106-
// Call trace where DigestToFileName is invoked:
107-
//
108-
// TransferArtifact() -> TransferManifest() -> set.AddBlob()
109-
// -> accessobj.FileSystemBlobAccess.AddBlob()
110-
// -> path := a.DigestPath(blob.Digest())
111-
// -> common.DigestToFileName(digest) // returns "sha256.DIGEST"
112-
func convertBlobPaths(fs vfs.FileSystem, dir string) error {
113-
blobsDir := filepath.Join(dir, "blobs")
114-
entries, err := vfs.ReadDir(fs, blobsDir)
115-
if err != nil {
116-
return err
95+
// collectTags returns a deduplicated list of tags combining:
96+
// - Resource version FIRST (becomes org.opencontainers.image.ref.name for ORAS resolution)
97+
// - Original tags from the source artifact set (preserves mutable refs like "latest")
98+
func collectTags(src *artifactset.ArtifactSet, version string) []string {
99+
seen := make(map[string]struct{})
100+
var tags []string
101+
102+
// Add resource version first - it becomes the primary tag (org.opencontainers.image.ref.name)
103+
if version != "" {
104+
seen[version] = struct{}{}
105+
tags = append(tags, version)
117106
}
118107

119-
for _, e := range entries {
120-
if e.IsDir() {
121-
continue
122-
}
123-
name := e.Name()
124-
if algo, dig, ok := strings.Cut(name, "."); ok {
125-
algoDir := filepath.Join(blobsDir, algo)
126-
fs.MkdirAll(algoDir, 0o755)
127-
fs.Rename(filepath.Join(blobsDir, name), filepath.Join(algoDir, dig))
108+
// Add original tags from source index annotations
109+
mainDigest := src.GetMain()
110+
for _, m := range src.GetIndex().Manifests {
111+
if m.Digest == mainDigest && m.Annotations != nil {
112+
if tagStr := artifactset.RetrieveTags(m.Annotations); tagStr != "" {
113+
for _, t := range strings.Split(tagStr, ",") {
114+
t = strings.TrimSpace(t)
115+
if _, ok := seen[t]; !ok && t != "" {
116+
seen[t] = struct{}{}
117+
tags = append(tags, t)
118+
}
119+
}
120+
}
128121
}
129122
}
130-
return nil
123+
124+
return tags
131125
}

api/utils/accessobj/filesystemaccess.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"io"
66
"os"
7+
"path/filepath"
78
"sync"
89

910
"github.com/mandelsoft/vfs/pkg/vfs"
@@ -140,6 +141,14 @@ func (a *FileSystemBlobAccess) AddBlob(blob blobaccess.BlobAccess) error {
140141
}
141142

142143
defer r.Close()
144+
145+
// Create parent directory if path uses nested structure (e.g., blobs/sha256/DIGEST vs blobs/sha256.DIGEST)
146+
if dir := filepath.Dir(path); dir != a.base.GetInfo().GetElementDirectoryName() {
147+
if err := a.base.GetFileSystem().MkdirAll(dir, a.base.GetMode()|0o111); err != nil {
148+
return fmt.Errorf("unable to create directory for '%s': %w", path, err)
149+
}
150+
}
151+
143152
w, err := a.base.GetFileSystem().OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, a.base.GetMode()&0o666)
144153
if err != nil {
145154
return fmt.Errorf("unable to open file '%s': %w", path, err)

0 commit comments

Comments
 (0)