Skip to content

Commit 64735ab

Browse files
committed
copy: create zstd:chunked sentinel variants when pushing images
When pushing images with zstd:chunked layers, create sentinel variants with the sentinel layer prepended. For single images to destinations supporting manifest lists, wrap in an OCI index. For multi-image copies, add sentinel variants to the manifest list. Uses the sentinel MIME type on layer descriptors for detection consistency. Signed-off-by: Giuseppe Scrivano <gscrivan@redhat.com>
1 parent 3ff6363 commit 64735ab

File tree

2 files changed

+289
-0
lines changed

2 files changed

+289
-0
lines changed

image/copy/copy.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,18 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef,
385385
return nil, err
386386
}
387387
copiedManifest = single.manifest
388+
// If the copied single image has zstd:chunked layers and the destination
389+
// supports manifest lists, wrap it in an OCI index with a sentinel variant.
390+
// Skip when digests must be preserved or destination is a digested reference.
391+
if !options.PreserveDigests && supportsMultipleImages(c.dest) {
392+
indexManifest, err := c.createSingleImageSentinelIndex(ctx, single)
393+
if err != nil {
394+
return nil, err
395+
}
396+
if indexManifest != nil {
397+
copiedManifest = indexManifest
398+
}
399+
}
388400
} else {
389401
// If we were asked to copy multiple images and can't, that's an error.
390402
if !supportsMultipleImages(c.dest) {

image/copy/multiple.go

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package copy
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"errors"
78
"fmt"
89
"maps"
@@ -16,9 +17,12 @@ import (
1617
"go.podman.io/image/v5/docker/reference"
1718
"go.podman.io/image/v5/internal/image"
1819
internalManifest "go.podman.io/image/v5/internal/manifest"
20+
"go.podman.io/image/v5/internal/private"
1921
"go.podman.io/image/v5/internal/set"
2022
"go.podman.io/image/v5/manifest"
2123
"go.podman.io/image/v5/pkg/compression"
24+
"go.podman.io/image/v5/types"
25+
chunkedToc "go.podman.io/storage/pkg/chunked/toc"
2226
)
2327

2428
type instanceOpKind int
@@ -29,6 +33,14 @@ const (
2933
instanceOpDelete
3034
)
3135

36+
// copiedInstanceData stores info about a successfully copied instance,
37+
// used for creating sentinel variants.
38+
type copiedInstanceData struct {
39+
sourceDigest digest.Digest
40+
result copySingleImageResult
41+
platform *imgspecv1.Platform
42+
}
43+
3244
type instanceOp struct {
3345
op instanceOpKind
3446
sourceDigest digest.Digest
@@ -311,6 +323,8 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
311323
if err != nil {
312324
return nil, fmt.Errorf("preparing instances for copy: %w", err)
313325
}
326+
var copiedInstances []copiedInstanceData
327+
314328
c.Printf("Copying %d images generated from %d images in list\n", copyLen, len(instanceDigests))
315329
copyCount := 0 // Track copy/clone operations separately from delete operations
316330
for i, instance := range instanceOpList {
@@ -335,6 +349,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
335349
UpdateCompressionAlgorithms: updated.compressionAlgorithms,
336350
UpdateMediaType: updated.manifestMIMEType,
337351
})
352+
// Capture instance data for sentinel variant creation
353+
instanceDetails, detailsErr := updatedList.Instance(instance.sourceDigest)
354+
if detailsErr == nil {
355+
copiedInstances = append(copiedInstances, copiedInstanceData{
356+
sourceDigest: instance.sourceDigest,
357+
result: updated,
358+
platform: instanceDetails.ReadOnly.Platform,
359+
})
360+
}
338361
case instanceOpClone:
339362
copyCount++
340363
logrus.Debugf("Replicating instance %s (%d/%d)", instance.sourceDigest, copyCount, copyLen)
@@ -369,6 +392,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
369392
}
370393
}
371394

395+
// Create zstd:chunked sentinel variants for instances where any layer uses zstd:chunked.
396+
if cannotModifyManifestListReason == "" {
397+
sentinelEdits, err := c.createZstdChunkedSentinelVariants(ctx, copiedInstances, updatedList)
398+
if err != nil {
399+
return nil, fmt.Errorf("creating zstd:chunked sentinel variants: %w", err)
400+
}
401+
instanceEdits = append(instanceEdits, sentinelEdits...)
402+
}
403+
372404
// Now reset the digest/size/types of the manifests in the list and remove deleted instances.
373405
if err = updatedList.EditInstances(instanceEdits, cannotModifyManifestListReason != ""); err != nil {
374406
return nil, fmt.Errorf("updating manifest list: %w", err)
@@ -441,3 +473,248 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
441473

442474
return manifestList, nil
443475
}
476+
477+
// hasZstdChunkedLayers returns true if any non-empty layer in the manifest has
478+
// zstd:chunked TOC annotations.
479+
func hasZstdChunkedLayers(ociMan *manifest.OCI1) bool {
480+
for _, l := range ociMan.LayerInfos() {
481+
if l.EmptyLayer {
482+
continue
483+
}
484+
d, err := chunkedToc.GetTOCDigest(l.Annotations)
485+
if err == nil && d != nil {
486+
return true
487+
}
488+
}
489+
return false
490+
}
491+
492+
// pushSentinelVariant creates and pushes a sentinel variant of the given OCI manifest.
493+
// It prepends a sentinel layer and DiffID, creates a new config, and pushes everything
494+
// to the destination. Returns the serialized sentinel manifest and its digest.
495+
func (c *copier) pushSentinelVariant(ctx context.Context, ociMan *manifest.OCI1, ociConfig *imgspecv1.Image) ([]byte, digest.Digest, error) {
496+
sentinelContent := chunkedToc.ZstdChunkedSentinelContent
497+
498+
// Push sentinel blob (content-addressed, so idempotent if already pushed).
499+
sentinelBlobInfo := types.BlobInfo{
500+
Digest: chunkedToc.ZstdChunkedSentinelDigest,
501+
Size: int64(len(sentinelContent)),
502+
}
503+
reused, _, err := c.dest.TryReusingBlobWithOptions(ctx, sentinelBlobInfo,
504+
private.TryReusingBlobOptions{Cache: c.blobInfoCache})
505+
if err != nil {
506+
return nil, "", fmt.Errorf("checking sentinel blob: %w", err)
507+
}
508+
if !reused {
509+
_, err = c.dest.PutBlobWithOptions(ctx, bytes.NewReader(sentinelContent),
510+
sentinelBlobInfo, private.PutBlobOptions{Cache: c.blobInfoCache})
511+
if err != nil {
512+
return nil, "", fmt.Errorf("pushing sentinel blob: %w", err)
513+
}
514+
}
515+
516+
// Create new config with sentinel DiffID prepended.
517+
newDiffIDs := make([]digest.Digest, 0, len(ociConfig.RootFS.DiffIDs)+1)
518+
newDiffIDs = append(newDiffIDs, chunkedToc.ZstdChunkedSentinelDigest)
519+
newDiffIDs = append(newDiffIDs, ociConfig.RootFS.DiffIDs...)
520+
ociConfig.RootFS.DiffIDs = newDiffIDs
521+
configBlob, err := json.Marshal(ociConfig)
522+
if err != nil {
523+
return nil, "", fmt.Errorf("marshaling sentinel config: %w", err)
524+
}
525+
configDigest := digest.FromBytes(configBlob)
526+
527+
// Push new config.
528+
configBlobInfo := types.BlobInfo{
529+
Digest: configDigest,
530+
Size: int64(len(configBlob)),
531+
MediaType: imgspecv1.MediaTypeImageConfig,
532+
}
533+
reused, _, err = c.dest.TryReusingBlobWithOptions(ctx, configBlobInfo,
534+
private.TryReusingBlobOptions{Cache: c.blobInfoCache})
535+
if err != nil {
536+
return nil, "", fmt.Errorf("checking sentinel config: %w", err)
537+
}
538+
if !reused {
539+
_, err = c.dest.PutBlobWithOptions(ctx, bytes.NewReader(configBlob),
540+
configBlobInfo, private.PutBlobOptions{Cache: c.blobInfoCache, IsConfig: true})
541+
if err != nil {
542+
return nil, "", fmt.Errorf("pushing sentinel config: %w", err)
543+
}
544+
}
545+
546+
// Build sentinel manifest: sentinel layer + original layers.
547+
newLayers := make([]imgspecv1.Descriptor, 0, len(ociMan.Layers)+1)
548+
newLayers = append(newLayers, imgspecv1.Descriptor{
549+
MediaType: chunkedToc.ZstdChunkedSentinelMediaType,
550+
Digest: chunkedToc.ZstdChunkedSentinelDigest,
551+
Size: int64(len(sentinelContent)),
552+
})
553+
newLayers = append(newLayers, ociMan.Layers...)
554+
555+
sentinelOCI := manifest.OCI1FromComponents(imgspecv1.Descriptor{
556+
MediaType: imgspecv1.MediaTypeImageConfig,
557+
Digest: configDigest,
558+
Size: int64(len(configBlob)),
559+
}, newLayers)
560+
sentinelManifestBlob, err := sentinelOCI.Serialize()
561+
if err != nil {
562+
return nil, "", fmt.Errorf("serializing sentinel manifest: %w", err)
563+
}
564+
sentinelManifestDigest := digest.FromBytes(sentinelManifestBlob)
565+
566+
// Push sentinel manifest.
567+
if err := c.dest.PutManifest(ctx, sentinelManifestBlob, &sentinelManifestDigest); err != nil {
568+
return nil, "", fmt.Errorf("pushing sentinel manifest: %w", err)
569+
}
570+
571+
return sentinelManifestBlob, sentinelManifestDigest, nil
572+
}
573+
574+
// createZstdChunkedSentinelVariants creates sentinel variants for instances
575+
// where any layer uses zstd:chunked. The sentinel variant has a non-tar sentinel
576+
// layer prepended, signaling aware clients to skip the full-digest mitigation.
577+
// It returns ListEdit entries to add the sentinel variants to the manifest list.
578+
func (c *copier) createZstdChunkedSentinelVariants(ctx context.Context, copiedInstances []copiedInstanceData, updatedList internalManifest.List) ([]internalManifest.ListEdit, error) {
579+
var edits []internalManifest.ListEdit
580+
581+
// Check which platforms already have a sentinel variant in the source.
582+
platformsWithSentinel := set.New[platformComparable]()
583+
for _, d := range updatedList.Instances() {
584+
details, err := updatedList.Instance(d)
585+
if err != nil {
586+
continue
587+
}
588+
if details.ReadOnly.Annotations[internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel] == internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue {
589+
platformsWithSentinel.Add(platformV1ToPlatformComparable(details.ReadOnly.Platform))
590+
}
591+
}
592+
593+
for _, ci := range copiedInstances {
594+
// Only handle OCI manifests (zstd:chunked is OCI-only).
595+
if ci.result.manifestMIMEType != imgspecv1.MediaTypeImageManifest {
596+
continue
597+
}
598+
599+
// Skip if this platform already has a sentinel variant.
600+
if platformsWithSentinel.Contains(platformV1ToPlatformComparable(ci.platform)) {
601+
continue
602+
}
603+
604+
ociMan, err := manifest.OCI1FromManifest(ci.result.manifest)
605+
if err != nil {
606+
logrus.Debugf("Cannot parse manifest for sentinel variant: %v", err)
607+
continue
608+
}
609+
610+
if !hasZstdChunkedLayers(ociMan) {
611+
continue
612+
}
613+
614+
// Use the config as written to the destination (reflects any edits during copy).
615+
var ociConfig imgspecv1.Image
616+
if err := json.Unmarshal(ci.result.configBlob, &ociConfig); err != nil {
617+
logrus.Debugf("Cannot parse config for sentinel variant: %v", err)
618+
continue
619+
}
620+
621+
sentinelManifestBlob, sentinelManifestDigest, err := c.pushSentinelVariant(ctx, ociMan, &ociConfig)
622+
if err != nil {
623+
return nil, err
624+
}
625+
626+
edits = append(edits, internalManifest.ListEdit{
627+
ListOperation: internalManifest.ListOpAdd,
628+
AddDigest: sentinelManifestDigest,
629+
AddSize: int64(len(sentinelManifestBlob)),
630+
AddMediaType: imgspecv1.MediaTypeImageManifest,
631+
AddPlatform: ci.platform,
632+
AddAnnotations: map[string]string{
633+
internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel: internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue,
634+
internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue,
635+
},
636+
AddCompressionAlgorithms: ci.result.compressionAlgorithms,
637+
})
638+
639+
platformsWithSentinel.Add(platformV1ToPlatformComparable(ci.platform))
640+
}
641+
642+
return edits, nil
643+
}
644+
645+
// createSingleImageSentinelIndex checks if a single copied image has zstd:chunked
646+
// layers, and if so, creates a sentinel variant and wraps both in an OCI index.
647+
// Returns the serialized index manifest, or nil if no sentinel was needed.
648+
func (c *copier) createSingleImageSentinelIndex(ctx context.Context, single copySingleImageResult) ([]byte, error) {
649+
if single.manifestMIMEType != imgspecv1.MediaTypeImageManifest {
650+
return nil, nil
651+
}
652+
653+
ociMan, err := manifest.OCI1FromManifest(single.manifest)
654+
if err != nil {
655+
return nil, nil
656+
}
657+
658+
if !hasZstdChunkedLayers(ociMan) {
659+
return nil, nil
660+
}
661+
662+
// Use the config as written to the destination (reflects any edits during copy).
663+
var ociConfig imgspecv1.Image
664+
if err := json.Unmarshal(single.configBlob, &ociConfig); err != nil {
665+
logrus.Debugf("Cannot parse config for single-image sentinel: %v", err)
666+
return nil, nil
667+
}
668+
669+
sentinelManifestBlob, sentinelManifestDigest, err := c.pushSentinelVariant(ctx, ociMan, &ociConfig)
670+
if err != nil {
671+
return nil, err
672+
}
673+
674+
// Extract platform from config.
675+
var platform *imgspecv1.Platform
676+
if ociConfig.OS != "" || ociConfig.Architecture != "" {
677+
platform = &imgspecv1.Platform{
678+
OS: ociConfig.OS,
679+
Architecture: ociConfig.Architecture,
680+
Variant: ociConfig.Variant,
681+
OSVersion: ociConfig.OSVersion,
682+
}
683+
}
684+
685+
// Build OCI index with: original manifest first, sentinel variant last.
686+
// Both entries get the zstd annotation so that old clients (which prefer
687+
// zstd over gzip) fall through to position-based selection and pick the
688+
// original at position 0 instead of the sentinel.
689+
index := manifest.OCI1IndexFromComponents([]imgspecv1.Descriptor{
690+
{
691+
MediaType: imgspecv1.MediaTypeImageManifest,
692+
Digest: single.manifestDigest,
693+
Size: int64(len(single.manifest)),
694+
Platform: platform,
695+
Annotations: map[string]string{
696+
internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue,
697+
},
698+
},
699+
{
700+
MediaType: imgspecv1.MediaTypeImageManifest,
701+
Digest: sentinelManifestDigest,
702+
Size: int64(len(sentinelManifestBlob)),
703+
Platform: platform,
704+
Annotations: map[string]string{
705+
internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel: internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue,
706+
internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue,
707+
},
708+
},
709+
}, nil)
710+
indexBlob, err := index.Serialize()
711+
if err != nil {
712+
return nil, fmt.Errorf("serializing sentinel index: %w", err)
713+
}
714+
715+
if err := c.dest.PutManifest(ctx, indexBlob, nil); err != nil {
716+
return nil, fmt.Errorf("pushing sentinel index: %w", err)
717+
}
718+
719+
return indexBlob, nil
720+
}

0 commit comments

Comments
 (0)