Skip to content

Commit ed03b46

Browse files
giuseppeclaude
andcommitted
copy: create zstd:chunked sentinel variants when pushing images
When pushing images, generate zstd:chunked sentinel variant manifests. For manifest lists, create sentinel variants for each instance after copying. For single images, wrap the result in an OCI index with a sentinel variant when the destination supports it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Giuseppe Scrivano <gscrivan@redhat.com>
1 parent e52e124 commit ed03b46

File tree

2 files changed

+285
-0
lines changed

2 files changed

+285
-0
lines changed

image/copy/copy.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,18 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef,
332332
return nil, err
333333
}
334334
copiedManifest = single.manifest
335+
// If the copied single image has zstd:chunked layers and the destination
336+
// supports manifest lists, wrap it in an OCI index with a sentinel variant.
337+
// Skip when digests must be preserved or destination is a digested reference.
338+
if !options.PreserveDigests && supportsMultipleImages(c.dest) {
339+
indexManifest, err := c.createSingleImageSentinelIndex(ctx, single)
340+
if err != nil {
341+
return nil, err
342+
}
343+
if indexManifest != nil {
344+
copiedManifest = indexManifest
345+
}
346+
}
335347
} else if c.options.ImageListSelection == CopySystemImage {
336348
if len(options.EnsureCompressionVariantsExist) > 0 {
337349
return nil, fmt.Errorf("EnsureCompressionVariantsExist is not implemented when not creating a multi-architecture image")

image/copy/multiple.go

Lines changed: 273 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 instanceCopyKind int
@@ -28,6 +32,14 @@ const (
2832
instanceCopyClone
2933
)
3034

35+
// copiedInstanceData stores info about a successfully copied instance,
36+
// used for creating sentinel variants.
37+
type copiedInstanceData struct {
38+
sourceDigest digest.Digest
39+
result copySingleImageResult
40+
platform *imgspecv1.Platform
41+
}
42+
3143
type instanceCopy struct {
3244
op instanceCopyKind
3345
sourceDigest digest.Digest
@@ -283,6 +295,8 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
283295
if err != nil {
284296
return nil, fmt.Errorf("preparing instances for copy: %w", err)
285297
}
298+
var copiedInstances []copiedInstanceData
299+
286300
c.Printf("Copying %d images generated from %d images in list\n", len(instanceCopyList), len(instanceDigests))
287301
for i, instance := range instanceCopyList {
288302
// Update instances to be edited by their `ListOperation` and
@@ -305,6 +319,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
305319
UpdateCompressionAlgorithms: updated.compressionAlgorithms,
306320
UpdateMediaType: updated.manifestMIMEType,
307321
})
322+
// Capture instance data for sentinel variant creation
323+
instanceDetails, detailsErr := updatedList.Instance(instance.sourceDigest)
324+
if detailsErr == nil {
325+
copiedInstances = append(copiedInstances, copiedInstanceData{
326+
sourceDigest: instance.sourceDigest,
327+
result: updated,
328+
platform: instanceDetails.ReadOnly.Platform,
329+
})
330+
}
308331
case instanceCopyClone:
309332
logrus.Debugf("Replicating instance %s (%d/%d)", instance.sourceDigest, i+1, len(instanceCopyList))
310333
c.Printf("Replicating image %s (%d/%d)\n", instance.sourceDigest, i+1, len(instanceCopyList))
@@ -333,6 +356,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
333356
}
334357
}
335358

359+
// Create zstd:chunked sentinel variants for instances where any layer uses zstd:chunked.
360+
if cannotModifyManifestListReason == "" {
361+
sentinelEdits, err := c.createZstdChunkedSentinelVariants(ctx, copiedInstances, updatedList)
362+
if err != nil {
363+
return nil, fmt.Errorf("creating zstd:chunked sentinel variants: %w", err)
364+
}
365+
instanceEdits = append(instanceEdits, sentinelEdits...)
366+
}
367+
336368
// Now reset the digest/size/types of the manifests in the list to account for any conversions that we made.
337369
if err = updatedList.EditInstances(instanceEdits, cannotModifyManifestListReason != ""); err != nil {
338370
return nil, fmt.Errorf("updating manifest list: %w", err)
@@ -405,3 +437,244 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
405437

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

0 commit comments

Comments
 (0)