Skip to content
Open
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
5 changes: 3 additions & 2 deletions internal/pkg/api/v2alpha1/type_internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,9 @@ type CollectorSchema struct {
}

type CopyImageSchemaMap struct {
OperatorsByImage map[string]map[string]struct{} // key is the origin image name and value is an array of operators' name
BundlesByImage map[string]map[string]string // key is the image name and value is the bundle name
OperatorsByImage map[string]map[string]struct{} // key is the origin image name and value is an array of operators' name
BundlesByImage map[string]map[string]string // key is the image name and value is the bundle name
ManifestListDigests map[string][]string // key is the origin image ref, value is the sub-manifest digests (only for manifest lists)
}

// CopyImageSchema
Expand Down
173 changes: 172 additions & 1 deletion internal/pkg/cli/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@ package cli
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"

imgmanifest "go.podman.io/image/v5/manifest"
"go.podman.io/image/v5/transports/alltransports"
"go.podman.io/image/v5/types"

"github.com/openshift/oc-mirror/v2/internal/pkg/api/v2alpha1"
"github.com/openshift/oc-mirror/v2/internal/pkg/consts"
"github.com/openshift/oc-mirror/v2/internal/pkg/emoji"
"github.com/openshift/oc-mirror/v2/internal/pkg/image"
)

func (o *ExecutorSchema) DryRun(ctx context.Context, allImages []v2alpha1.CopyImageSchema) error {
func (o *ExecutorSchema) DryRun(ctx context.Context, allImages []v2alpha1.CopyImageSchema, preCollectedManifestLists map[string][]string) error {
// set up location of logs dir
outDir := filepath.Join(o.Opts.Global.WorkingDir, dryRunOutDir)
// clean up logs directory
Expand All @@ -22,6 +31,28 @@ func (o *ExecutorSchema) DryRun(ctx context.Context, allImages []v2alpha1.CopyIm
o.Log.Error(" %v ", err)
return err
}

// Inspect only images not already classified during collection.
var remaining []v2alpha1.CopyImageSchema
for _, img := range allImages {
if _, found := preCollectedManifestLists[img.Origin]; !found {
remaining = append(remaining, img)
}
}

o.Log.Info(emoji.LeftPointingMagnifyingGlass+" inspecting %d remaining images for manifest lists (%d already detected during collection)...",
len(remaining), len(allImages)-len(remaining))
runtimeDigests := o.inspectManifestLists(ctx, remaining)

// Merge pre-collected and runtime manifest list results.
manifestListDigests := make(map[string][]string, len(preCollectedManifestLists)+len(runtimeDigests))
for k, v := range preCollectedManifestLists {
manifestListDigests[k] = v
}
for k, v := range runtimeDigests {
manifestListDigests[k] = v
}
Comment on lines +49 to +54
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maps.Copy could be used here to reduce the code.


// creating file for storing list of cached images
mappingTxtFilePath := filepath.Join(outDir, mappingFile)
mappingTxtFile, err := os.Create(mappingTxtFilePath)
Expand All @@ -33,8 +64,31 @@ func (o *ExecutorSchema) DryRun(ctx context.Context, allImages []v2alpha1.CopyIm
nbMissingImgs := 0
var buff bytes.Buffer
var missingImgsBuff bytes.Buffer

for _, img := range allImages {
buff.WriteString(img.Source + "=" + img.Destination + "\n")

// Collect sub-digest source=destination pairs
type subDigestEntry struct{ source, dest string }
var subDigestEntries []subDigestEntry

// Look up manifest list sub-digests: check both by Origin (pre-collected
// during operator collection) and by Source (detected at runtime).
manifestDigests := manifestListDigests[img.Origin]
if len(manifestDigests) == 0 {
manifestDigests = manifestListDigests[img.Source]
}
if len(manifestDigests) > 0 {
// This is a manifest list, write each sub-digest with digest-pinned destination
sourceBase, _, _ := strings.Cut(img.Source, "@")
for _, digest := range manifestDigests {
subSource := sourceBase + "@" + digest
subDest := subDigestDestination(img.Destination, digest)
buff.WriteString(subSource + "=" + subDest + "\n")
subDigestEntries = append(subDigestEntries, subDigestEntry{source: subSource, dest: subDest})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if o.Opts.IsMirrorToDisk() {
exists, err := o.Mirror.Check(ctx, img.Destination, o.Opts, false)
if err != nil {
Expand All @@ -43,6 +97,10 @@ func (o *ExecutorSchema) DryRun(ctx context.Context, allImages []v2alpha1.CopyIm
if err != nil || !exists {
missingImgsBuff.WriteString(img.Source + "=" + img.Destination + "\n")
nbMissingImgs++
// Also include sub-digest entries in missing list
for _, sub := range subDigestEntries {
missingImgsBuff.WriteString(sub.source + "=" + sub.dest + "\n")
}
}
}
}
Expand Down Expand Up @@ -73,3 +131,116 @@ func (o *ExecutorSchema) DryRun(ctx context.Context, allImages []v2alpha1.CopyIm
o.Log.Info(emoji.PageFacingUp+" list of all images for mirroring in : %s", mappingTxtFilePath)
return nil
}

// subDigestDestination returns a digest-pinned destination for a sub-digest entry.
// For docker:// destinations, it strips the tag and appends the sub-digest to avoid
// destination overwrites when multiple architectures map to the same tag.
// For non-docker destinations (oci:, dir:, etc.), the destination is returned as-is.
func subDigestDestination(dest string, digest string) string {
if !strings.HasPrefix(dest, consts.DockerProtocol) {
return dest
}
destSpec, err := image.ParseRef(dest)
if err != nil {
return dest
}
return destSpec.Transport + destSpec.Name + "@" + digest
}

// inspectManifestLists concurrently inspects all images to identify manifest lists
// and returns a map of source references to their sub-manifest digests.
// Concurrency is bounded via a semaphore to avoid overwhelming registries.
func (o *ExecutorSchema) inspectManifestLists(ctx context.Context, images []v2alpha1.CopyImageSchema) map[string][]string {
manifestListDigests := make(map[string][]string)
var mu sync.Mutex
var wg sync.WaitGroup

parallelism := o.Opts.ParallelImages
if parallelism == 0 {
parallelism = maxParallelImageDownloads
}
semaphore := make(chan struct{}, parallelism)

cancelCtx, cancel := context.WithCancel(ctx)
defer cancel()

for _, img := range images {
select {
case <-cancelCtx.Done():
break
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the idea here is go outside of the for loop, only break wouldn't be enough, one of the approach below would solve it:

for _, img := range images {
    select {
    case <-cancelCtx.Done():
        goto cleanup  // or use a label
    default:
    }
    // ... rest of loop
}
cleanup:
wg.Wait()
return manifestListDigests

or

for _, img := range images {
    if cancelCtx.Err() != nil {
        break
    }
    // ... rest of loop
}

default:
}

semaphore <- struct{}{}

wg.Add(1)
go func(source string) {
defer wg.Done()
defer func() { <-semaphore }()

digests, err := o.getManifestListDigests(cancelCtx, source)
if err != nil {
o.Log.Warn("unable to inspect manifest for %s: %v", source, err)
return
}
if len(digests) > 0 {
mu.Lock()
manifestListDigests[source] = digests
mu.Unlock()
}
}(img.Source)
}
wg.Wait()
return manifestListDigests
}

// getManifestListDigests inspects the source image to check if it's a manifest list
// and returns the sub-manifest digests. Works with any transport supported by
// containers/image (docker://, oci:, etc.) via alltransports.ParseImageName.
// Returns a slice of digest strings (e.g., ["sha256:abc...", "sha256:def..."]) or nil if not a manifest list.
func (o *ExecutorSchema) getManifestListDigests(ctx context.Context, source string) ([]string, error) {
srcRef, err := alltransports.ParseImageName(source)
if err != nil {
// Retry with docker:// prefix for sources without transport (e.g., Cincinnati sources)
srcRef, err = alltransports.ParseImageName(consts.DockerProtocol + source)
if err != nil {
return nil, fmt.Errorf("error parsing image name %s: %w", source, err)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

sysCtx, err := o.Opts.SrcImage.NewSystemContext()
if err != nil {
return nil, fmt.Errorf("error creating system context: %w", err)
}
// The local cache registry is HTTP-only; ensure we skip TLS verification for it.
if o.Opts.LocalStorageFQDN != "" && strings.Contains(source, o.Opts.LocalStorageFQDN) {
sysCtx.DockerInsecureSkipTLSVerify = types.OptionalBoolTrue
}

imgSrc, err := srcRef.NewImageSource(ctx, sysCtx)
if err != nil {
return nil, fmt.Errorf("error creating image source for %s: %w", source, err)
}
defer imgSrc.Close()

manifestBytes, manifestType, err := imgSrc.GetManifest(ctx, nil)
if err != nil {
return nil, fmt.Errorf("error getting manifest for %s: %w", source, err)
}

if !imgmanifest.MIMETypeIsMultiImage(manifestType) {
return nil, nil
}

list, err := imgmanifest.ListFromBlob(manifestBytes, manifestType)
if err != nil {
return nil, fmt.Errorf("error parsing manifest list for %s: %w", source, err)
}

instances := list.Instances()
digests := make([]string, 0, len(instances))
for _, instance := range instances {
digests = append(digests, instance.String())
}
return digests, nil
}
Loading