diff --git a/.github/config/wordlist.txt b/.github/config/wordlist.txt index 132782bb40..22702b6cf6 100644 --- a/.github/config/wordlist.txt +++ b/.github/config/wordlist.txt @@ -6,6 +6,7 @@ actiondescriptor additionalresource addversion adr +afterall aggregative aml anchore diff --git a/.github/workflows/lint_and_test.yaml b/.github/workflows/lint_and_test.yaml index c94a322abb..cf4644421d 100644 --- a/.github/workflows/lint_and_test.yaml +++ b/.github/workflows/lint_and_test.yaml @@ -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 diff --git a/api/oci/extensions/repositories/artifactset/artifactset_test.go b/api/oci/extensions/repositories/artifactset/artifactset_test.go index 9b55623838..a5b5fa2e04 100644 --- a/api/oci/extensions/repositories/artifactset/artifactset_test.go +++ b/api/oci/extensions/repositories/artifactset/artifactset_test.go @@ -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 @@ -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) { @@ -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)) @@ -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)) diff --git a/api/oci/extensions/repositories/artifactset/ctf_roundtrip_test.go b/api/oci/extensions/repositories/artifactset/ctf_roundtrip_test.go new file mode 100644 index 0000000000..b8bd15c68d --- /dev/null +++ b/api/oci/extensions/repositories/artifactset/ctf_roundtrip_test.go @@ -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") + }) +}) diff --git a/api/oci/extensions/repositories/artifactset/format.go b/api/oci/extensions/repositories/artifactset/format.go index fb752d95a3..be2186d0a7 100644 --- a/api/oci/extensions/repositories/artifactset/format.go +++ b/api/oci/extensions/repositories/artifactset/format.go @@ -1,6 +1,8 @@ package artifactset import ( + "path/filepath" + "strings" "sync" "github.com/mandelsoft/goutils/errors" @@ -14,7 +16,7 @@ import ( ) const ( - // The artifact descriptor name for artifact format. + // ArtifactSetDescriptorFileName is the artifact descriptor name for artifact format. ArtifactSetDescriptorFileName = "artifact-descriptor.json" BlobsDirectoryName = "blobs" @@ -30,7 +32,7 @@ func IsOCIDefaultFormat() bool { func DescriptorFileName(format string) string { switch format { - case FORMAT_OCI: + case FORMAT_OCI, FORMAT_OCI_COMPLIANT: return OCIArtifactSetDescriptorFileName case FORMAT_OCM: return ArtifactSetDescriptorFileName @@ -42,6 +44,7 @@ func DescriptorFileName(format string) string { type accessObjectInfo struct { accessobj.DefaultAccessObjectInfo + format string // FORMAT_OCI, FORMAT_OCI_COMPLIANT, or FORMAT_OCM } var _ accessobj.AccessObjectInfo = (*accessObjectInfo)(nil) @@ -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 { @@ -70,6 +73,9 @@ func NewAccessObjectInfo(fmts ...string) accessobj.AccessObjectInfo { oci = false case FORMAT_OCI: oci = true + case FORMAT_OCI_COMPLIANT: + a.format = FORMAT_OCI_COMPLIANT + oci = true case "": } } @@ -91,6 +97,19 @@ func (a *accessObjectInfo) setOCM() { a.AdditionalFiles = nil } +// SubPath returns the path for a blob. For OCI-compliant format, converts +// "sha256.DIGEST" to "blobs/sha256/DIGEST" per OCI Image Layout Specification. +// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs +func (a *accessObjectInfo) SubPath(name string) string { + if a.format == FORMAT_OCI_COMPLIANT { + // 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) setupOCIFS(fs vfs.FileSystem, mode vfs.FileMode) error { data := `{ "imageLayoutVersion": "1.0.0" diff --git a/api/oci/extensions/repositories/artifactset/testhelper/formats.go b/api/oci/extensions/repositories/artifactset/testhelper/formats.go index 3a4ad09223..acc14367ee 100644 --- a/api/oci/extensions/repositories/artifactset/testhelper/formats.go +++ b/api/oci/extensions/repositories/artifactset/testhelper/formats.go @@ -12,6 +12,7 @@ func TestForAllFormats(msg string, f func(fmt string)) { DescribeTable(fmt.Sprintf("%s: structure format handling", msg), f, Entry("OCM format", artifactset.FORMAT_OCM), Entry("OCI format", artifactset.FORMAT_OCI), + Entry("OCI compliant format", artifactset.FORMAT_OCI_COMPLIANT), ) } @@ -19,5 +20,6 @@ func FTestForAllFormats(msg string, f func(fmt string)) { FDescribeTable(fmt.Sprintf("%s: structure format handling", msg), f, Entry("OCM format", artifactset.FORMAT_OCM), Entry("OCI format", artifactset.FORMAT_OCI), + Entry("OCI compliant format", artifactset.FORMAT_OCI_COMPLIANT), ) } diff --git a/api/oci/extensions/repositories/artifactset/type.go b/api/oci/extensions/repositories/artifactset/type.go index ae8bb18cb7..2d4d631fc7 100644 --- a/api/oci/extensions/repositories/artifactset/type.go +++ b/api/oci/extensions/repositories/artifactset/type.go @@ -22,8 +22,9 @@ func init() { } const ( - FORMAT_OCI = "oci/v1" - FORMAT_OCM = "ocm/v1" + FORMAT_OCI = "oci/v1" + FORMAT_OCI_COMPLIANT = "oci/v1+compliant" // OCI Image Layout with nested blob directories (blobs/sha256/DIGEST) + FORMAT_OCM = "ocm/v1" ) type RepositorySpec struct { diff --git a/api/ocm/extensions/download/handlers/init.go b/api/ocm/extensions/download/handlers/init.go index 6f8614fa44..e36de1939d 100644 --- a/api/ocm/extensions/download/handlers/init.go +++ b/api/ocm/extensions/download/handlers/init.go @@ -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" ) diff --git a/api/ocm/extensions/download/handlers/ocilayout/handler.go b/api/ocm/extensions/download/handlers/ocilayout/handler.go new file mode 100644 index 0000000000..b2134f8d3a --- /dev/null +++ b/api/ocm/extensions/download/handlers/ocilayout/handler.go @@ -0,0 +1,127 @@ +package ocilayout + +import ( + "strings" + + "github.com/mandelsoft/goutils/errors" + "github.com/mandelsoft/goutils/finalizer" + "github.com/mandelsoft/vfs/pkg/vfs" + + "ocm.software/ocm/api/oci/artdesc" + "ocm.software/ocm/api/oci/extensions/repositories/artifactset" + "ocm.software/ocm/api/ocm/cpi" + "ocm.software/ocm/api/ocm/extensions/download" + "ocm.software/ocm/api/utils/accessio" + "ocm.software/ocm/api/utils/accessobj" + "ocm.software/ocm/api/utils/logging" + common "ocm.software/ocm/api/utils/misc" +) + +const PRIORITY = 200 + +type Handler struct{} + +func New() download.Handler { + return &Handler{} +} + +func (h *Handler) Download(p common.Printer, racc cpi.ResourceAccess, path string, fs vfs.FileSystem) (ok bool, _ string, err error) { + var finalize finalizer.Finalizer + defer finalize.FinalizeWithErrorPropagation(&err) + + // Step 1: Get access method to read resource content + m, err := racc.AccessMethod() + if err != nil { + return false, "", err + } + finalize.Close(m) + + // Step 2: Check MIME type - only handle OCI artifacts (tar/tar+gzip) + if !isOCIArtifact(m.MimeType()) { + logging.Logger().Debug("skipping non-OCI artifact", "mime", m.MimeType()) + return false, "", nil + } + + if path == "" { + path = racc.Meta().GetName() + } + + // Step 3: Open resource blob as artifact set (contains OCI image) + src, err := artifactset.OpenFromDataAccess(accessobj.ACC_READONLY, m.MimeType(), m) + if err != nil { + return true, "", errors.Wrapf(err, "open artifact set") + } + finalize.Close(src) + + // Step 4: Get the main artifact from the set + art, err := src.GetArtifact(src.GetMain().String()) + if err != nil { + return true, "", errors.Wrapf(err, "get artifact") + } + finalize.Close(art) + + // Step 5: Create target with OCI-compliant format (index.json + oci-layout + nested blobs) + // Always use tgz format for OCI layout + target, err := artifactset.Create(accessobj.ACC_CREATE, path, 0o755, + accessio.PathFileSystem(fs), + accessobj.FormatTGZ, + artifactset.StructureFormat(artifactset.FORMAT_OCI_COMPLIANT), + ) + if err != nil { + return true, "", errors.Wrapf(err, "create OCI layout") + } + + // Step 6: Transfer all manifests and blobs to target with hybrid tagging: + // - Original tags from source (e.g., "latest") + // - Resource version (e.g., "1.0.0") + tags := collectTags(src, racc.Meta().GetVersion()) + if err := artifactset.TransferArtifact(art, target, tags...); err != nil { + err = errors.Join(err, target.Close()) + return true, "", errors.Wrapf(err, "transfer artifact") + } + + if err := target.Close(); err != nil { + return true, "", errors.Wrapf(err, "close target") + } + + resourceName := racc.Meta().GetName() + resourceVersion := racc.Meta().GetVersion() + resourceType := racc.Meta().GetType() + + p.Printf("Resource '%s' (type: %s, version: %s) saved to: %s with tag: %s\n", + resourceName, resourceType, resourceVersion, path, resourceVersion) + return true, path, nil +} + +func isOCIArtifact(mime string) bool { + return artdesc.IsOCIMediaType(mime) && + (strings.HasSuffix(mime, "+tar") || strings.HasSuffix(mime, "+tar+gzip")) +} + +// collectTags returns a deduplicated list of tags combining: +// - Resource version FIRST (becomes org.opencontainers.image.ref.name for ORAS resolution) +// - Original tags from the source artifact set (preserves mutable refs like "latest") +func collectTags(src *artifactset.ArtifactSet, version string) []string { + var tags []string + + // Add resource version first - it becomes the primary tag (org.opencontainers.image.ref.name) + if version != "" { + tags = append(tags, version) + } + + // Add original tags from source index annotations + mainDigest := src.GetMain() + for _, m := range src.GetIndex().Manifests { + if m.Digest == mainDigest && m.Annotations != nil { + if tagStr := artifactset.RetrieveTags(m.Annotations); tagStr != "" { + for t := range strings.SplitSeq(tagStr, ",") { + if t = strings.TrimSpace(t); t != "" && t != version { + tags = append(tags, t) + } + } + } + } + } + + return tags +} diff --git a/api/utils/accessobj/filesystemaccess.go b/api/utils/accessobj/filesystemaccess.go index a15f37b05b..6fa9a51bc5 100644 --- a/api/utils/accessobj/filesystemaccess.go +++ b/api/utils/accessobj/filesystemaccess.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "path/filepath" "sync" "github.com/mandelsoft/vfs/pkg/vfs" @@ -140,6 +141,15 @@ func (a *FileSystemBlobAccess) AddBlob(blob blobaccess.BlobAccess) error { } defer r.Close() + + // Create parent directory if path uses nested structure (e.g., blobs/sha256/DIGEST vs blobs/sha256.DIGEST) + // See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs + if dir := filepath.Dir(path); dir != a.base.GetInfo().GetElementDirectoryName() { + if err := a.base.GetFileSystem().MkdirAll(dir, a.base.GetMode()|0o111); err != nil { + return fmt.Errorf("unable to create directory for '%s': %w", path, err) + } + } + w, err := a.base.GetFileSystem().OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, a.base.GetMode()&0o666) if err != nil { return fmt.Errorf("unable to open file '%s': %w", path, err) diff --git a/api/utils/accessobj/format-tar.go b/api/utils/accessobj/format-tar.go index 72d8532116..448a0c7e85 100644 --- a/api/utils/accessobj/format-tar.go +++ b/api/utils/accessobj/format-tar.go @@ -59,49 +59,98 @@ func (h *TarHandler) Create(info AccessObjectInfo, path string, opts accessio.Op } // Write tars the current descriptor and its artifacts. -func (h *TarHandler) Write(obj *AccessObject, path string, opts accessio.Options, mode vfs.FileMode) error { +func (h *TarHandler) Write(obj *AccessObject, path string, opts accessio.Options, mode vfs.FileMode) (err error) { writer, err := opts.WriterFor(path, mode) if err != nil { return fmt.Errorf("unable to write: %w", err) } - defer writer.Close() + defer func() { + err = errors.Join(err, writer.Close()) + }() - return h.WriteToStream(obj, writer, opts) + // Check if OCI layout (has subdirectories in element dir) + if h.hasNestedDirs(obj) { + return h.writeToStream(obj, writer, opts, h.writeOCICompliant) + } + return h.writeToStream(obj, writer, opts, h.writeElementsFlat) } -func (h TarHandler) WriteToStream(obj *AccessObject, writer io.Writer, opts accessio.Options) error { - if h.compression != nil { - w, err := h.compression.Compressor(writer, nil, nil) - if err != nil { - return fmt.Errorf("unable to compress writer: %w", err) +// hasNestedDirs checks if the element directory contains subdirectories (OCI layout). +func (h *TarHandler) hasNestedDirs(obj *AccessObject) bool { + elemDir := obj.info.GetElementDirectoryName() + entries, err := vfs.ReadDir(obj.fs, elemDir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() { + return true } - defer w.Close() + } + return false +} + +// writeToStream is the common implementation for writing tar streams. +// The elementWriter parameter determines how element content is written (flat vs nested). +func (h TarHandler) writeToStream(obj *AccessObject, writer io.Writer, opts accessio.Options, elementWriter func(*AccessObject, *tar.Writer) error) error { + writer, cleanup, err := h.applyCompression(writer) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } - writer = w + tw := tar.NewWriter(writer) + + if err := h.writeDescriptor(obj, tw); err != nil { + return err + } + + if err := h.writeAdditionalFiles(obj, tw); err != nil { + return err + } + + if err := elementWriter(obj, tw); err != nil { + return err } - // write descriptor - _, err := obj.Update() + return tw.Close() +} + +// applyCompression wraps the writer with compression if configured. +// Returns the wrapped writer and a cleanup function (may be nil). +func (h TarHandler) applyCompression(writer io.Writer) (io.Writer, func(), error) { + if h.compression == nil { + return writer, nil, nil + } + w, err := h.compression.Compressor(writer, nil, nil) if err != nil { + return nil, nil, fmt.Errorf("unable to compress writer: %w", err) + } + return w, func() { w.Close() }, nil +} + +// writeDescriptor updates the access object and writes the descriptor to the tar. +func (h TarHandler) writeDescriptor(obj *AccessObject, tw *tar.Writer) error { + if _, err := obj.Update(); err != nil { return fmt.Errorf("unable to update access object: %w", err) } data, err := obj.state.GetBlob() if err != nil { - return fmt.Errorf("unable to write to get state blob: %w", err) + return fmt.Errorf("unable to get state blob: %w", err) } defer data.Close() - tw := tar.NewWriter(writer) - cdHeader := &tar.Header{ + header := &tar.Header{ Name: obj.info.GetDescriptorFileName(), Size: data.Size(), Mode: FileMode, ModTime: ModTime, } - - if err := tw.WriteHeader(cdHeader); err != nil { + if err := tw.WriteHeader(header); err != nil { return fmt.Errorf("unable to write descriptor header: %w", err) } @@ -111,55 +160,55 @@ func (h TarHandler) WriteToStream(obj *AccessObject, writer io.Writer, opts acce } defer r.Close() - if _, err := io.Copy(tw, r); err != nil { + if _, err := io.CopyN(tw, r, data.Size()); err != nil { return fmt.Errorf("unable to write descriptor content: %w", err) } + return nil +} - // Copy additional files - for _, f := range obj.info.GetAdditionalFiles(obj.fs) { - ok, err := vfs.IsFile(obj.fs, f) +// writeAdditionalFiles copies additional files to the tar if they exist. +func (h TarHandler) writeAdditionalFiles(obj *AccessObject, tw *tar.Writer) error { + for _, path := range obj.info.GetAdditionalFiles(obj.fs) { + // Skip if file doesn't exist + ok, err := vfs.IsFile(obj.fs, path) if err != nil { - return errors.Wrapf(err, "cannot check for file %q", f) + return errors.Wrapf(err, "cannot check for file %q", path) + } + if !ok { + continue } - if ok { - fi, err := obj.fs.Stat(f) - if err != nil { - return errors.Wrapf(err, "cannot stat file %q", f) - } - header := &tar.Header{ - Name: f, - Size: fi.Size(), - Mode: FileMode, - ModTime: ModTime, - } - if err := tw.WriteHeader(header); err != nil { - return errors.Wrapf(err, "unable to write descriptor header") - } - r, err := obj.fs.Open(f) - if err != nil { - return errors.Wrapf(err, "unable to get reader") - } - if _, err := io.Copy(tw, r); err != nil { - r.Close() - return errors.Wrapf(err, "unable to write file %s", f) - } - r.Close() + // Get file info for the existing file + fi, err := obj.fs.Stat(path) + if err != nil { + return errors.Wrapf(err, "cannot stat file %q", path) + } + + // Use writeFileEntry for the actual writing + if err := h.writeFileEntry(obj, tw, path, fi); err != nil { + return err } } + return nil +} - // add all element content - err = tw.WriteHeader(&tar.Header{ +// writeElementsFlat writes element content using flat directory structure. +// This handles non-OCI compliant formats where blobs are stored with filenames in the format +// 'algorithm.digest' (e.g., 'sha256.abcd1234...') directly in the blobs directory, +// rather than nested in blobs/sha256/abcd1234... as required by the OCI specification. +func (h TarHandler) writeElementsFlat(obj *AccessObject, tw *tar.Writer) error { + elemDir := obj.info.GetElementDirectoryName() + + if err := tw.WriteHeader(&tar.Header{ Typeflag: tar.TypeDir, - Name: obj.info.GetElementDirectoryName(), + Name: elemDir, Mode: DirMode, ModTime: ModTime, - }) - if err != nil { + }); err != nil { return fmt.Errorf("unable to write %s directory: %w", obj.info.GetElementTypeName(), err) } - fileInfos, err := vfs.ReadDir(obj.fs, obj.info.GetElementDirectoryName()) + fileInfos, err := vfs.ReadDir(obj.fs, elemDir) if err != nil { if os.IsNotExist(err) { return nil @@ -169,29 +218,114 @@ func (h TarHandler) WriteToStream(obj *AccessObject, writer io.Writer, opts acce for _, fileInfo := range fileInfos { path := obj.info.SubPath(fileInfo.Name()) - header := &tar.Header{ - Name: path, - Size: fileInfo.Size(), - Mode: FileMode, - ModTime: ModTime, + if err := h.writeFileEntry(obj, tw, path, fileInfo); err != nil { + return err } - if err := tw.WriteHeader(header); err != nil { - return fmt.Errorf("unable to write %s header: %w", obj.info.GetElementTypeName(), err) + } + return nil +} + +// writeFileEntry writes a single file entry to the tar. +func (h TarHandler) writeFileEntry(obj *AccessObject, tw *tar.Writer, path string, info os.FileInfo) (err error) { + header := &tar.Header{ + Name: path, + Size: info.Size(), + Mode: FileMode, + ModTime: ModTime, + } + if err = tw.WriteHeader(header); err != nil { + return fmt.Errorf("unable to write %s header: %w", obj.info.GetElementTypeName(), err) + } + + content, err := obj.fs.Open(path) + if err != nil { + return fmt.Errorf("unable to open %s: %w", obj.info.GetElementTypeName(), err) + } + defer func() { + err = errors.Join(err, content.Close()) + }() + + if _, err = io.CopyN(tw, content, info.Size()); err != nil { + return fmt.Errorf("unable to write %s content: %w", obj.info.GetElementTypeName(), err) + } + return nil +} + +// writeOCICompliant writes element content to the tar following OCI standards. +// This handles OCI layouts with a standard two-level structure (e.g. blobs/sha256). +// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#filesystem-layout +func (h TarHandler) writeOCICompliant(obj *AccessObject, tw *tar.Writer) error { + dir := obj.info.GetElementDirectoryName() + // Write root directory header + if err := tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: dir, + Mode: DirMode, + ModTime: ModTime, + }); err != nil { + return fmt.Errorf("unable to write directory header for %s: %w", dir, err) + } + + entries, err := vfs.ReadDir(obj.fs, dir) + if err != nil { + if os.IsNotExist(err) { + return nil } + return fmt.Errorf("unable to read directory %s: %w", dir, err) + } - content, err := obj.fs.Open(path) - if err != nil { - return fmt.Errorf("unable to open %s: %w", obj.info.GetElementTypeName(), err) + // Process first level entries (typically 'blobs') + for _, entry := range entries { + subPath := dir + "/" + entry.Name() + + if entry.IsDir() { + if err := h.writeDirEntry(obj, tw, subPath); err != nil { + return err + } + } else { + // Handle files in root directory + if err := h.writeFileEntry(obj, tw, subPath, entry); err != nil { + return err + } } - if _, err := io.Copy(tw, content); err != nil { - return fmt.Errorf("unable to write %s content: %w", obj.info.GetElementTypeName(), err) + } + return nil +} + +// writeDirEntry writes a directory entry and its content to the tar. +// This specifically handles OCI-style directory entries (e.g. the 'sha256' subdirectory). +// See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#filesystem-layout +func (h TarHandler) writeDirEntry(obj *AccessObject, tw *tar.Writer, path string) error { + // Write directory header + if err := tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeDir, + Name: path, + Mode: DirMode, + ModTime: ModTime, + }); err != nil { + return fmt.Errorf("unable to write directory header for %s: %w", path, err) + } + + // Process entries in this directory (typically hash digest files) + entries, err := vfs.ReadDir(obj.fs, path) + if err != nil { + if os.IsNotExist(err) { + return nil } - if err := content.Close(); err != nil { - return fmt.Errorf("unable to close %s %s: %w", obj.info.GetElementTypeName(), path, err) + return fmt.Errorf("unable to read directory %s: %w", path, err) + } + + // Process files in directory + for _, entry := range entries { + filePath := path + "/" + entry.Name() + if !entry.IsDir() { + if err := h.writeFileEntry(obj, tw, filePath, entry); err != nil { + return err + } } } - return tw.Close() + return nil } func (h *TarHandler) NewFromReader(info AccessObjectInfo, acc AccessMode, in io.Reader, opts accessio.Options, closer Closer) (*AccessObject, error) { diff --git a/cmds/ocm/commands/ocicmds/artifacts/download/cmd.go b/cmds/ocm/commands/ocicmds/artifacts/download/cmd.go index 621ced55fc..b5f1f569bc 100644 --- a/cmds/ocm/commands/ocicmds/artifacts/download/cmd.go +++ b/cmds/ocm/commands/ocicmds/artifacts/download/cmd.go @@ -54,9 +54,13 @@ func (o *Command) ForName(name string) *cobra.Command { Short: "download oci artifacts", Long: ` Download artifacts from an OCI registry. The result is stored in -artifact set format, without the repository part +artifact set format, without the repository part. The files are named according to the artifact repository name. + +By default, blobs are stored in OCM artifact set format (blobs/.). +Use --oci-layout to store blobs in OCI Image Layout format (blobs//) +for compatibility with tools that expect the OCI Image Layout Specification. `, } } @@ -242,7 +246,19 @@ func (d *download) Save(o *artifacthdlr.Object, f string) error { digest := blob.Digest() format := formatoption.From(d.opts) - set, err := artifactset.Create(accessobj.ACC_CREATE, f, format.Mode(), format.Format, accessio.PathFileSystem(dest.PathFilesystem)) + + createOpts := []accessio.Option{ + format.Format, + accessio.PathFileSystem(dest.PathFilesystem), + } + // When --oci-layout is specified, use FORMAT_OCI_COMPLIANT to store blobs at + // blobs// per OCI Image Layout Specification. + // See: https://specs.opencontainers.org/image-spec/image-layout/?v=v1.1.1#blobs + if opts.OCILayout { + createOpts = append(createOpts, artifactset.StructureFormat(artifactset.FORMAT_OCI_COMPLIANT)) + } + + set, err := artifactset.Create(accessobj.ACC_CREATE, f, format.Mode(), createOpts...) if err != nil { return err } diff --git a/cmds/ocm/commands/ocicmds/artifacts/download/oci_layout_roundtrip_test.go b/cmds/ocm/commands/ocicmds/artifacts/download/oci_layout_roundtrip_test.go new file mode 100644 index 0000000000..7ec5bad974 --- /dev/null +++ b/cmds/ocm/commands/ocicmds/artifacts/download/oci_layout_roundtrip_test.go @@ -0,0 +1,293 @@ +//go:build integration + +package download_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + + "github.com/go-logr/logr" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mandelsoft/vfs/pkg/osfs" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/registry" + "github.com/testcontainers/testcontainers-go/wait" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/oci" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/retry" + + envhelper "ocm.software/ocm/api/helper/env" + . "ocm.software/ocm/cmds/ocm/testhelper" +) + +// This test verifies the complete roundtrip workflow: +// 1. Create a component version with an OCI image resource +// 2. Transfer it to a remote OCI registry (GHCR) +// 3. Download the image using OCM's --oci-layout flag (creates OCI Image Layout) +// 4. Verify the OCI layout is valid and can be read by ORAS +// 5. Copy the OCI layout back to the registry with a new tag using ORAS +// 6. Run the copied image with Docker to confirm it works end-to-end +// +// This confirms that OCM's --oci-layout output is OCI-compliant and interoperable. +var _ = Describe("OCM to ORAS to Docker roundtrip", Ordered, func() { + const ( + componentName = "example.com/hello" + componentVersion = "1.0.0" + resourceName = "hello-image" + resourceVersion = "1.0.0" + imageName = "hello-world" + imageTag = "linux" + copiedImageTag = "copied-from-oci-layout" + ) + + const distributionRegistryImage = "registry:2.8.3" + + var ( + tempDir string + ctfDir string + ociDir string + env *TestEnv + ctx context.Context + store *oci.Store + srcDesc ocispec.Descriptor + copiedImageRef string + authClient *auth.Client + registryURL string // with scheme for OCM CLI (e.g., http://localhost:5000) + registryHost string // without scheme for ORAS (e.g., localhost:5000) + registryContainer *registry.RegistryContainer + log logr.Logger + ) + + BeforeAll(func() { + log = GinkgoLogr + ctx = context.Background() + + // Start containerized registry using testcontainers with wait strategy + log.Info("Launching test registry", "image", distributionRegistryImage) + var err error + registryContainer, err = registry.Run(ctx, distributionRegistryImage, + testcontainers.WithWaitStrategy(wait.ForHTTP("/v2/").WithPort("5000/tcp")), + ) + Expect(err).To(Succeed(), "Failed to start registry container") + + registryHost, err = registryContainer.HostAddress(ctx) + Expect(err).To(Succeed(), "Failed to get registry host address") + + // Use plain HTTP for local testcontainer registry + registryURL = "http://" + registryHost + log.Info("Test registry ready", "url", registryURL, "host", registryHost) + + tempDir, err = os.MkdirTemp("", "ocm-integration-*") + Expect(err).To(Succeed()) + + env = NewTestEnv(envhelper.FileSystem(osfs.New())) + + // Create unauthenticated client for ORAS operations (local registry doesn't need auth) + authClient = &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.DefaultCache, + } + }) + + AfterAll(func() { + log.Info("Cleaning up registry packages", "host", registryHost) + + // List of repositories to clean up (use registryHost for ORAS) + reposToClean := []string{ + registryHost + "/" + componentName, // component version + registryHost + "/" + imageName, // copied image + registryHost + "/library/" + imageName, // transferred image (OCM uses library/ prefix) + } + + for _, repoPath := range reposToClean { + repo, err := remote.NewRepository(repoPath) + if err != nil { + continue + } + repo.Client = authClient + + // Try to delete all known tags + tagsToDelete := []string{componentVersion, imageTag, copiedImageTag} + for _, tag := range tagsToDelete { + desc, err := repo.Resolve(ctx, tag) + if err != nil { + continue + } + if err := repo.Delete(ctx, desc); err != nil { + log.Info("Warning: failed to delete", "repo", repoPath, "tag", tag, "error", err) + } else { + log.Info("Deleted", "repo", repoPath, "tag", tag) + } + } + } + + if env != nil { + env.Cleanup() + } + if tempDir != "" { + os.RemoveAll(tempDir) + } + + // Terminate the registry container + if registryContainer != nil { + if err := testcontainers.TerminateContainer(registryContainer); err != nil { + log.Info("Warning: failed to terminate registry container", "error", err) + } + } + }) + + // Step 1: Create a component version with an OCI image resource. + // This simulates a user packaging their application with OCM. + It("creates component version from constructor", func() { + ctfDir = filepath.Join(tempDir, "ctf") + 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: ` + imageName + `:` + imageTag + ` +` + err := os.WriteFile(constructorFile, []byte(constructorContent), 0644) + Expect(err).To(Succeed(), "MUST create constructor file") + + // Create component version + buf := bytes.NewBuffer(nil) + Expect(env.CatchOutput(buf).Execute( + "add", "componentversions", + "--create", + "--file", ctfDir, + constructorFile, + )).To(Succeed()) + log.Info("Create output", "output", buf.String()) + }) + + // Step 2: Verify the component version was created correctly. + // Lists the component and its resources to confirm the structure. + It("lists components and resources", func() { + buf := bytes.NewBuffer(nil) + Expect(env.CatchOutput(buf).Execute("get", "componentversions", ctfDir)).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", + ctfDir+"//"+componentName+":"+componentVersion, + )).To(Succeed()) + log.Info("Resources", "output", buf.String()) + Expect(buf.String()).To(ContainSubstring(resourceName)) + }) + + // Step 3: Transfer the component version to a remote OCI registry. + // Uses --copy-resources to also push the referenced OCI image. + It("transfers component version to a remote registry", func() { + buf := bytes.NewBuffer(nil) + Expect(env.CatchOutput(buf).Execute( + "transfer", "componentversions", + "--copy-resources", + ctfDir+"//"+componentName+":"+componentVersion, + registryURL, + )).To(Succeed()) + log.Info("Transfer output", "output", buf.String()) + }) + + // Step 4: Verify the component version exists in the remote registry. + // Confirms the transfer was successful. + It("lists resources from remote registry", func() { + buf := bytes.NewBuffer(nil) + Expect(env.CatchOutput(buf).Execute( + "get", "resources", + registryURL+"//"+componentName+":"+componentVersion, + )).To(Succeed()) + log.Info("Remote resources", "output", buf.String()) + Expect(buf.String()).To(ContainSubstring(resourceName)) + }) + + // Step 5: Download an OCI artifact using --oci-layout flag. + // This creates an OCI Image Layout directory structure that should be + // compliant with the OCI Image Layout Specification. + It("downloads OCI image with --oci-layout", func() { + ociDir = filepath.Join(tempDir, "oci-download") + + buf := bytes.NewBuffer(nil) + Expect(env.CatchOutput(buf).Execute( + "download", "artifact", + "--oci-layout", + "-O", ociDir, + imageName+":"+imageTag, + )).To(Succeed()) + log.Info("Download output", "output", buf.String()) + }) + + // Step 6: Verify the OCI layout is valid using ORAS. + // If ORAS can open and resolve the layout, it confirms OCI compliance. + It("opens downloaded OCI layout with ORAS", func() { + var err error + store, err = oci.New(ociDir) + Expect(err).To(Succeed(), "ORAS MUST open OCI layout") + + // Resolve tag and enumerate content (layers/config) + srcDesc, err = store.Resolve(ctx, imageTag) + Expect(err).To(Succeed(), "ORAS MUST resolve tag") + + successors, err := content.Successors(ctx, store, srcDesc) + Expect(err).To(Succeed(), "ORAS MUST get successors") + Expect(len(successors)).To(BeNumerically(">", 0)) + log.Info("Found successors", "count", len(successors)) + }) + + // Step 7: Copy the OCI layout back to the registry with a new tag. + // This proves the downloaded OCI layout can be republished using standard tools. + It("copies OCI layout to registry with new tag", func() { + copiedImageRef = registryHost + "/" + imageName + ":" + copiedImageTag + dst, err := remote.NewRepository(registryHost + "/" + imageName) + Expect(err).To(Succeed()) + dst.Client = authClient + dst.PlainHTTP = true // testcontainers registry uses plain HTTP + desc, err := oras.Copy(ctx, store, imageTag, dst, copiedImageTag, oras.DefaultCopyOptions) + Expect(err).To(Succeed()) + log.Info("Copied", "ref", copiedImageRef, "digest", desc.Digest) + }) + + // Step 8: Verify the copied image exists in the registry. + // Confirms the ORAS copy operation was successful. + It("verifies copied image in registry", func() { + repo, err := remote.NewRepository(registryHost + "/" + imageName) + Expect(err).To(Succeed()) + repo.Client = authClient + repo.PlainHTTP = true // testcontainers registry uses plain HTTP + desc, err := repo.Resolve(ctx, copiedImageTag) + Expect(err).To(Succeed()) + log.Info("Verified image in registry", "ref", copiedImageRef, "digest", desc.Digest) + }) + + // Step 9: Run the copied image with Docker. + // This is the final proof that the entire roundtrip works: + // OCM create -> transfer -> download (--oci-layout) -> ORAS copy -> Docker run + It("runs copied image from registry with docker", func() { + log.Info("Running image", "ref", copiedImageRef) + cmd := exec.Command("docker", "run", "--rm", copiedImageRef) + out, err := cmd.CombinedOutput() + Expect(err).To(Succeed(), "Docker run failed: %s", string(out)) + Expect(string(out)).To(ContainSubstring("Hello from Docker")) + log.Info("Docker output", "output", string(out)) + }) +}) diff --git a/cmds/ocm/commands/ocicmds/artifacts/download/option.go b/cmds/ocm/commands/ocicmds/artifacts/download/option.go index 174e60727f..ab7eda8b2a 100644 --- a/cmds/ocm/commands/ocicmds/artifacts/download/option.go +++ b/cmds/ocm/commands/ocicmds/artifacts/download/option.go @@ -17,13 +17,15 @@ func New() *Option { } type Option struct { - Layers []int - DirTree bool + Layers []int + DirTree bool + OCILayout bool } func (o *Option) AddFlags(fs *pflag.FlagSet) { fs.IntSliceVarP(&o.Layers, "layers", "", nil, "extract dedicated layers") fs.BoolVarP(&o.DirTree, "dirtree", "", false, "extract as effective filesystem content") + fs.BoolVarP(&o.OCILayout, "oci-layout", "", false, "download as OCI Image Layout (blobs in blobs//)") } func (o *Option) Usage() string { @@ -32,5 +34,14 @@ With option --layers it is possible to request the download of dedicated layers, only. Option --dirtree expects the artifact to be a layered filesystem (for example OCI Image) and provided the effective filesystem content. + +Option --oci-layout changes the blob storage structure in the downloaded +artifact. Without this option, blobs are stored in a flat directory at +blobs/<algorithm>.<encoded> (e.g., blobs/sha256.abc123...). +With this option, blobs are stored in a nested directory structure at +blobs/<algorithm>/<encoded> (e.g., blobs/sha256/abc123...) +as specified by the OCI Image Layout Specification +(see +https://github.com/opencontainers/image-spec/blob/main/image-layout.md). ` } diff --git a/cmds/ocm/commands/ocmcmds/resources/download/action.go b/cmds/ocm/commands/ocmcmds/resources/download/action.go index 8fada4b0b1..c567da4084 100644 --- a/cmds/ocm/commands/ocmcmds/resources/download/action.go +++ b/cmds/ocm/commands/ocmcmds/resources/download/action.go @@ -12,6 +12,7 @@ import ( "ocm.software/ocm/api/ocm" v1 "ocm.software/ocm/api/ocm/compdesc/meta/v1" "ocm.software/ocm/api/ocm/extensions/download" + ocilayouthdlr "ocm.software/ocm/api/ocm/extensions/download/handlers/ocilayout" "ocm.software/ocm/api/ocm/tools/signing" "ocm.software/ocm/api/utils/blobaccess" common2 "ocm.software/ocm/api/utils/misc" @@ -33,14 +34,23 @@ type Action struct { } func NewAction(ctx ocm.ContextProvider, opts *output.Options) *Action { - return &Action{downloaders: download.For(ctx), opts: opts} + downloaders := download.For(ctx) + + local := From(opts) + if local.OCILayout { + // Create a copy to avoid modifying the global registry + downloaders = downloaders.Copy() + downloaders.Register(ocilayouthdlr.New(), download.ForArtifactType(download.ALL), download.WithPrio(ocilayouthdlr.PRIORITY)) + } + + return &Action{downloaders: downloaders, opts: opts} } func (d *Action) AddOptions(opts ...options.Options) { d.opts.OptionSet = append(d.opts.OptionSet, opts...) } -func (d *Action) Add(e interface{}) error { +func (d *Action) Add(e any) error { d.data = append(d.data, e.(*elemhdlr.Object)) return nil } diff --git a/cmds/ocm/commands/ocmcmds/resources/download/options.go b/cmds/ocm/commands/ocmcmds/resources/download/options.go index 4942477e1d..1714bf3b30 100644 --- a/cmds/ocm/commands/ocmcmds/resources/download/options.go +++ b/cmds/ocm/commands/ocmcmds/resources/download/options.go @@ -21,6 +21,7 @@ type Option struct { SilentOption bool UseHandlers bool Verify bool + OCILayout bool } func (o *Option) SetUseHandlers(ok ...bool) *Option { @@ -33,6 +34,7 @@ func (o *Option) AddFlags(fs *pflag.FlagSet) { fs.BoolVarP(&o.UseHandlers, "download-handlers", "d", false, "use download handler if possible") } fs.BoolVarP(&o.Verify, "verify", "", false, "verify downloads") + fs.BoolVarP(&o.OCILayout, "oci-layout", "", false, "download OCI artifacts in OCI Image Layout format (blobs//)") } func (o *Option) Usage() string { diff --git a/docs/reference/ocm_download_artifacts.md b/docs/reference/ocm_download_artifacts.md index a7a7928e3e..4238c8c31d 100644 --- a/docs/reference/ocm_download_artifacts.md +++ b/docs/reference/ocm_download_artifacts.md @@ -18,6 +18,7 @@ artifacts, artifact, art, a --dirtree extract as effective filesystem content -h, --help help for artifacts --layers ints extract dedicated layers + --oci-layout download as OCI Image Layout (blobs in blobs//) -O, --outfile string output file or directory --repo string repository name or spec -t, --type string archive format (directory, tar, tgz) (default "directory") @@ -26,10 +27,14 @@ artifacts, artifact, art, a ### Description Download artifacts from an OCI registry. The result is stored in -artifact set format, without the repository part +artifact set format, without the repository part. The files are named according to the artifact repository name. +By default, blobs are stored in OCM artifact set format (blobs/.). +Use --oci-layout to store blobs in OCI Image Layout format (blobs//) +for compatibility with tools that expect the OCI Image Layout Specification. + If the repository/registry option is specified, the given names are interpreted relative to the specified registry using the syntax @@ -71,6 +76,15 @@ dedicated layers, only. Option --dirtree expects the artifact to be a layered filesystem (for example OCI Image) and provided the effective filesystem content. +Option --oci-layout changes the blob storage structure in the downloaded +artifact. Without this option, blobs are stored in a flat directory at +blobs/<algorithm>.<encoded> (e.g., blobs/sha256.abc123...). +With this option, blobs are stored in a nested directory structure at +blobs/<algorithm>/<encoded> (e.g., blobs/sha256/abc123...) +as specified by the OCI Image Layout Specification +(see +https://github.com/opencontainers/image-spec/blob/main/image-layout.md). + The --type option accepts a file format for the target archive to use. It is only evaluated if the target diff --git a/docs/reference/ocm_download_cli.md b/docs/reference/ocm_download_cli.md index 34fdab53db..5abcae6de9 100644 --- a/docs/reference/ocm_download_cli.md +++ b/docs/reference/ocm_download_cli.md @@ -17,6 +17,7 @@ cli, ocmcli, ocm-cli ```text -c, --constraints constraints version constraint -h, --help help for cli + --oci-layout download OCI artifacts in OCI Image Layout format (blobs//) -O, --outfile string output file or directory -p, --path lookup executable in PATH --repo string repository name or spec diff --git a/docs/reference/ocm_download_resources.md b/docs/reference/ocm_download_resources.md index 09e3012282..811a17675d 100644 --- a/docs/reference/ocm_download_resources.md +++ b/docs/reference/ocm_download_resources.md @@ -23,6 +23,7 @@ resources, resource, res, r -h, --help help for resources --latest restrict component versions to latest --lookup stringArray repository name or spec for closure lookup fallback + --oci-layout download OCI artifacts in OCI Image Layout format (blobs//) -O, --outfile string output file or directory -r, --recursive follow component reference nesting --repo string repository name or spec diff --git a/go.mod b/go.mod index dad0bc3907..49265b5467 100644 --- a/go.mod +++ b/go.mod @@ -68,6 +68,8 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go/modules/registry v0.40.0 github.com/texttheater/golang-levenshtein v1.0.1 github.com/tonglil/buflogr v1.1.1 github.com/ulikunitz/xz v0.5.15 @@ -107,7 +109,6 @@ require ( codeberg.org/chavacava/garif v0.2.0 // indirect github.com/4meepo/tagalign v1.4.2 // indirect github.com/Abirdcfly/dupword v0.1.6 // indirect - github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/provider v0.19.0 // indirect github.com/AlwxSin/noinlineerr v1.0.5 // indirect github.com/Antonboom/errname v1.1.0 // indirect @@ -195,6 +196,7 @@ require ( github.com/butuzov/mirror v1.3.0 // indirect github.com/catenacyber/perfsprint v0.9.1 // indirect github.com/ccojocar/zxcvbn-go v1.0.4 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.3 // indirect @@ -219,6 +221,7 @@ require ( github.com/containers/ocicrypt v1.2.1 // indirect github.com/containers/storage v1.59.1 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/daixiang0/gci v0.13.7 // indirect @@ -235,6 +238,7 @@ require ( github.com/docker/docker-credential-helpers v0.9.4 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/elliotchance/orderedmap v1.8.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect @@ -261,6 +265,7 @@ require ( github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/analysis v0.24.1 // indirect github.com/go-openapi/errors v0.22.4 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect @@ -369,6 +374,7 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/macabu/inamedparam v0.2.0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/manuelarte/embeddedstructfieldcheck v0.3.0 // indirect @@ -386,11 +392,14 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect @@ -416,6 +425,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polyfloyd/go-errorlint v1.8.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect @@ -443,6 +453,7 @@ require ( github.com/securego/gosec/v2 v2.22.7 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.8 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sigstore/fulcio v1.8.4 // indirect github.com/sigstore/protobuf-specs v0.5.0 // indirect @@ -470,6 +481,8 @@ require ( github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect github.com/transparency-dev/formats v0.0.0-20251103090025-99ec6f4410eb // indirect @@ -493,6 +506,7 @@ require ( github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect gitlab.com/gitlab-org/api/client-go v0.159.0 // indirect go-simpler.org/musttag v0.13.1 // indirect diff --git a/go.sum b/go.sum index 4607165a8e..838bb57ecd 100644 --- a/go.sum +++ b/go.sum @@ -1009,6 +1009,8 @@ github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNv github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -1072,6 +1074,8 @@ github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elliotchance/orderedmap v1.8.0 h1:TrOREecvh3JbS+NCgwposXG5ZTFHtEsQiCGOhPElnMw= @@ -1192,6 +1196,9 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/analysis v0.24.1 h1:Xp+7Yn/KOnVWYG8d+hPksOYnCYImE3TieBa7rBOesYM= github.com/go-openapi/analysis v0.24.1/go.mod h1:dU+qxX7QGU1rl7IYhBC8bIfmWQdX4Buoea4TGtxXY84= github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM= @@ -1638,6 +1645,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= @@ -1709,12 +1718,16 @@ github.com/mittwald/go-helm-client v0.12.19 h1:GzwISuYemkgISegXfYzY3i6blRZzfNpp2 github.com/mittwald/go-helm-client v0.12.19/go.mod h1:mlTMyzGOua5rXH4+kFTU/YsE9xxqvwkEW1c5ukM8Cj4= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg= github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= github.com/moby/moby/client v0.2.1 h1:1Grh1552mvv6i+sYOdY+xKKVTvzJegcVMhuXocyDz/k= github.com/moby/moby/client v0.2.1/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= @@ -1725,6 +1738,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -1825,6 +1840,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= @@ -1901,6 +1918,8 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= +github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= @@ -1991,6 +2010,10 @@ github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/registry v0.40.0 h1:z+CymIuT9quh8plBbM+lpncY6diV//q0LbRk+mxMpow= +github.com/testcontainers/testcontainers-go/modules/registry v0.40.0/go.mod h1:TWdy7+y7w14Ii5UCSfr7qvxPYI3GE7lc7NEP0ofxlLQ= github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4= github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk= github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U= @@ -2024,6 +2047,10 @@ github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29w github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU= github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= @@ -2095,6 +2122,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= @@ -2420,6 +2449,7 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2445,6 +2475,7 @@ golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=