Skip to content

Commit 4a226ce

Browse files
committed
fix: add --oci-layout flag for ocm download resource
<!-- 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 71ba4a5 commit 4a226ce

File tree

7 files changed

+672
-1
lines changed

7 files changed

+672
-1
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
//go:build integration
2+
3+
package artifactset_test
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
12+
. "github.com/onsi/ginkgo/v2"
13+
. "github.com/onsi/gomega"
14+
15+
"github.com/mandelsoft/vfs/pkg/osfs"
16+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
17+
"oras.land/oras-go/v2/content"
18+
"oras.land/oras-go/v2/content/oci"
19+
20+
envhelper "ocm.software/ocm/api/helper/env"
21+
. "ocm.software/ocm/cmds/ocm/testhelper"
22+
)
23+
24+
const (
25+
componentName = "example.com/hello"
26+
componentVersion = "1.0.0"
27+
resourceName = "hello-image"
28+
resourceVersion = "1.0.0"
29+
imageName = "hello-world"
30+
imageTag = "linux"
31+
32+
// Use the latest stable OCM release image
33+
ocmImage = "ghcr.io/open-component-model/ocm/ocm.software/ocmcli/ocmcli-image:0.34.2"
34+
)
35+
36+
// verifyOCILayoutWithORAS opens an OCI layout and resolves a tag using ORAS.
37+
// Returns the descriptor if successful.
38+
func verifyOCILayoutWithORAS(ctx context.Context, ociDir, tag string) (ocispec.Descriptor, error) {
39+
store, err := oci.New(ociDir)
40+
if err != nil {
41+
return ocispec.Descriptor{}, fmt.Errorf("ORAS failed to open OCI layout: %w", err)
42+
}
43+
44+
srcDesc, err := store.Resolve(ctx, tag)
45+
if err != nil {
46+
// Try resource version as fallback
47+
srcDesc, err = store.Resolve(ctx, resourceVersion)
48+
if err != nil {
49+
indexData, readErr := os.ReadFile(filepath.Join(ociDir, "index.json"))
50+
if readErr == nil {
51+
GinkgoWriter.Printf("index.json content: %s\n", string(indexData))
52+
}
53+
return ocispec.Descriptor{}, fmt.Errorf("ORAS could not resolve tag %q: %w", tag, err)
54+
}
55+
}
56+
57+
successors, err := content.Successors(ctx, store, srcDesc)
58+
if err != nil {
59+
return ocispec.Descriptor{}, fmt.Errorf("ORAS failed to get successors: %w", err)
60+
}
61+
if len(successors) == 0 {
62+
return ocispec.Descriptor{}, fmt.Errorf("ORAS found no successors")
63+
}
64+
GinkgoWriter.Printf("Found %d successors for tag %q\n", len(successors), tag)
65+
66+
return srcDesc, nil
67+
}
68+
69+
// This test verifies the CTF-based workflow with --oci-layout flag:
70+
// 1. Create a CTF archive using current OCM version
71+
// 2. Transfer CTF to new CTF with --copy-resources
72+
// 3. Download resource from new CTF with --oci-layout (creates OCI Image Layout)
73+
// 4. Verify downloaded OCI layout is compliant with ORAS store.Resolve()
74+
// 5. Download resource without --oci-layout (creates single file blob)
75+
// 6. Verify the difference between OCI layout and raw blob download
76+
var _ = Describe("CTF to CTF-with-resource to OCI roundtrip", Ordered, func() {
77+
var (
78+
tempDir string
79+
sourceCTF string
80+
targetCTF string
81+
resourcesOciDir string
82+
resourcesOcmDir string
83+
env *TestEnv
84+
)
85+
86+
BeforeAll(func() {
87+
88+
var err error
89+
tempDir, err = os.MkdirTemp("", "ocm-ctf-oci-layout-*")
90+
Expect(err).To(Succeed())
91+
92+
env = NewTestEnv(envhelper.FileSystem(osfs.New()))
93+
})
94+
95+
AfterAll(func() {
96+
if env != nil {
97+
env.Cleanup()
98+
}
99+
})
100+
101+
It("creates CTF using stable OCM release", func() {
102+
sourceCTF = filepath.Join(tempDir, "ctf-source")
103+
constructorFile := filepath.Join(tempDir, "component-constructor.yaml")
104+
constructorContent := `components:
105+
- name: ` + componentName + `
106+
version: ` + componentVersion + `
107+
provider:
108+
name: example.com
109+
resources:
110+
- name: ` + resourceName + `
111+
type: ociImage
112+
version: ` + resourceVersion + `
113+
relation: external
114+
access:
115+
type: ociArtifact
116+
imageReference: ` + imageName + `:` + imageTag + `
117+
`
118+
err := os.WriteFile(constructorFile, []byte(constructorContent), 0644)
119+
Expect(err).To(Succeed(), "MUST create constructor file")
120+
121+
// Create CTF directory
122+
err = os.MkdirAll(sourceCTF, 0755)
123+
Expect(err).To(Succeed(), "MUST create CTF directory")
124+
125+
// Use the current OCM version to create the CTF
126+
GinkgoWriter.Printf("Creating CTF using current OCM version\n")
127+
128+
buf := bytes.NewBuffer(nil)
129+
err = env.CatchOutput(buf).Execute(
130+
"add", "componentversions",
131+
"--create",
132+
"--file", sourceCTF,
133+
constructorFile,
134+
)
135+
GinkgoWriter.Printf("OCM output: %s\n", buf.String())
136+
Expect(err).To(Succeed(), "OCM MUST create CTF: %s", buf.String())
137+
})
138+
139+
It("transfers CTF to new CTF with --copy-resources", func() {
140+
targetCTF = filepath.Join(tempDir, "ctf-target")
141+
buf := bytes.NewBuffer(nil)
142+
GinkgoWriter.Printf(" #### transfer componentversions " + sourceCTF + " " + targetCTF + "--copy-resources")
143+
Expect(env.CatchOutput(buf).Execute(
144+
"transfer", "componentversions", sourceCTF, targetCTF, "--copy-resources")).To(Succeed())
145+
GinkgoWriter.Printf("Transfer output: %s\n", buf.String())
146+
})
147+
148+
It("verifies components and resources in target CTF", func() {
149+
buf := bytes.NewBuffer(nil)
150+
Expect(env.CatchOutput(buf).Execute("get", "componentversions", targetCTF)).To(Succeed())
151+
GinkgoWriter.Printf("Components: %s\n", buf.String())
152+
Expect(buf.String()).To(ContainSubstring(componentName))
153+
154+
// List resources
155+
buf.Reset()
156+
Expect(env.CatchOutput(buf).Execute(
157+
"get", "resources",
158+
targetCTF+"//"+componentName+":"+componentVersion,
159+
)).To(Succeed())
160+
GinkgoWriter.Printf("Resources: %s\n", buf.String())
161+
Expect(buf.String()).To(ContainSubstring(resourceName))
162+
})
163+
164+
It("downloads resource from target CTF with --oci-layout", func() {
165+
resourcesOciDir = filepath.Join(tempDir, "resource-oci-layout")
166+
167+
buf := bytes.NewBuffer(nil)
168+
Expect(env.CatchOutput(buf).Execute(
169+
"download", "resources",
170+
"--oci-layout",
171+
"-O", resourcesOciDir,
172+
targetCTF+"//"+componentName+":"+componentVersion,
173+
resourceName,
174+
)).To(Succeed())
175+
Expect(verifyOCILayoutStructure(resourcesOciDir)).To(Succeed())
176+
store, err := oci.New(resourcesOciDir)
177+
Expect(err).To(Succeed(), "ORAS failed to open OCI layout: %w", err)
178+
179+
srcDesc, err := store.Resolve(context.Background(), resourceVersion)
180+
Expect(err).To(Succeed(), "resource MUST be OCI compliant")
181+
GinkgoWriter.Printf("Successfully verified OCI layout with ORAS: digest=%s\n", srcDesc.Digest)
182+
183+
})
184+
185+
It("downloads resource from target CTF without --oci-layout", func() {
186+
resourcesOcmDir = filepath.Join(tempDir, "resource-ocm-layout")
187+
188+
buf := bytes.NewBuffer(nil)
189+
Expect(env.CatchOutput(buf).Execute(
190+
"download", "resources",
191+
"-O", resourcesOcmDir,
192+
targetCTF+"//"+componentName+":"+componentVersion,
193+
resourceName,
194+
)).To(Succeed())
195+
Expect(verifyOCILayoutStructure(resourcesOcmDir)).ToNot(Succeed())
196+
GinkgoWriter.Printf("Resource download output: %s\n", buf.String())
197+
})
198+
})
199+
200+
// verifyOCILayoutStructure checks that the OCI layout has the expected structure.
201+
// Returns an error if any required file or directory is missing.
202+
func verifyOCILayoutStructure(ociDir string) error {
203+
// Check oci-layout file exists
204+
ociLayoutPath := filepath.Join(ociDir, "oci-layout")
205+
if _, err := os.Stat(ociLayoutPath); err != nil {
206+
return fmt.Errorf("oci-layout file MUST exist: %w", err)
207+
}
208+
209+
// Check index.json exists
210+
indexPath := filepath.Join(ociDir, "index.json")
211+
if _, err := os.Stat(indexPath); err != nil {
212+
return fmt.Errorf("index.json MUST exist: %w", err)
213+
}
214+
215+
// Check blobs directory exists
216+
blobsDir := filepath.Join(ociDir, "blobs")
217+
info, err := os.Stat(blobsDir)
218+
if err != nil {
219+
return fmt.Errorf("blobs directory MUST exist: %w", err)
220+
}
221+
if !info.IsDir() {
222+
return fmt.Errorf("blobs MUST be a directory, got file")
223+
}
224+
225+
GinkgoWriter.Printf("OCI layout structure verified: oci-layout, index.json, blobs/\n")
226+
return nil
227+
}

0 commit comments

Comments
 (0)