Skip to content
Draft
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
12 changes: 12 additions & 0 deletions image/copy/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,18 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef,
return nil, err
}
copiedManifest = single.manifest
// If the copied single image has zstd:chunked layers and the destination
// supports manifest lists, wrap it in an OCI index with a sentinel variant.
// Skip when digests must be preserved or destination is a digested reference.
if !options.PreserveDigests && supportsMultipleImages(c.dest) {
indexManifest, err := c.createSingleImageSentinelIndex(ctx, single)
if err != nil {
return nil, err
}
if indexManifest != nil {
copiedManifest = indexManifest
}
}
} else if c.options.ImageListSelection == CopySystemImage {
if len(options.EnsureCompressionVariantsExist) > 0 {
return nil, fmt.Errorf("EnsureCompressionVariantsExist is not implemented when not creating a multi-architecture image")
Expand Down
277 changes: 277 additions & 0 deletions image/copy/multiple.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package copy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"maps"
Expand All @@ -16,9 +17,12 @@ import (
"go.podman.io/image/v5/docker/reference"
"go.podman.io/image/v5/internal/image"
internalManifest "go.podman.io/image/v5/internal/manifest"
"go.podman.io/image/v5/internal/private"
"go.podman.io/image/v5/internal/set"
"go.podman.io/image/v5/manifest"
"go.podman.io/image/v5/pkg/compression"
"go.podman.io/image/v5/types"
chunkedToc "go.podman.io/storage/pkg/chunked/toc"
)

type instanceCopyKind int
Expand All @@ -28,6 +32,14 @@ const (
instanceCopyClone
)

// copiedInstanceData stores info about a successfully copied instance,
// used for creating sentinel variants.
type copiedInstanceData struct {
sourceDigest digest.Digest
result copySingleImageResult
platform *imgspecv1.Platform
}

type instanceCopy struct {
op instanceCopyKind
sourceDigest digest.Digest
Expand Down Expand Up @@ -283,6 +295,8 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
if err != nil {
return nil, fmt.Errorf("preparing instances for copy: %w", err)
}
var copiedInstances []copiedInstanceData

c.Printf("Copying %d images generated from %d images in list\n", len(instanceCopyList), len(instanceDigests))
for i, instance := range instanceCopyList {
// Update instances to be edited by their `ListOperation` and
Expand All @@ -305,6 +319,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
UpdateCompressionAlgorithms: updated.compressionAlgorithms,
UpdateMediaType: updated.manifestMIMEType,
})
// Capture instance data for sentinel variant creation
instanceDetails, detailsErr := updatedList.Instance(instance.sourceDigest)
if detailsErr == nil {
copiedInstances = append(copiedInstances, copiedInstanceData{
sourceDigest: instance.sourceDigest,
result: updated,
platform: instanceDetails.ReadOnly.Platform,
})
}
case instanceCopyClone:
logrus.Debugf("Replicating instance %s (%d/%d)", instance.sourceDigest, i+1, len(instanceCopyList))
c.Printf("Replicating image %s (%d/%d)\n", instance.sourceDigest, i+1, len(instanceCopyList))
Expand Down Expand Up @@ -333,6 +356,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
}
}

// Create zstd:chunked sentinel variants for instances where any layer uses zstd:chunked.
if cannotModifyManifestListReason == "" {
Copy link
Contributor

Choose a reason for hiding this comment

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

Initially, I would expect this decision to happen in prepareInstanceCopies (future prepareInstanceOps), and unit-tested there.

OTOH this seems to be happening later because we collect the pushed manifests… that might be a good reason.

sentinelEdits, err := c.createZstdChunkedSentinelVariants(ctx, copiedInstances, updatedList)
if err != nil {
return nil, fmt.Errorf("creating zstd:chunked sentinel variants: %w", err)
}
instanceEdits = append(instanceEdits, sentinelEdits...)
}

// Now reset the digest/size/types of the manifests in the list to account for any conversions that we made.
if err = updatedList.EditInstances(instanceEdits, cannotModifyManifestListReason != ""); err != nil {
return nil, fmt.Errorf("updating manifest list: %w", err)
Expand Down Expand Up @@ -405,3 +437,248 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,

return manifestList, nil
}

// hasZstdChunkedLayers returns true if any non-empty layer in the manifest has
// zstd:chunked TOC annotations.
func hasZstdChunkedLayers(ociMan *manifest.OCI1) bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder about correct layering / abstractions, but that can be decided a bit later.

for _, l := range ociMan.LayerInfos() {
if l.EmptyLayer {
continue
}
d, err := chunkedToc.GetTOCDigest(l.Annotations)
if err == nil && d != nil {
return true
}
}
return false
}

// pushSentinelVariant creates and pushes a sentinel variant of the given OCI manifest.
// It prepends a sentinel layer and DiffID, creates a new config, and pushes everything
// to the destination. Returns the serialized sentinel manifest and its digest.
func (c *copier) pushSentinelVariant(ctx context.Context, ociMan *manifest.OCI1, ociConfig *imgspecv1.Image) ([]byte, digest.Digest, error) {
sentinelContent := chunkedToc.ZstdChunkedSentinelContent

// Push sentinel blob (content-addressed, so idempotent if already pushed).
sentinelBlobInfo := types.BlobInfo{
Digest: chunkedToc.ZstdChunkedSentinelDigest,
Size: int64(len(sentinelContent)),
}
reused, _, err := c.dest.TryReusingBlobWithOptions(ctx, sentinelBlobInfo,
private.TryReusingBlobOptions{Cache: c.blobInfoCache})
if err != nil {
return nil, "", fmt.Errorf("checking sentinel blob: %w", err)
}
if !reused {
_, err = c.dest.PutBlobWithOptions(ctx, bytes.NewReader(sentinelContent),
sentinelBlobInfo, private.PutBlobOptions{Cache: c.blobInfoCache})
if err != nil {
return nil, "", fmt.Errorf("pushing sentinel blob: %w", err)
}
}

// Create new config with sentinel DiffID prepended.
newDiffIDs := make([]digest.Digest, 0, len(ociConfig.RootFS.DiffIDs)+1)
newDiffIDs = append(newDiffIDs, chunkedToc.ZstdChunkedSentinelDigest)
newDiffIDs = append(newDiffIDs, ociConfig.RootFS.DiffIDs...)
ociConfig.RootFS.DiffIDs = newDiffIDs
configBlob, err := json.Marshal(ociConfig)
if err != nil {
return nil, "", fmt.Errorf("marshaling sentinel config: %w", err)
}
configDigest := digest.FromBytes(configBlob)

// Push new config.
configBlobInfo := types.BlobInfo{
Digest: configDigest,
Size: int64(len(configBlob)),
MediaType: imgspecv1.MediaTypeImageConfig,
}
reused, _, err = c.dest.TryReusingBlobWithOptions(ctx, configBlobInfo,
private.TryReusingBlobOptions{Cache: c.blobInfoCache})
if err != nil {
return nil, "", fmt.Errorf("checking sentinel config: %w", err)
}
if !reused {
_, err = c.dest.PutBlobWithOptions(ctx, bytes.NewReader(configBlob),
configBlobInfo, private.PutBlobOptions{Cache: c.blobInfoCache, IsConfig: true})
if err != nil {
return nil, "", fmt.Errorf("pushing sentinel config: %w", err)
}
}

// Build sentinel manifest: sentinel layer + original layers.
newLayers := make([]imgspecv1.Descriptor, 0, len(ociMan.Layers)+1)
newLayers = append(newLayers, imgspecv1.Descriptor{
MediaType: imgspecv1.MediaTypeImageLayerZstd,
Digest: chunkedToc.ZstdChunkedSentinelDigest,
Size: int64(len(sentinelContent)),
})
newLayers = append(newLayers, ociMan.Layers...)

sentinelOCI := manifest.OCI1FromComponents(imgspecv1.Descriptor{
MediaType: imgspecv1.MediaTypeImageConfig,
Digest: configDigest,
Size: int64(len(configBlob)),
}, newLayers)
sentinelManifestBlob, err := sentinelOCI.Serialize()
if err != nil {
return nil, "", fmt.Errorf("serializing sentinel manifest: %w", err)
}
sentinelManifestDigest := digest.FromBytes(sentinelManifestBlob)

// Push sentinel manifest.
if err := c.dest.PutManifest(ctx, sentinelManifestBlob, &sentinelManifestDigest); err != nil {
Copy link
Contributor

@mtrmac mtrmac Mar 5, 2026

Choose a reason for hiding this comment

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

The rules say: push each blob ~in order, then do PutManifest. Pushing only the extra blob works for docker:// but not for other transports.

I think this should probably be an “almost ordinary” copy from the one image on the destination to another, using copySingleImage with an “add a layer” flag. That would naturally trigger the API-required TryReusingBlob calls, and we don’t need a special code path creating an updated manifest here.

… or maybe, actually, “remove a layer” flag? Do we start with slow-pulling zstd-chunked and TOC annotations, and add the "fast pull" marker only, or do we start with "fast pull zstd-chunked with TOC", and turn that into "~ordinary zstd with no TOC annotations at all"?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yet another aspect to this: When decompressing or re-compressing (to gzip), we need to strip the sentinel.

So, in this sense, the “copy single instance” code needs to be compression-sentinel aware whatever we do. It might then be elegant for the “create a zstd:chunked” code path to add the sentinel as part of an “ordinary compression”, and we would then clone that “fast pull” image into a compatible one, per the “remove a layer” option in the comment above.

return nil, "", fmt.Errorf("pushing sentinel manifest: %w", err)
}

return sentinelManifestBlob, sentinelManifestDigest, nil
}

// createZstdChunkedSentinelVariants creates sentinel variants for instances
// where any layer uses zstd:chunked. The sentinel variant has a non-tar sentinel
// layer prepended, signaling aware clients to skip the full-digest mitigation.
// It returns ListEdit entries to add the sentinel variants to the manifest list.
func (c *copier) createZstdChunkedSentinelVariants(ctx context.Context, copiedInstances []copiedInstanceData, updatedList internalManifest.List) ([]internalManifest.ListEdit, error) {
var edits []internalManifest.ListEdit

// Check which platforms already have a sentinel variant in the source.
platformsWithSentinel := set.New[platformComparable]()
for _, d := range updatedList.Instances() {
details, err := updatedList.Instance(d)
if err != nil {
continue
}
if details.ReadOnly.Annotations[internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel] == internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue {
platformsWithSentinel.Add(platformV1ToPlatformComparable(details.ReadOnly.Platform))
}
}

for _, ci := range copiedInstances {
// Only handle OCI manifests (zstd:chunked is OCI-only).
if ci.result.manifestMIMEType != imgspecv1.MediaTypeImageManifest {
continue
}

// Skip if this platform already has a sentinel variant.
if platformsWithSentinel.Contains(platformV1ToPlatformComparable(ci.platform)) {
continue
}

ociMan, err := manifest.OCI1FromManifest(ci.result.manifest)
if err != nil {
logrus.Debugf("Cannot parse manifest for sentinel variant: %v", err)
continue
}

if !hasZstdChunkedLayers(ociMan) {
continue
}

// Use the config as written to the destination (reflects any edits during copy).
var ociConfig imgspecv1.Image
if err := json.Unmarshal(ci.result.configBlob, &ociConfig); err != nil {
logrus.Debugf("Cannot parse config for sentinel variant: %v", err)
continue
}

sentinelManifestBlob, sentinelManifestDigest, err := c.pushSentinelVariant(ctx, ociMan, &ociConfig)
if err != nil {
return nil, err
}

edits = append(edits, internalManifest.ListEdit{
ListOperation: internalManifest.ListOpAdd,
AddDigest: sentinelManifestDigest,
AddSize: int64(len(sentinelManifestBlob)),
AddMediaType: imgspecv1.MediaTypeImageManifest,
AddPlatform: ci.platform,
AddAnnotations: map[string]string{
internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel: internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue,
internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue,
},
AddCompressionAlgorithms: ci.result.compressionAlgorithms,
})

platformsWithSentinel.Add(platformV1ToPlatformComparable(ci.platform))
}

return edits, nil
}

// createSingleImageSentinelIndex checks if a single copied image has zstd:chunked
// layers, and if so, creates a sentinel variant and wraps both in an OCI index.
// Returns the serialized index manifest, or nil if no sentinel was needed.
func (c *copier) createSingleImageSentinelIndex(ctx context.Context, single copySingleImageResult) ([]byte, error) {
if single.manifestMIMEType != imgspecv1.MediaTypeImageManifest {
return nil, nil
}

ociMan, err := manifest.OCI1FromManifest(single.manifest)
if err != nil {
return nil, nil
}

if !hasZstdChunkedLayers(ociMan) {
return nil, nil
}

// Use the config as written to the destination (reflects any edits during copy).
var ociConfig imgspecv1.Image
if err := json.Unmarshal(single.configBlob, &ociConfig); err != nil {
logrus.Debugf("Cannot parse config for single-image sentinel: %v", err)
return nil, nil
}

sentinelManifestBlob, sentinelManifestDigest, err := c.pushSentinelVariant(ctx, ociMan, &ociConfig)
if err != nil {
return nil, err
}

// Extract platform from config.
var platform *imgspecv1.Platform
if ociConfig.OS != "" || ociConfig.Architecture != "" {
platform = &imgspecv1.Platform{
OS: ociConfig.OS,
Architecture: ociConfig.Architecture,
Variant: ociConfig.Variant,
OSVersion: ociConfig.OSVersion,
}
}

// Build OCI index with: original manifest first, sentinel variant last.
// Both entries get the zstd annotation so that old clients (which prefer
// zstd over gzip) fall through to position-based selection and pick the
// original at position 0 instead of the sentinel.
index := manifest.OCI1IndexFromComponents([]imgspecv1.Descriptor{
{
MediaType: imgspecv1.MediaTypeImageManifest,
Digest: single.manifestDigest,
Size: int64(len(single.manifest)),
Platform: platform,
Annotations: map[string]string{
internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue,
},
},
{
MediaType: imgspecv1.MediaTypeImageManifest,
Digest: sentinelManifestDigest,
Size: int64(len(sentinelManifestBlob)),
Platform: platform,
Annotations: map[string]string{
internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel: internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue,
internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue,
},
},
}, nil)
indexBlob, err := index.Serialize()
if err != nil {
return nil, fmt.Errorf("serializing sentinel index: %w", err)
}

if err := c.dest.PutManifest(ctx, indexBlob, nil); err != nil {
return nil, fmt.Errorf("pushing sentinel index: %w", err)
}

return indexBlob, nil
}
Loading
Loading