Skip to content

Commit 58d89bd

Browse files
committed
fix: add --oci-layout flag for ocm download artifact|resources
<!-- markdownlint-disable MD041 --> This change adds an optional --oci-layout flag to the `ocm download artifacts|resources` 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 <[email protected]>
1 parent 02bcc9f commit 58d89bd

File tree

20 files changed

+1008
-31
lines changed

20 files changed

+1008
-31
lines changed

.github/config/wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ actiondescriptor
66
additionalresource
77
addversion
88
adr
9+
afterall
910
aggregative
1011
aml
1112
anchore

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

Lines changed: 72 additions & 14 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_COMPLIANT {
85+
// OCI compliant 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 and OCI formats use 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,39 @@ 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_COMPLIANT {
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
}
133-
if format == artifactset.FORMAT_OCI {
155+
if format == artifactset.FORMAT_OCI_COMPLIANT {
134156
elems = append(elems, artifactset.OCILayouFileName)
157+
elems = append(elems,
158+
"blobs/sha256/3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
159+
"blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
160+
"blobs/sha256/810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
161+
} else if format == artifactset.FORMAT_OCI {
162+
elems = append(elems, artifactset.OCILayouFileName)
163+
elems = append(elems,
164+
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
165+
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
166+
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
167+
} else {
168+
elems = append(elems,
169+
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
170+
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
171+
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
135172
}
136173
Expect(files).To(ContainElements(elems))
137174
})
@@ -160,6 +197,7 @@ var _ = Describe("artifact management", func() {
160197
tr := tar.NewReader(zip)
161198

162199
files := []string{}
200+
dirs := []string{}
163201
for {
164202
header, err := tr.Next()
165203
if err != nil {
@@ -171,19 +209,39 @@ var _ = Describe("artifact management", func() {
171209

172210
switch header.Typeflag {
173211
case tar.TypeDir:
174-
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
212+
dirs = append(dirs, header.Name)
175213
case tar.TypeReg:
176214
files = append(files, header.Name)
177215
}
178216
}
217+
218+
// Check directories
219+
Expect(dirs).To(ContainElement(artifactset.BlobsDirectoryName))
220+
if format == artifactset.FORMAT_OCI_COMPLIANT {
221+
Expect(dirs).To(ContainElement("blobs/sha256"))
222+
}
223+
224+
// Check files based on format
179225
elems := []interface{}{
180226
artifactset.DescriptorFileName(format),
181-
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
182-
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
183-
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
184227
}
185-
if format == artifactset.FORMAT_OCI {
228+
if format == artifactset.FORMAT_OCI_COMPLIANT {
229+
elems = append(elems, artifactset.OCILayouFileName)
230+
elems = append(elems,
231+
"blobs/sha256/3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
232+
"blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
233+
"blobs/sha256/810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
234+
} else if format == artifactset.FORMAT_OCI {
186235
elems = append(elems, artifactset.OCILayouFileName)
236+
elems = append(elems,
237+
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
238+
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
239+
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
240+
} else {
241+
elems = append(elems,
242+
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
243+
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
244+
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
187245
}
188246
Expect(files).To(ContainElements(elems))
189247
})
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
//go:build integration
2+
3+
package artifactset_test
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"fmt"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"strings"
13+
14+
"github.com/go-logr/logr"
15+
. "github.com/onsi/ginkgo/v2"
16+
. "github.com/onsi/gomega"
17+
"oras.land/oras-go/v2/content/oci"
18+
19+
"github.com/mandelsoft/vfs/pkg/osfs"
20+
21+
envhelper "ocm.software/ocm/api/helper/env"
22+
. "ocm.software/ocm/cmds/ocm/testhelper"
23+
)
24+
25+
const (
26+
componentName = "example.com/hello"
27+
componentVersion = "1.0.0"
28+
resourceName = "hello-image"
29+
resourceVersion = "1.0.0"
30+
imageReference = "hello-world:linux"
31+
)
32+
33+
// This test verifies the CTF-based workflow with --oci-layout flag:
34+
// 1. Create a CTF archive with an OCI image resource
35+
// 2. Transfer CTF to new CTF with --copy-resources
36+
// 3. Verify components and resources in target CTF
37+
// 4. Download resource with --oci-layout flag:
38+
// - Creates OCI Image Layout directory (index.json, oci-layout, blobs/sha256/...)
39+
// - Verifies layout structure is OCI-compliant
40+
// - Resolves artifact by resource version using ORAS
41+
// 5. Download resource without --oci-layout:
42+
// - Creates OCM artifact set format (not OCI-compliant)
43+
// - Verifies layout structure check fails
44+
var _ = Describe("CTF to CTF-with-resource to OCI roundtrip", Ordered, func() {
45+
var (
46+
tempDir string
47+
sourceCTF string
48+
targetCTF string
49+
resourcesOciTgz string
50+
resourcesOciTar string
51+
resourcesOcmDir string
52+
env *TestEnv
53+
log logr.Logger
54+
)
55+
56+
BeforeAll(func() {
57+
log = GinkgoLogr
58+
59+
var err error
60+
tempDir, err = os.MkdirTemp("", "ocm-ctf-oci-layout-*")
61+
Expect(err).To(Succeed())
62+
63+
env = NewTestEnv(envhelper.FileSystem(osfs.New()))
64+
})
65+
66+
AfterAll(func() {
67+
if env != nil {
68+
env.Cleanup()
69+
}
70+
})
71+
72+
It("creates CTF ", func() {
73+
sourceCTF = filepath.Join(tempDir, "ctf-source")
74+
constructorFile := filepath.Join(tempDir, "component-constructor.yaml")
75+
constructorContent := `components:
76+
- name: ` + componentName + `
77+
version: ` + componentVersion + `
78+
provider:
79+
name: example.com
80+
resources:
81+
- name: ` + resourceName + `
82+
type: ociImage
83+
version: ` + resourceVersion + `
84+
relation: external
85+
access:
86+
type: ociArtifact
87+
imageReference: ` + imageReference + `
88+
`
89+
err := os.WriteFile(constructorFile, []byte(constructorContent), 0644)
90+
Expect(err).To(Succeed(), "MUST create constructor file")
91+
92+
// Create CTF directory
93+
err = os.MkdirAll(sourceCTF, 0755)
94+
Expect(err).To(Succeed(), "MUST create CTF directory")
95+
log.Info("Creating CTF using current OCM version")
96+
97+
buf := bytes.NewBuffer(nil)
98+
err = env.CatchOutput(buf).Execute(
99+
"add", "componentversions",
100+
"--create",
101+
"--file", sourceCTF,
102+
constructorFile,
103+
)
104+
log.Info("OCM output", "output", buf.String())
105+
Expect(err).To(Succeed(), "OCM MUST create CTF: %s", buf.String())
106+
})
107+
108+
It("transfers CTF to new CTF with --copy-resources", func() {
109+
targetCTF = filepath.Join(tempDir, "ctf-target")
110+
buf := bytes.NewBuffer(nil)
111+
log.Info("transfer componentversions", "source", sourceCTF, "target", targetCTF)
112+
Expect(env.CatchOutput(buf).Execute(
113+
"transfer", "componentversions", sourceCTF, targetCTF, "--copy-resources")).To(Succeed())
114+
log.Info("Transfer output", "output", buf.String())
115+
})
116+
117+
It("verifies components and resources in target CTF", func() {
118+
buf := bytes.NewBuffer(nil)
119+
Expect(env.CatchOutput(buf).Execute("get", "componentversions", targetCTF)).To(Succeed())
120+
log.Info("Components", "output", buf.String())
121+
Expect(buf.String()).To(ContainSubstring(componentName))
122+
123+
// List resources
124+
buf.Reset()
125+
Expect(env.CatchOutput(buf).Execute(
126+
"get", "resources",
127+
targetCTF+"//"+componentName+":"+componentVersion,
128+
)).To(Succeed())
129+
log.Info("Resources", "output", buf.String())
130+
Expect(buf.String()).To(ContainSubstring(resourceName))
131+
})
132+
133+
It("downloads resource as OCI tar with --oci-layout", func() {
134+
resourcesOciTgz = filepath.Join(tempDir, "resource-oci-layout.tgz")
135+
resourcesOciTar = filepath.Join(tempDir, "resource-oci-layout.tar")
136+
137+
// Download as tgz
138+
buf := bytes.NewBuffer(nil)
139+
Expect(env.CatchOutput(buf).Execute(
140+
"download", "resources", "--oci-layout",
141+
"-O", resourcesOciTgz,
142+
targetCTF+"//"+componentName+":"+componentVersion, resourceName,
143+
)).To(Succeed())
144+
145+
// Gunzip to tar for oras and docker
146+
cmd := exec.Command("sh", "-c", "gunzip -c "+resourcesOciTgz+" > "+resourcesOciTar)
147+
out, err := cmd.CombinedOutput()
148+
Expect(err).To(Succeed(), "gunzip failed: %s", string(out))
149+
log.Info("Downloaded and extracted OCI tar", "path", resourcesOciTar)
150+
})
151+
152+
It("verifies ref.name with oras-go library", func() {
153+
// Open OCI layout from tar using oras-go
154+
store, err := oci.NewFromTar(context.Background(), resourcesOciTar)
155+
Expect(err).To(Succeed(), "oras failed to open tar")
156+
157+
// Resolve by resource version tag - this proves ref.name is set correctly
158+
desc, err := store.Resolve(context.Background(), resourceVersion)
159+
Expect(err).To(Succeed(), "oras failed to resolve by resource version")
160+
log.Info("ORAS resolved manifest by resource version", "version", resourceVersion, "digest", desc.Digest)
161+
})
162+
163+
It("loads OCI tar to Docker and runs the image", func() {
164+
// Load the OCI tar into Docker
165+
cmd := exec.Command("docker", "load", "-i", resourcesOciTar)
166+
out, err := cmd.CombinedOutput()
167+
Expect(err).To(Succeed(), "docker load failed: %s", string(out))
168+
169+
// Parse image ID and run directly
170+
imageID := parseImageIDFromDockerLoad(string(out))
171+
Expect(imageID).NotTo(BeEmpty(), "failed to parse image ID from docker load output")
172+
173+
cmd = exec.Command("docker", "run", "--rm", imageID)
174+
out, err = cmd.CombinedOutput()
175+
Expect(err).To(Succeed(), "docker run failed: %s", string(out))
176+
Expect(string(out)).To(ContainSubstring("Hello from Docker"))
177+
178+
// Cleanup
179+
_ = exec.Command("docker", "rmi", imageID).Run()
180+
})
181+
182+
It("downloads resource from target CTF without --oci-layout", func() {
183+
resourcesOcmDir = filepath.Join(tempDir, "resource-ocm-layout")
184+
185+
buf := bytes.NewBuffer(nil)
186+
Expect(env.CatchOutput(buf).Execute(
187+
"download", "resources",
188+
"-O", resourcesOcmDir,
189+
targetCTF+"//"+componentName+":"+componentVersion,
190+
resourceName,
191+
)).To(Succeed())
192+
Expect(verifyOCILayoutStructure(resourcesOcmDir)).ToNot(Succeed())
193+
log.Info("Resource download output", "output", buf.String())
194+
})
195+
})
196+
197+
// verifyOCILayoutStructure checks that the OCI layout has the expected structure.
198+
// Returns an error if any required file or directory is missing.
199+
func verifyOCILayoutStructure(ociDir string) error {
200+
// Check oci-layout file exists
201+
ociLayoutPath := filepath.Join(ociDir, "oci-layout")
202+
if _, err := os.Stat(ociLayoutPath); err != nil {
203+
return fmt.Errorf("oci-layout file MUST exist: %w", err)
204+
}
205+
206+
// Check index.json exists
207+
indexPath := filepath.Join(ociDir, "index.json")
208+
if _, err := os.Stat(indexPath); err != nil {
209+
return fmt.Errorf("index.json MUST exist: %w", err)
210+
}
211+
212+
// Check blobs directory exists
213+
blobsDir := filepath.Join(ociDir, "blobs")
214+
info, err := os.Stat(blobsDir)
215+
if err != nil {
216+
return fmt.Errorf("blobs directory MUST exist: %w", err)
217+
}
218+
if !info.IsDir() {
219+
return fmt.Errorf("blobs MUST be a directory, got file")
220+
}
221+
222+
GinkgoLogr.Info("OCI layout structure verified: oci-layout, index.json, blobs/")
223+
return nil
224+
}
225+
226+
// parseImageIDFromDockerLoad parses the image ID from docker load output.
227+
func parseImageIDFromDockerLoad(output string) string {
228+
for _, line := range strings.Split(output, "\n") {
229+
if _, id, ok := strings.Cut(line, "Loaded image ID: "); ok {
230+
return strings.TrimSpace(id)
231+
}
232+
if _, id, ok := strings.Cut(line, "Loaded image: "); ok {
233+
return strings.TrimSpace(id)
234+
}
235+
}
236+
return ""
237+
}

0 commit comments

Comments
 (0)