Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 58 additions & 12 deletions api/oci/extensions/repositories/artifactset/artifactset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,26 @@ var _ = Describe("artifact management", func() {
for _, fi := range infos {
blobs = append(blobs, fi.Name())
}
Expect(blobs).To(ContainElements(
"sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"))
if format == artifactset.FORMAT_OCI {
// OCI format uses nested directory structure: blobs/sha256/DIGEST
Expect(blobs).To(ContainElement("sha256"))
subInfos, err := vfs.ReadDir(tempfs, "test/"+artifactset.BlobsDirectoryName+"/sha256")
Expect(err).To(Succeed())
subBlobs := []string{}
for _, fi := range subInfos {
subBlobs = append(subBlobs, fi.Name())
}
Expect(subBlobs).To(ContainElements(
"3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"))
} else {
// OCM format uses flat structure: blobs/sha256.DIGEST
Expect(blobs).To(ContainElements(
"sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"))
}
})

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

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

switch header.Typeflag {
case tar.TypeDir:
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
dirs = append(dirs, header.Name)
case tar.TypeReg:
files = append(files, header.Name)
}
}

// Check directories
Expect(dirs).To(ContainElement(artifactset.BlobsDirectoryName))
if format == artifactset.FORMAT_OCI {
Expect(dirs).To(ContainElement("blobs/sha256"))
}

// Check files based on format
elems := []interface{}{
artifactset.DescriptorFileName(format),
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
}
if format == artifactset.FORMAT_OCI {
elems = append(elems, artifactset.OCILayouFileName)
elems = append(elems,
"blobs/sha256/3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"blobs/sha256/810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
} else {
elems = append(elems,
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
}
Expect(files).To(ContainElements(elems))
})
Expand Down Expand Up @@ -160,6 +191,7 @@ var _ = Describe("artifact management", func() {
tr := tar.NewReader(zip)

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

switch header.Typeflag {
case tar.TypeDir:
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
dirs = append(dirs, header.Name)
case tar.TypeReg:
files = append(files, header.Name)
}
}

// Check directories
Expect(dirs).To(ContainElement(artifactset.BlobsDirectoryName))
if format == artifactset.FORMAT_OCI {
Expect(dirs).To(ContainElement("blobs/sha256"))
}

// Check files based on format
elems := []interface{}{
artifactset.DescriptorFileName(format),
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
}
if format == artifactset.FORMAT_OCI {
elems = append(elems, artifactset.OCILayouFileName)
elems = append(elems,
"blobs/sha256/3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"blobs/sha256/810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
} else {
elems = append(elems,
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50")
}
Expect(files).To(ContainElements(elems))
})
Expand Down
193 changes: 193 additions & 0 deletions api/oci/extensions/repositories/artifactset/ctf_roundtrip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
//go:build integration

package artifactset_test

import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/mandelsoft/vfs/pkg/osfs"
"oras.land/oras-go/v2/content/oci"

envhelper "ocm.software/ocm/api/helper/env"
. "ocm.software/ocm/cmds/ocm/testhelper"
)

const (
componentName = "example.com/hello"
componentVersion = "1.0.0"
resourceName = "hello-image"
resourceVersion = "1.0.0"
imageReference = "hello-world:linux"
)

// This test verifies the CTF-based workflow with --oci-layout flag:
// 1. Create a CTF archive with an OCI image resource
// 2. Transfer CTF to new CTF with --copy-resources
// 3. Verify components and resources in target CTF
// 4. Download resource with --oci-layout flag:
// - Creates OCI Image Layout directory (index.json, oci-layout, blobs/sha256/...)
// - Verifies layout structure is OCI-compliant
// - Resolves artifact by resource version using ORAS
// 5. Download resource without --oci-layout:
// - Creates OCM artifact set format (not OCI-compliant)
// - Verifies layout structure check fails
var _ = Describe("CTF to CTF-with-resource to OCI roundtrip", Ordered, func() {
var (
tempDir string
sourceCTF string
targetCTF string
resourcesOciDir string
resourcesOcmDir string
env *TestEnv
)

BeforeAll(func() {

var err error
tempDir, err = os.MkdirTemp("", "ocm-ctf-oci-layout-*")
Expect(err).To(Succeed())

env = NewTestEnv(envhelper.FileSystem(osfs.New()))
})

AfterAll(func() {
if env != nil {
env.Cleanup()
}
})

It("creates CTF using stable OCM release", func() {
sourceCTF = filepath.Join(tempDir, "ctf-source")
constructorFile := filepath.Join(tempDir, "component-constructor.yaml")
constructorContent := `components:
- name: ` + componentName + `
version: ` + componentVersion + `
provider:
name: example.com
resources:
- name: ` + resourceName + `
type: ociImage
version: ` + resourceVersion + `
relation: external
access:
type: ociArtifact
imageReference: ` + imageReference + `
`
err := os.WriteFile(constructorFile, []byte(constructorContent), 0644)
Expect(err).To(Succeed(), "MUST create constructor file")

// Create CTF directory
err = os.MkdirAll(sourceCTF, 0755)
Expect(err).To(Succeed(), "MUST create CTF directory")

// Use the current OCM version to create the CTF
GinkgoWriter.Printf("Creating CTF using current OCM version\n")

buf := bytes.NewBuffer(nil)
err = env.CatchOutput(buf).Execute(
"add", "componentversions",
"--create",
"--file", sourceCTF,
constructorFile,
)
GinkgoWriter.Printf("OCM output: %s\n", buf.String())
Expect(err).To(Succeed(), "OCM MUST create CTF: %s", buf.String())
})

It("transfers CTF to new CTF with --copy-resources", func() {
targetCTF = filepath.Join(tempDir, "ctf-target")
buf := bytes.NewBuffer(nil)
GinkgoWriter.Printf(" #### transfer componentversions " + sourceCTF + " " + targetCTF + "--copy-resources")
Expect(env.CatchOutput(buf).Execute(
"transfer", "componentversions", sourceCTF, targetCTF, "--copy-resources")).To(Succeed())
GinkgoWriter.Printf("Transfer output: %s\n", buf.String())
})

It("verifies components and resources in target CTF", func() {
buf := bytes.NewBuffer(nil)
Expect(env.CatchOutput(buf).Execute("get", "componentversions", targetCTF)).To(Succeed())
GinkgoWriter.Printf("Components: %s\n", buf.String())
Expect(buf.String()).To(ContainSubstring(componentName))

// List resources
buf.Reset()
Expect(env.CatchOutput(buf).Execute(
"get", "resources",
targetCTF+"//"+componentName+":"+componentVersion,
)).To(Succeed())
GinkgoWriter.Printf("Resources: %s\n", buf.String())
Expect(buf.String()).To(ContainSubstring(resourceName))
})

It("downloads resource from target CTF with --oci-layout", func() {
resourcesOciDir = filepath.Join(tempDir, "resource-oci-layout")

buf := bytes.NewBuffer(nil)
Expect(env.CatchOutput(buf).Execute(
"download", "resources",
"--oci-layout",
"-O", resourcesOciDir,
targetCTF+"//"+componentName+":"+componentVersion,
resourceName,
)).To(Succeed())
Expect(verifyOCILayoutStructure(resourcesOciDir)).To(Succeed())

store, err := oci.New(resourcesOciDir)
Expect(err).To(Succeed(), "ORAS failed to open OCI layout: %w", err)

srcDesc, err := store.Resolve(context.Background(), resourceVersion)
Expect(err).To(Succeed(), "resource MUST be OCI compliant")
GinkgoWriter.Printf("Successfully verified OCI layout with ORAS: digest=%s\n", srcDesc.Digest)

})

It("downloads resource from target CTF without --oci-layout", func() {
resourcesOcmDir = filepath.Join(tempDir, "resource-ocm-layout")

buf := bytes.NewBuffer(nil)
Expect(env.CatchOutput(buf).Execute(
"download", "resources",
"-O", resourcesOcmDir,
targetCTF+"//"+componentName+":"+componentVersion,
resourceName,
)).To(Succeed())
Expect(verifyOCILayoutStructure(resourcesOcmDir)).ToNot(Succeed())
GinkgoWriter.Printf("Resource download output: %s\n", buf.String())
})
})

// verifyOCILayoutStructure checks that the OCI layout has the expected structure.
// Returns an error if any required file or directory is missing.
func verifyOCILayoutStructure(ociDir string) error {
// Check oci-layout file exists
ociLayoutPath := filepath.Join(ociDir, "oci-layout")
if _, err := os.Stat(ociLayoutPath); err != nil {
return fmt.Errorf("oci-layout file MUST exist: %w", err)
}

// Check index.json exists
indexPath := filepath.Join(ociDir, "index.json")
if _, err := os.Stat(indexPath); err != nil {
return fmt.Errorf("index.json MUST exist: %w", err)
}

// Check blobs directory exists
blobsDir := filepath.Join(ociDir, "blobs")
info, err := os.Stat(blobsDir)
if err != nil {
return fmt.Errorf("blobs directory MUST exist: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("blobs MUST be a directory, got file")
}

GinkgoWriter.Printf("OCI layout structure verified: oci-layout, index.json, blobs/\n")
return nil
}
18 changes: 17 additions & 1 deletion api/oci/extensions/repositories/artifactset/format.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package artifactset

import (
"path/filepath"
"strings"
"sync"

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

type accessObjectInfo struct {
accessobj.DefaultAccessObjectInfo
ociFormat bool
}

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

func NewAccessObjectInfo(fmts ...string) accessobj.AccessObjectInfo {
a := &accessObjectInfo{
baseInfo,
DefaultAccessObjectInfo: baseInfo,
}
oci := IsOCIDefaultFormat()
if len(fmts) > 0 {
Expand All @@ -84,6 +87,19 @@ func NewAccessObjectInfo(fmts ...string) accessobj.AccessObjectInfo {
func (a *accessObjectInfo) setOCI() {
a.DescriptorFileName = OCIArtifactSetDescriptorFileName
a.AdditionalFiles = []string{OCILayouFileName}
a.ociFormat = true
}

// SubPath returns the path for a blob. For OCI format, converts "sha256.DIGEST"
// to "blobs/sha256/DIGEST" per OCI Image Layout Specification.
func (a *accessObjectInfo) SubPath(name string) string {
if a.ociFormat {
// Convert sha256.DIGEST to sha256/DIGEST for OCI compliance
if algo, dig, ok := strings.Cut(name, "."); ok {
return filepath.Join(a.ElementDirectoryName, algo, dig)
}
}
return filepath.Join(a.ElementDirectoryName, name)
}

func (a *accessObjectInfo) setOCM() {
Expand Down
1 change: 1 addition & 0 deletions api/ocm/extensions/download/handlers/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ import (
_ "ocm.software/ocm/api/ocm/extensions/download/handlers/dirtree"
_ "ocm.software/ocm/api/ocm/extensions/download/handlers/executable"
_ "ocm.software/ocm/api/ocm/extensions/download/handlers/helm"
_ "ocm.software/ocm/api/ocm/extensions/download/handlers/ocilayout"
_ "ocm.software/ocm/api/ocm/extensions/download/handlers/ocirepo"
)
Loading
Loading