Skip to content

Commit 8cfcfca

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 6fb485d commit 8cfcfca

File tree

4 files changed

+309
-13
lines changed

4 files changed

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

0 commit comments

Comments
 (0)