Skip to content

Commit e3f8ac0

Browse files
authored
fix: add --oci-layout flag for OCI Image Layout blob paths (#1723)
## What this PR does / why we need it This PR adds an `--oci-layout` flag to `ocm download artifact` and `ocm download resources` commands to produce OCI Image Layout compliant output. ### Changes Introduced #### New `--oci-layout` Flag When specified, OCI artifacts are downloaded with: - **Nested blob directories**: `blobs/sha256/<digest>` instead of `blobs/sha256.<digest>` - **OCI Image Layout files**: `index.json` + `oci-layout` - **Proper tagging**: Resource version added to `org.opencontainers.image.ref.name` annotation #### New Format Constant Added `FORMAT_OCI_COMPLIANT` (`oci/v1+compliant`) which behaves like `FORMAT_OCI` but uses nested blob directory structure per OCI Image Layout specification. ### Behavior | Command | Without `--oci-layout` | With `--oci-layout` | |---------|------------------------|---------------------| | `download artifact` | Flat blob paths | Nested blob paths, OCI compliant | | `download resources` | Standard handlers | OCI layout handler for OCI artifacts | ### `ref.name` Annotation - **`download artifact`**: Uses tag from source reference (e.g., `latest`) - **`download resources`**: Uses resource version Fixes #1668 --------- Signed-off-by: Piotr Janik <piotr.janik@sap.com>
1 parent 6509bcb commit e3f8ac0

File tree

21 files changed

+1054
-98
lines changed

21 files changed

+1054
-98
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

.github/workflows/lint_and_test.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ jobs:
4949
${{ env.cache_name }}-${{ runner.os }}-go-
5050
env:
5151
cache_name: run-tests-go-cache # needs to be the same key in the end as in the build step
52+
- name: Set up skopeo
53+
uses: warjiang/setup-skopeo@71776e03c10d767c04af8924fe5a67763f9b3d34
5254
- name: Build
5355
run: make build -j
5456
- name: Test

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

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ func defaultManifestFill(a *artifactset.ArtifactSet) {
2929
MustWithOffset(1, Calling(a.AddArtifact(art)))
3030
}
3131

32+
// blobPath returns the expected blob path based on format.
33+
// FORMAT_OCI_COMPLIANT uses nested paths (blobs/sha256/DIGEST),
34+
// others use flat paths (blobs/sha256.DIGEST).
35+
// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs
36+
func blobPath(format, digest string) string {
37+
if format == artifactset.FORMAT_OCI_COMPLIANT {
38+
return "blobs/sha256/" + digest
39+
}
40+
return "blobs/sha256." + digest
41+
}
42+
3243
var _ = Describe("artifact management", func() {
3344
var tempfs vfs.FileSystem
3445
var opts accessio.Options
@@ -75,16 +86,15 @@ var _ = Describe("artifact management", func() {
7586
Expect(vfs.FileExists(tempfs, "test/"+desc)).To(BeTrue())
7687
Expect(vfs.FileExists(tempfs, "test/"+artifactset.OCILayouFileName)).To(Equal(desc == artifactset.OCIArtifactSetDescriptorFileName))
7788

78-
infos, err := vfs.ReadDir(tempfs, "test/"+artifactset.BlobsDirectoryName)
79-
Expect(err).To(Succeed())
80-
blobs := []string{}
81-
for _, fi := range infos {
82-
blobs = append(blobs, fi.Name())
89+
// Check blobs exist at expected paths
90+
for _, digest := range []string{
91+
"3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
92+
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
93+
"810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
94+
} {
95+
path := "test/" + blobPath(format, digest)
96+
Expect(vfs.FileExists(tempfs, path)).To(BeTrue(), "blob not found: %s", path)
8397
}
84-
Expect(blobs).To(ContainElements(
85-
"sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
86-
"sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
87-
"sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"))
8898
})
8999

90100
TestForAllFormats("instantiate tgz artifact", func(format string) {
@@ -119,18 +129,22 @@ var _ = Describe("artifact management", func() {
119129

120130
switch header.Typeflag {
121131
case tar.TypeDir:
122-
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
132+
// FORMAT_OCI_COMPLIANT has nested dirs (blobs/sha256/), others have only blobs/
133+
// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs
134+
if format != artifactset.FORMAT_OCI_COMPLIANT {
135+
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
136+
}
123137
case tar.TypeReg:
124138
files = append(files, header.Name)
125139
}
126140
}
127141
elems := []interface{}{
128142
artifactset.DescriptorFileName(format),
129-
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
130-
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
131-
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
143+
blobPath(format, "3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a"),
144+
blobPath(format, "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"),
145+
blobPath(format, "810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"),
132146
}
133-
if format == artifactset.FORMAT_OCI {
147+
if format == artifactset.FORMAT_OCI || format == artifactset.FORMAT_OCI_COMPLIANT {
134148
elems = append(elems, artifactset.OCILayouFileName)
135149
}
136150
Expect(files).To(ContainElements(elems))
@@ -171,18 +185,22 @@ var _ = Describe("artifact management", func() {
171185

172186
switch header.Typeflag {
173187
case tar.TypeDir:
174-
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
188+
// FORMAT_OCI_COMPLIANT has nested dirs (blobs/sha256/), others have only blobs/
189+
// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs
190+
if format != artifactset.FORMAT_OCI_COMPLIANT {
191+
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
192+
}
175193
case tar.TypeReg:
176194
files = append(files, header.Name)
177195
}
178196
}
179197
elems := []interface{}{
180198
artifactset.DescriptorFileName(format),
181-
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
182-
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
183-
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
199+
blobPath(format, "3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a"),
200+
blobPath(format, "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"),
201+
blobPath(format, "810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"),
184202
}
185-
if format == artifactset.FORMAT_OCI {
203+
if format != artifactset.FORMAT_OCM {
186204
elems = append(elems, artifactset.OCILayouFileName)
187205
}
188206
Expect(files).To(ContainElements(elems))
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
//go:build integration
2+
3+
package artifactset_test
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"os"
12+
"os/exec"
13+
"path/filepath"
14+
15+
"github.com/go-logr/logr"
16+
"github.com/mandelsoft/vfs/pkg/osfs"
17+
. "github.com/onsi/ginkgo/v2"
18+
. "github.com/onsi/gomega"
19+
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
20+
"oras.land/oras-go/v2/content/oci"
21+
22+
envhelper "ocm.software/ocm/api/helper/env"
23+
. "ocm.software/ocm/cmds/ocm/testhelper"
24+
)
25+
26+
const (
27+
componentName = "example.com/hello"
28+
componentVersion = "1.0.0"
29+
resourceName = "hello-image"
30+
resourceVersion = "1.0.0"
31+
imageReference = "ghcr.io/piotrjanik/open-component-model/hello-ocm:latest"
32+
)
33+
34+
func gunzipToTar(tgzPath string) (string, error) {
35+
tarPath := tgzPath[:len(tgzPath)-3] + "tar"
36+
out, err := exec.Command("sh", "-c", "gunzip -c "+tgzPath+" > "+tarPath).CombinedOutput()
37+
if err != nil {
38+
return "", fmt.Errorf("gunzip failed: %s", string(out))
39+
}
40+
return tarPath, nil
41+
}
42+
43+
// This test verifies the CTF-based workflow with --oci-layout flag:
44+
// 1. Create a CTF archive with an OCI image resource
45+
// 2. Transfer CTF to new CTF with --copy-resources
46+
// 3. Verify components and resources in target CTF
47+
// 4. Download resource with --oci-layout flag:
48+
// - Creates OCI Image Layout directory (index.json, oci-layout, blobs/sha256/...)
49+
// - Verifies layout structure is OCI-compliant
50+
// - Resolves artifact by resource version using ORAS
51+
// 5. Download resource without --oci-layout:
52+
// - Creates OCM artifact set format (not OCI-compliant)
53+
// - Verifies layout structure check fails
54+
var _ = Describe("CTF to CTF-with-resource to OCI roundtrip", Ordered, func() {
55+
var (
56+
tempDir string
57+
sourceCTF string
58+
targetCTF string
59+
resourcesOciTgz string
60+
resourcesOcmTgz string
61+
imageTag string
62+
env *TestEnv
63+
log logr.Logger
64+
)
65+
66+
BeforeAll(func() {
67+
log = GinkgoLogr
68+
69+
var err error
70+
tempDir, err = os.MkdirTemp("", "ocm-ctf-oci-layout-*")
71+
Expect(err).To(Succeed())
72+
73+
env = NewTestEnv(envhelper.FileSystem(osfs.New()))
74+
})
75+
76+
AfterAll(func() {
77+
if imageTag != "" {
78+
_ = exec.Command("docker", "rmi", imageTag).Run()
79+
}
80+
if env != nil {
81+
env.Cleanup()
82+
}
83+
})
84+
85+
It("creates CTF ", func() {
86+
sourceCTF = filepath.Join(tempDir, "ctf-source")
87+
constructorFile := filepath.Join(tempDir, "component-constructor.yaml")
88+
constructorContent := `components:
89+
- name: ` + componentName + `
90+
version: ` + componentVersion + `
91+
provider:
92+
name: example.com
93+
resources:
94+
- name: ` + resourceName + `
95+
type: ociImage
96+
version: ` + resourceVersion + `
97+
relation: external
98+
access:
99+
type: ociArtifact
100+
imageReference: ` + imageReference + `
101+
`
102+
err := os.WriteFile(constructorFile, []byte(constructorContent), 0644)
103+
Expect(err).To(Succeed(), "MUST create constructor file")
104+
105+
// Create CTF directory
106+
err = os.MkdirAll(sourceCTF, 0755)
107+
Expect(err).To(Succeed(), "MUST create CTF directory")
108+
log.Info("Creating CTF using current OCM version")
109+
110+
buf := bytes.NewBuffer(nil)
111+
err = env.CatchOutput(buf).Execute(
112+
"add", "componentversions",
113+
"--create",
114+
"--file", sourceCTF,
115+
constructorFile,
116+
)
117+
log.Info("OCM output", "output", buf.String())
118+
Expect(err).To(Succeed(), "OCM MUST create CTF: %s", buf.String())
119+
})
120+
121+
It("transfers CTF to new CTF with --copy-resources", func() {
122+
targetCTF = filepath.Join(tempDir, "ctf-target")
123+
buf := bytes.NewBuffer(nil)
124+
log.Info("transfer componentversions", "source", sourceCTF, "target", targetCTF)
125+
Expect(env.CatchOutput(buf).Execute(
126+
"transfer", "componentversions", sourceCTF, targetCTF, "--copy-resources")).To(Succeed())
127+
log.Info("Transfer output", "output", buf.String())
128+
})
129+
130+
It("verifies components and resources in target CTF", func() {
131+
buf := bytes.NewBuffer(nil)
132+
Expect(env.CatchOutput(buf).Execute("get", "componentversions", targetCTF)).To(Succeed())
133+
log.Info("Components", "output", buf.String())
134+
Expect(buf.String()).To(ContainSubstring(componentName))
135+
136+
// List resources
137+
buf.Reset()
138+
Expect(env.CatchOutput(buf).Execute(
139+
"get", "resources",
140+
targetCTF+"//"+componentName+":"+componentVersion,
141+
)).To(Succeed())
142+
log.Info("Resources", "output", buf.String())
143+
Expect(buf.String()).To(ContainSubstring(resourceName))
144+
})
145+
146+
It("downloads resource as OCI tgz with --oci-layout", func() {
147+
resourcesOciTgz = filepath.Join(tempDir, "resource-oci-layout.tgz")
148+
149+
buf := bytes.NewBuffer(nil)
150+
Expect(env.CatchOutput(buf).Execute(
151+
"download", "resources", "--oci-layout",
152+
"-O", resourcesOciTgz,
153+
targetCTF+"//"+componentName+":"+componentVersion, resourceName,
154+
)).To(Succeed())
155+
log.Info("Downloaded OCI tgz", "path", resourcesOciTgz)
156+
})
157+
158+
It("verifies with oras-go library", func() {
159+
ctx := context.Background()
160+
tarPath, err := gunzipToTar(resourcesOciTgz)
161+
Expect(err).To(Succeed())
162+
163+
// Open OCI layout from tar using oras-go
164+
store, err := oci.NewFromTar(ctx, tarPath)
165+
Expect(err).To(Succeed(), "oras failed to open tar as OCI layout")
166+
167+
// Resolve by resource version tag
168+
desc, err := store.Resolve(ctx, resourceVersion)
169+
Expect(err).To(Succeed(), "oras failed to resolve by resource version tag")
170+
Expect(desc.MediaType).ToNot(BeEmpty())
171+
172+
// Verify multi-arch image index
173+
Expect(desc.MediaType).To(Equal(ociv1.MediaTypeImageIndex))
174+
175+
// Fetch and parse index
176+
reader, err := store.Fetch(ctx, desc)
177+
Expect(err).To(Succeed(), "failed to fetch index")
178+
indexData, err := io.ReadAll(reader)
179+
Expect(err).To(Succeed(), "failed to read index")
180+
Expect(reader.Close()).To(Succeed())
181+
182+
var index ociv1.Index
183+
Expect(json.Unmarshal(indexData, &index)).To(Succeed())
184+
Expect(index.Manifests).To(HaveLen(2), "expected 2 platform manifests (amd64, arm64)")
185+
186+
// Fetch first platform manifest
187+
reader, err = store.Fetch(ctx, index.Manifests[0])
188+
Expect(err).To(Succeed(), "failed to fetch platform manifest")
189+
manifestData, err := io.ReadAll(reader)
190+
Expect(err).To(Succeed(), "failed to read manifest")
191+
Expect(reader.Close()).To(Succeed())
192+
193+
var manifest ociv1.Manifest
194+
Expect(json.Unmarshal(manifestData, &manifest)).To(Succeed())
195+
Expect(manifest.Layers).ToNot(BeEmpty())
196+
197+
// Verify config
198+
configReader, err := store.Fetch(ctx, manifest.Config)
199+
Expect(err).To(Succeed(), "failed to fetch config")
200+
configData, err := io.ReadAll(configReader)
201+
Expect(err).To(Succeed(), "failed to read config")
202+
Expect(configReader.Close()).To(Succeed(), "failed to close reader")
203+
var config ociv1.Image
204+
Expect(json.Unmarshal(configData, &config)).To(Succeed())
205+
Expect(config.Config.Entrypoint).ToNot(BeEmpty())
206+
})
207+
208+
It("copies OCI archive to Docker with skopeo", func() {
209+
// Use skopeo to copy from OCI archive (tgz) to docker daemon
210+
imageTag = "ocm-test-hello:" + resourceVersion
211+
cmd := exec.Command("skopeo", "copy",
212+
"oci-archive:"+resourcesOciTgz+":"+resourceVersion,
213+
"docker-daemon:"+imageTag,
214+
"--override-os=linux")
215+
out, err := cmd.CombinedOutput()
216+
Expect(err).To(Succeed(), "skopeo copy failed: %s", string(out))
217+
})
218+
219+
It("runs image copied by skopeo", func() {
220+
log.Info("Running image", "tag", imageTag)
221+
222+
cmd := exec.Command("docker", "run", "--rm", imageTag)
223+
out, err := cmd.CombinedOutput()
224+
Expect(err).To(Succeed(), "docker run failed: %s", string(out))
225+
Expect(string(out)).To(ContainSubstring("Hello OCM!"))
226+
})
227+
228+
It("downloads resource from target CTF without --oci-layout and verifies it", func() {
229+
ctx := context.Background()
230+
resourcesOcmTgz = filepath.Join(tempDir, "resource-ocm-layout")
231+
232+
buf := bytes.NewBuffer(nil)
233+
Expect(env.CatchOutput(buf).Execute(
234+
"download", "resources",
235+
"-O", resourcesOcmTgz,
236+
targetCTF+"//"+componentName+":"+componentVersion,
237+
resourceName,
238+
)).To(Succeed())
239+
log.Info("Resource download output", "output", buf.String())
240+
tarPath, err := gunzipToTar(resourcesOcmTgz)
241+
Expect(err).To(Succeed())
242+
// Verify oras cannot open OCM format as OCI layout
243+
store, err := oci.NewFromTar(ctx, tarPath)
244+
Expect(err).To(Succeed(), "oras should open non-OCI layout")
245+
_, err = store.Resolve(ctx, resourceVersion)
246+
Expect(err).ToNot(Succeed(), "oras should fail to resolve by resource version tag")
247+
})
248+
})

0 commit comments

Comments
 (0)