Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/config/wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ actiondescriptor
additionalresource
addversion
adr
afterall
aggregative
aml
anchore
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/lint_and_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ jobs:
${{ env.cache_name }}-${{ runner.os }}-go-
env:
cache_name: run-tests-go-cache # needs to be the same key in the end as in the build step
- name: Set up skopeo
uses: warjiang/setup-skopeo@71776e03c10d767c04af8924fe5a67763f9b3d34
- name: Build
run: make build -j
- name: Test
Expand Down
56 changes: 37 additions & 19 deletions api/oci/extensions/repositories/artifactset/artifactset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ func defaultManifestFill(a *artifactset.ArtifactSet) {
MustWithOffset(1, Calling(a.AddArtifact(art)))
}

// blobPath returns the expected blob path based on format.
// FORMAT_OCI_COMPLIANT uses nested paths (blobs/sha256/DIGEST),
// others use flat paths (blobs/sha256.DIGEST).
// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs
func blobPath(format, digest string) string {
if format == artifactset.FORMAT_OCI_COMPLIANT {
return "blobs/sha256/" + digest
}
return "blobs/sha256." + digest
}

var _ = Describe("artifact management", func() {
var tempfs vfs.FileSystem
var opts accessio.Options
Expand Down Expand Up @@ -75,16 +86,15 @@ var _ = Describe("artifact management", func() {
Expect(vfs.FileExists(tempfs, "test/"+desc)).To(BeTrue())
Expect(vfs.FileExists(tempfs, "test/"+artifactset.OCILayouFileName)).To(Equal(desc == artifactset.OCIArtifactSetDescriptorFileName))

infos, err := vfs.ReadDir(tempfs, "test/"+artifactset.BlobsDirectoryName)
Expect(err).To(Succeed())
blobs := []string{}
for _, fi := range infos {
blobs = append(blobs, fi.Name())
// Check blobs exist at expected paths
for _, digest := range []string{
"3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
} {
path := "test/" + blobPath(format, digest)
Expect(vfs.FileExists(tempfs, path)).To(BeTrue(), "blob not found: %s", path)
}
Expect(blobs).To(ContainElements(
"sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"))
})

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

switch header.Typeflag {
case tar.TypeDir:
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
// FORMAT_OCI_COMPLIANT has nested dirs (blobs/sha256/), others have only blobs/
// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs
if format != artifactset.FORMAT_OCI_COMPLIANT {
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
}
case tar.TypeReg:
files = append(files, header.Name)
}
}
elems := []interface{}{
artifactset.DescriptorFileName(format),
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
blobPath(format, "3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a"),
blobPath(format, "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"),
blobPath(format, "810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"),
}
if format == artifactset.FORMAT_OCI {
if format == artifactset.FORMAT_OCI || format == artifactset.FORMAT_OCI_COMPLIANT {
elems = append(elems, artifactset.OCILayouFileName)
}
Expect(files).To(ContainElements(elems))
Expand Down Expand Up @@ -171,18 +185,22 @@ var _ = Describe("artifact management", func() {

switch header.Typeflag {
case tar.TypeDir:
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
// FORMAT_OCI_COMPLIANT has nested dirs (blobs/sha256/), others have only blobs/
// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs
if format != artifactset.FORMAT_OCI_COMPLIANT {
Expect(header.Name).To(Equal(artifactset.BlobsDirectoryName))
}
case tar.TypeReg:
files = append(files, header.Name)
}
}
elems := []interface{}{
artifactset.DescriptorFileName(format),
"blobs/sha256.3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a",
"blobs/sha256.44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"blobs/sha256.810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50",
blobPath(format, "3d05e105e350edf5be64fe356f4906dd3f9bf442a279e4142db9879bba8e677a"),
blobPath(format, "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"),
blobPath(format, "810ff2fb242a5dee4220f2cb0e6a519891fb67f2f828a6cab4ef8894633b1f50"),
}
if format == artifactset.FORMAT_OCI {
if format != artifactset.FORMAT_OCM {
elems = append(elems, artifactset.OCILayouFileName)
}
Expect(files).To(ContainElements(elems))
Expand Down
248 changes: 248 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,248 @@
//go:build integration

package artifactset_test

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"

"github.com/go-logr/logr"
"github.com/mandelsoft/vfs/pkg/osfs"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
"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 = "ghcr.io/piotrjanik/open-component-model/hello-ocm:latest"
)

func gunzipToTar(tgzPath string) (string, error) {
tarPath := tgzPath[:len(tgzPath)-3] + "tar"
out, err := exec.Command("sh", "-c", "gunzip -c "+tgzPath+" > "+tarPath).CombinedOutput()
if err != nil {
return "", fmt.Errorf("gunzip failed: %s", string(out))
}
return tarPath, nil
}

// 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
resourcesOciTgz string
resourcesOcmTgz string
imageTag string
env *TestEnv
log logr.Logger
)

BeforeAll(func() {
log = GinkgoLogr

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

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

AfterAll(func() {
if imageTag != "" {
_ = exec.Command("docker", "rmi", imageTag).Run()
}
if env != nil {
env.Cleanup()
}
})

It("creates CTF ", 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")
log.Info("Creating CTF using current OCM version")

buf := bytes.NewBuffer(nil)
err = env.CatchOutput(buf).Execute(
"add", "componentversions",
"--create",
"--file", sourceCTF,
constructorFile,
)
log.Info("OCM output", "output", 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)
log.Info("transfer componentversions", "source", sourceCTF, "target", targetCTF)
Expect(env.CatchOutput(buf).Execute(
"transfer", "componentversions", sourceCTF, targetCTF, "--copy-resources")).To(Succeed())
log.Info("Transfer output", "output", 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())
log.Info("Components", "output", buf.String())
Expect(buf.String()).To(ContainSubstring(componentName))

// List resources
buf.Reset()
Expect(env.CatchOutput(buf).Execute(
"get", "resources",
targetCTF+"//"+componentName+":"+componentVersion,
)).To(Succeed())
log.Info("Resources", "output", buf.String())
Expect(buf.String()).To(ContainSubstring(resourceName))
})

It("downloads resource as OCI tgz with --oci-layout", func() {
resourcesOciTgz = filepath.Join(tempDir, "resource-oci-layout.tgz")

buf := bytes.NewBuffer(nil)
Expect(env.CatchOutput(buf).Execute(
"download", "resources", "--oci-layout",
"-O", resourcesOciTgz,
targetCTF+"//"+componentName+":"+componentVersion, resourceName,
)).To(Succeed())
log.Info("Downloaded OCI tgz", "path", resourcesOciTgz)
})

It("verifies with oras-go library", func() {
ctx := context.Background()
tarPath, err := gunzipToTar(resourcesOciTgz)
Expect(err).To(Succeed())

// Open OCI layout from tar using oras-go
store, err := oci.NewFromTar(ctx, tarPath)
Expect(err).To(Succeed(), "oras failed to open tar as OCI layout")

// Resolve by resource version tag
desc, err := store.Resolve(ctx, resourceVersion)
Expect(err).To(Succeed(), "oras failed to resolve by resource version tag")
Expect(desc.MediaType).ToNot(BeEmpty())

// Verify multi-arch image index
Expect(desc.MediaType).To(Equal(ociv1.MediaTypeImageIndex))

// Fetch and parse index
reader, err := store.Fetch(ctx, desc)
Expect(err).To(Succeed(), "failed to fetch index")
indexData, err := io.ReadAll(reader)
Expect(err).To(Succeed(), "failed to read index")
Expect(reader.Close()).To(Succeed())

var index ociv1.Index
Expect(json.Unmarshal(indexData, &index)).To(Succeed())
Expect(index.Manifests).To(HaveLen(2), "expected 2 platform manifests (amd64, arm64)")

// Fetch first platform manifest
reader, err = store.Fetch(ctx, index.Manifests[0])
Expect(err).To(Succeed(), "failed to fetch platform manifest")
manifestData, err := io.ReadAll(reader)
Expect(err).To(Succeed(), "failed to read manifest")
Expect(reader.Close()).To(Succeed())

var manifest ociv1.Manifest
Expect(json.Unmarshal(manifestData, &manifest)).To(Succeed())
Expect(manifest.Layers).ToNot(BeEmpty())

// Verify config
configReader, err := store.Fetch(ctx, manifest.Config)
Expect(err).To(Succeed(), "failed to fetch config")
configData, err := io.ReadAll(configReader)
Expect(err).To(Succeed(), "failed to read config")
Expect(configReader.Close()).To(Succeed(), "failed to close reader")
var config ociv1.Image
Expect(json.Unmarshal(configData, &config)).To(Succeed())
Expect(config.Config.Entrypoint).ToNot(BeEmpty())
})

It("copies OCI archive to Docker with skopeo", func() {
// Use skopeo to copy from OCI archive (tgz) to docker daemon
imageTag = "ocm-test-hello:" + resourceVersion
cmd := exec.Command("skopeo", "copy",
"oci-archive:"+resourcesOciTgz+":"+resourceVersion,
"docker-daemon:"+imageTag,
"--override-os=linux")
out, err := cmd.CombinedOutput()
Expect(err).To(Succeed(), "skopeo copy failed: %s", string(out))
})

It("runs image copied by skopeo", func() {
log.Info("Running image", "tag", imageTag)

cmd := exec.Command("docker", "run", "--rm", imageTag)
out, err := cmd.CombinedOutput()
Expect(err).To(Succeed(), "docker run failed: %s", string(out))
Expect(string(out)).To(ContainSubstring("Hello OCM!"))
})

It("downloads resource from target CTF without --oci-layout and verifies it", func() {
ctx := context.Background()
resourcesOcmTgz = filepath.Join(tempDir, "resource-ocm-layout")

buf := bytes.NewBuffer(nil)
Expect(env.CatchOutput(buf).Execute(
"download", "resources",
"-O", resourcesOcmTgz,
targetCTF+"//"+componentName+":"+componentVersion,
resourceName,
)).To(Succeed())
log.Info("Resource download output", "output", buf.String())
tarPath, err := gunzipToTar(resourcesOcmTgz)
Expect(err).To(Succeed())
// Verify oras cannot open OCM format as OCI layout
store, err := oci.NewFromTar(ctx, tarPath)
Expect(err).To(Succeed(), "oras should open non-OCI layout")
_, err = store.Resolve(ctx, resourceVersion)
Expect(err).ToNot(Succeed(), "oras should fail to resolve by resource version tag")
})
})
Loading
Loading