Skip to content

Commit 7f9a11c

Browse files
committed
copy: add option to strip sparse manifest lists
Add the ability to strip non-copied instances from manifest lists when using CopySpecificImages. This implements the functionality originally proposed in containers/image#1707. When copying a subset of images from a multi-architecture manifest list using CopySpecificImages, the current behavior always produces a sparse manifest list - a manifest list that references platforms that weren't actually copied. While this maintains the original digest, many registries reject sparse manifests with "blob unknown to registry" errors. This commit adds a new SparseManifestListAction option that gives users control over this behavior: - KeepSparseManifestList (default): Preserves the manifest list as-is, even when some instances aren't copied. Some registries may not support sparse manifest lists. - StripSparseManifestList (new): Removes instances that are not being copied from the destination manifest list. This changes the digest but ensures compatibility with all registries. Based on original work by @bertbaron and @mtrmac in containers/image#1707, adapted for the container-libs monorepo structure. Relates to #227 Signed-off-by: Alex Guidi <aguidi@redhat.com>
1 parent 01e7561 commit 7f9a11c

10 files changed

Lines changed: 368 additions & 99 deletions

File tree

image/copy/copy.go

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,28 @@ const (
7777
// specific images from the source reference.
7878
type ImageListSelection int
7979

80+
const (
81+
// KeepSparseManifestList is the default value which, when set in
82+
// Options.SparseManifestListAction, indicates that the manifest is kept
83+
// as is even though some images from the list may be missing. Some
84+
// registries may not support this.
85+
KeepSparseManifestList SparseManifestListAction = iota
86+
87+
// StripSparseManifestList will strip missing images from the manifest
88+
// list. When images are stripped the digest will differ from the original.
89+
StripSparseManifestList
90+
)
91+
92+
// SparseManifestListAction is one of KeepSparseManifestList or StripSparseManifestList
93+
// to control the behavior when only a subset of images from a manifest list is copied
94+
type SparseManifestListAction int
95+
8096
// Options allows supplying non-default configuration modifying the behavior of CopyImage.
8197
type Options struct {
8298
RemoveSignatures bool // Remove any pre-existing signatures. Signers and SignBy… will still add a new signature.
99+
// RemoveListSignatures removes the manifest list signature while preserving per-instance signatures.
100+
// If RemoveSignatures is also true, RemoveSignatures takes precedence.
101+
RemoveListSignatures bool
83102
// Signers to use to add signatures during the copy.
84103
// Callers are still responsible for closing these Signer objects; they can be reused for multiple copy.Image operations in a row.
85104
Signers []*signer.Signer
@@ -102,6 +121,9 @@ type Options struct {
102121
ImageListSelection ImageListSelection // set to either CopySystemImage (the default), CopyAllImages, or CopySpecificImages to control which instances we copy when the source reference is a list; ignored if the source reference is not a list
103122
Instances []digest.Digest // if ImageListSelection is CopySpecificImages, copy only these instances, instances matching the InstancePlatforms list, and the list itself
104123
InstancePlatforms []InstancePlatformFilter // if ImageListSelection is CopySpecificImages, copy instances with matching OS/Architecture (all variants and compressions), it also copies the index/manifest_list instance.
124+
// When only a subset of images of a list is copied, this action indicates if the manifest should be kept or stripped.
125+
// See CopySpecificImages.
126+
SparseManifestListAction SparseManifestListAction
105127
// Give priority to pulling gzip images if multiple images are present when configured to OptionalBoolTrue,
106128
// prefers the best compression if this is configured as OptionalBoolFalse. Choose automatically (and the choice may change over time)
107129
// if this is set to OptionalBoolUndefined (which is the default behavior, and recommended for most callers).
@@ -318,30 +340,15 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef,
318340
return nil, fmt.Errorf("determining manifest MIME type for %s: %w", transports.ImageName(srcRef), err)
319341
}
320342

343+
var singleInstance *image.UnparsedImage
344+
var singleInstanceErrorWrapping string
321345
if !multiImage {
322-
if len(options.EnsureCompressionVariantsExist) > 0 {
323-
return nil, fmt.Errorf("EnsureCompressionVariantsExist is not implemented when not creating a multi-architecture image")
324-
}
325-
requireCompressionFormatMatch, err := shouldRequireCompressionFormatMatch(options)
326-
if err != nil {
327-
return nil, err
328-
}
329346
// The simple case: just copy a single image.
330-
single, err := c.copySingleImage(ctx, c.unparsedToplevel, nil, copySingleImageOptions{requireCompressionFormatMatch: requireCompressionFormatMatch})
331-
if err != nil {
332-
return nil, err
333-
}
334-
copiedManifest = single.manifest
347+
singleInstance = c.unparsedToplevel
348+
singleInstanceErrorWrapping = ""
335349
} else if c.options.ImageListSelection == CopySystemImage {
336-
if len(options.EnsureCompressionVariantsExist) > 0 {
337-
return nil, fmt.Errorf("EnsureCompressionVariantsExist is not implemented when not creating a multi-architecture image")
338-
}
339-
requireCompressionFormatMatch, err := shouldRequireCompressionFormatMatch(options)
340-
if err != nil {
341-
return nil, err
342-
}
343-
// This is a manifest list, and we weren't asked to copy multiple images. Choose a single image that
344-
// matches the current system to copy, and copy it.
350+
// This is a manifest list, and we weren't asked to copy multiple images.
351+
// Choose a single image that matches the current system to copy.
345352
mfest, manifestType, err := c.unparsedToplevel.Manifest(ctx)
346353
if err != nil {
347354
return nil, fmt.Errorf("reading manifest for %s: %w", transports.ImageName(srcRef), err)
@@ -355,13 +362,30 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef,
355362
return nil, fmt.Errorf("choosing an image from manifest list %s: %w", transports.ImageName(srcRef), err)
356363
}
357364
logrus.Debugf("Source is a manifest list; copying (only) instance %s for current system", instanceDigest)
358-
unparsedInstance := image.UnparsedInstance(rawSource, &instanceDigest)
359-
single, err := c.copySingleImage(ctx, unparsedInstance, nil, copySingleImageOptions{requireCompressionFormatMatch: requireCompressionFormatMatch})
365+
singleInstance = image.UnparsedInstance(rawSource, &instanceDigest)
366+
singleInstanceErrorWrapping = "copying system image from manifest list"
367+
} // else: a multi-instance copy
368+
369+
if singleInstance != nil {
370+
if len(options.EnsureCompressionVariantsExist) > 0 {
371+
return nil, fmt.Errorf("EnsureCompressionVariantsExist is not implemented when not creating a multi-architecture image")
372+
}
373+
if c.options.RemoveListSignatures {
374+
return nil, fmt.Errorf("RemoveListSignatures is not applicable when copying a single instance")
375+
}
376+
requireCompressionFormatMatch, err := shouldRequireCompressionFormatMatch(options)
377+
if err != nil {
378+
return nil, err
379+
}
380+
single, err := c.copySingleImage(ctx, singleInstance, nil, copySingleImageOptions{requireCompressionFormatMatch: requireCompressionFormatMatch})
360381
if err != nil {
361-
return nil, fmt.Errorf("copying system image from manifest list: %w", err)
382+
if singleInstanceErrorWrapping != "" {
383+
return nil, fmt.Errorf("%s: %w", singleInstanceErrorWrapping, err)
384+
}
385+
return nil, err
362386
}
363387
copiedManifest = single.manifest
364-
} else { /* c.options.ImageListSelection == CopyAllImages or c.options.ImageListSelection == CopySpecificImages, */
388+
} else {
365389
// If we were asked to copy multiple images and can't, that's an error.
366390
if !supportsMultipleImages(c.dest) {
367391
return nil, fmt.Errorf("copying multiple images: destination transport %q does not support copying multiple images as a group", destRef.Transport().Name())

image/copy/multiple.go

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,32 @@ import (
2121
"go.podman.io/image/v5/pkg/compression"
2222
)
2323

24-
type instanceCopyKind int
24+
type instanceOpKind int
2525

2626
const (
27-
instanceCopyCopy instanceCopyKind = iota
28-
instanceCopyClone
27+
instanceOpCopy instanceOpKind = iota
28+
instanceOpClone
29+
instanceOpDelete
2930
)
3031

31-
type instanceCopy struct {
32-
op instanceCopyKind
32+
type instanceOp struct {
33+
op instanceOpKind
3334
sourceDigest digest.Digest
3435

3536
// Fields which can be used by callers when operation
36-
// is `instanceCopyCopy`
37+
// is `instanceOpCopy`
3738
copyForceCompressionFormat bool
3839

3940
// Fields which can be used by callers when operation
40-
// is `instanceCopyClone`
41+
// is `instanceOpClone`
4142
cloneArtifactType string
4243
cloneCompressionVariant OptionCompressionVariant
4344
clonePlatform *imgspecv1.Platform
4445
cloneAnnotations map[string]string
46+
47+
// Fields which can be used by callers when operation
48+
// is `instanceOpDelete`
49+
deleteIndex int
4550
}
4651

4752
// internal type only to make imgspecv1.Platform comparable
@@ -99,72 +104,95 @@ func validateCompressionVariantExists(input []OptionCompressionVariant) error {
99104
return nil
100105
}
101106

102-
// prepareInstanceCopies prepares a list of instances which needs to copied to the manifest list.
103-
func prepareInstanceCopies(list internalManifest.List, instanceDigests []digest.Digest, options *Options) ([]instanceCopy, error) {
104-
res := []instanceCopy{}
107+
// prepareInstanceOps prepares a list of operations to perform on instances (copy, clone, or delete).
108+
// It returns a unified list of all operations and the count of copy/clone operations (excluding deletes).
109+
func prepareInstanceOps(list internalManifest.List, instanceDigests []digest.Digest, options *Options, cannotModifyManifestListReason string) ([]instanceOp, int, error) {
110+
res := []instanceOp{}
111+
deleteOps := []instanceOp{}
105112
if options.ImageListSelection == CopySpecificImages && len(options.EnsureCompressionVariantsExist) > 0 {
106113
// List can already contain compressed instance for a compression selected in `EnsureCompressionVariantsExist`
107114
// It's unclear what it means when `CopySpecificImages` includes an instance in options.Instances,
108115
// EnsureCompressionVariantsExist asks for an instance with some compression,
109116
// an instance with that compression already exists, but is not included in options.Instances.
110117
// We might define the semantics and implement this in the future.
111-
return res, fmt.Errorf("EnsureCompressionVariantsExist is not implemented for CopySpecificImages")
118+
return res, -1, fmt.Errorf("EnsureCompressionVariantsExist is not implemented for CopySpecificImages")
112119
}
113120
err := validateCompressionVariantExists(options.EnsureCompressionVariantsExist)
114121
if err != nil {
115-
return res, err
122+
return res, -1, err
116123
}
117124
compressionsByPlatform, err := platformCompressionMap(list, instanceDigests)
118125
if err != nil {
119-
return nil, err
126+
return nil, -1, err
120127
}
121128

122129
// Determine which specific images to copy (combining digest-based and platform-based selection)
123130
var specificImages *set.Set[digest.Digest]
124131
if options.ImageListSelection == CopySpecificImages {
125132
specificImages, err = determineSpecificImages(options, list)
126133
if err != nil {
127-
return nil, err
134+
return nil, -1, err
128135
}
129136
}
130137

131138
for i, instanceDigest := range instanceDigests {
132139
if options.ImageListSelection == CopySpecificImages &&
133140
!specificImages.Contains(instanceDigest) {
134-
logrus.Debugf("Skipping instance %s (%d/%d)", instanceDigest, i+1, len(instanceDigests))
141+
if options.SparseManifestListAction == StripSparseManifestList {
142+
if cannotModifyManifestListReason != "" {
143+
return nil, -1, fmt.Errorf("we should delete instance %s from manifest list, but we cannot: %s", instanceDigest, cannotModifyManifestListReason)
144+
}
145+
logrus.Debugf("deleting instance %s from destination’s manifest (%d/%d)", instanceDigest, i+1, len(instanceDigests))
146+
deleteOps = append(deleteOps, instanceOp{
147+
op: instanceOpDelete,
148+
deleteIndex: i,
149+
})
150+
} else {
151+
logrus.Debugf("skipping instance %s (%d/%d)", instanceDigest, i+1, len(instanceDigests))
152+
}
135153
continue
136154
}
137155
instanceDetails, err := list.Instance(instanceDigest)
138156
if err != nil {
139-
return res, fmt.Errorf("getting details for instance %s: %w", instanceDigest, err)
157+
return res, -1, fmt.Errorf("getting details for instance %s: %w", instanceDigest, err)
140158
}
141159
forceCompressionFormat, err := shouldRequireCompressionFormatMatch(options)
142160
if err != nil {
143-
return nil, err
161+
return nil, -1, err
144162
}
145-
res = append(res, instanceCopy{
146-
op: instanceCopyCopy,
163+
res = append(res, instanceOp{
164+
op: instanceOpCopy,
147165
sourceDigest: instanceDigest,
148166
copyForceCompressionFormat: forceCompressionFormat,
149167
})
150168
platform := platformV1ToPlatformComparable(instanceDetails.ReadOnly.Platform)
151169
compressionList := compressionsByPlatform[platform]
152170
for _, compressionVariant := range options.EnsureCompressionVariantsExist {
153171
if !compressionList.Contains(compressionVariant.Algorithm.Name()) {
154-
res = append(res, instanceCopy{
155-
op: instanceCopyClone,
172+
res = append(res, instanceOp{
173+
op: instanceOpClone,
156174
sourceDigest: instanceDigest,
157175
cloneArtifactType: instanceDetails.ReadOnly.ArtifactType,
158176
cloneCompressionVariant: compressionVariant,
159177
clonePlatform: instanceDetails.ReadOnly.Platform,
160178
cloneAnnotations: maps.Clone(instanceDetails.ReadOnly.Annotations),
161179
})
162-
// add current compression to the list so that we don’t create duplicate clones
163180
compressionList.Add(compressionVariant.Algorithm.Name())
164181
}
165182
}
166183
}
167-
return res, nil
184+
185+
// Add delete operations in reverse order (highest to lowest index) to avoid shifting
186+
slices.Reverse(deleteOps)
187+
copyLen := len(res) // Count copy/clone operations before appending deletes
188+
189+
if copyLen == 0 && len(deleteOps) == len(instanceDigests) {
190+
return nil, -1, fmt.Errorf("requested operation filtered out all platforms and would create an empty image")
191+
}
192+
193+
res = append(res, deleteOps...)
194+
195+
return res, copyLen, nil
168196
}
169197

170198
// determineSpecificImages returns a set of images to copy based on the
@@ -222,7 +250,7 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
222250

223251
sigs, err := c.sourceSignatures(ctx, c.unparsedToplevel,
224252
"Getting image list signatures",
225-
"Checking if image list destination supports signatures")
253+
"Checking if image list destination supports signatures", true)
226254
if err != nil {
227255
return nil, err
228256
}
@@ -248,7 +276,7 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
248276
// Compare, and perhaps keep in sync with, the version in copySingleImage.
249277
cannotModifyManifestListReason := ""
250278
if len(sigs) > 0 {
251-
cannotModifyManifestListReason = "Would invalidate signatures"
279+
cannotModifyManifestListReason = "Would invalidate signatures; consider removing them from the multi-platform list"
252280
}
253281
if destIsDigestedReference {
254282
cannotModifyManifestListReason = "Destination specifies a digest"
@@ -272,29 +300,31 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
272300
}
273301
if selectedListType != originalList.MIMEType() {
274302
if cannotModifyManifestListReason != "" {
275-
return nil, fmt.Errorf("Manifest list must be converted to type %q to be written to destination, but we cannot modify it: %q", selectedListType, cannotModifyManifestListReason)
303+
return nil, fmt.Errorf("Manifest list must be converted to type %q to be written to destination, but we cannot modify it: %s", selectedListType, cannotModifyManifestListReason)
276304
}
277305
}
278306

279307
// Copy each image, or just the ones we want to copy, in turn.
280308
instanceDigests := updatedList.Instances()
281309
instanceEdits := []internalManifest.ListEdit{}
282-
instanceCopyList, err := prepareInstanceCopies(updatedList, instanceDigests, c.options)
310+
instanceOpList, copyLen, err := prepareInstanceOps(updatedList, instanceDigests, c.options, cannotModifyManifestListReason)
283311
if err != nil {
284312
return nil, fmt.Errorf("preparing instances for copy: %w", err)
285313
}
286-
c.Printf("Copying %d images generated from %d images in list\n", len(instanceCopyList), len(instanceDigests))
287-
for i, instance := range instanceCopyList {
314+
c.Printf("Copying %d images generated from %d images in list\n", copyLen, len(instanceDigests))
315+
copyCount := 0 // Track copy/clone operations separately from delete operations
316+
for i, instance := range instanceOpList {
288317
// Update instances to be edited by their `ListOperation` and
289318
// populate necessary fields.
290319
switch instance.op {
291-
case instanceCopyCopy:
292-
logrus.Debugf("Copying instance %s (%d/%d)", instance.sourceDigest, i+1, len(instanceCopyList))
293-
c.Printf("Copying image %s (%d/%d)\n", instance.sourceDigest, i+1, len(instanceCopyList))
294-
unparsedInstance := image.UnparsedInstance(c.rawSource, &instanceCopyList[i].sourceDigest)
295-
updated, err := c.copySingleImage(ctx, unparsedInstance, &instanceCopyList[i].sourceDigest, copySingleImageOptions{requireCompressionFormatMatch: instance.copyForceCompressionFormat})
320+
case instanceOpCopy:
321+
copyCount++
322+
logrus.Debugf("Copying instance %s (%d/%d)", instance.sourceDigest, copyCount, copyLen)
323+
c.Printf("Copying image %s (%d/%d)\n", instance.sourceDigest, copyCount, copyLen)
324+
unparsedInstance := image.UnparsedInstance(c.rawSource, &instanceOpList[i].sourceDigest)
325+
updated, err := c.copySingleImage(ctx, unparsedInstance, &instanceOpList[i].sourceDigest, copySingleImageOptions{requireCompressionFormatMatch: instance.copyForceCompressionFormat})
296326
if err != nil {
297-
return nil, fmt.Errorf("copying image %d/%d from manifest list: %w", i+1, len(instanceCopyList), err)
327+
return nil, fmt.Errorf("copying image %d/%d from manifest list: %w", copyCount, copyLen, err)
298328
}
299329
// Record the result of a possible conversion here.
300330
instanceEdits = append(instanceEdits, internalManifest.ListEdit{
@@ -305,17 +335,18 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
305335
UpdateCompressionAlgorithms: updated.compressionAlgorithms,
306336
UpdateMediaType: updated.manifestMIMEType,
307337
})
308-
case instanceCopyClone:
309-
logrus.Debugf("Replicating instance %s (%d/%d)", instance.sourceDigest, i+1, len(instanceCopyList))
310-
c.Printf("Replicating image %s (%d/%d)\n", instance.sourceDigest, i+1, len(instanceCopyList))
311-
unparsedInstance := image.UnparsedInstance(c.rawSource, &instanceCopyList[i].sourceDigest)
312-
updated, err := c.copySingleImage(ctx, unparsedInstance, &instanceCopyList[i].sourceDigest, copySingleImageOptions{
338+
case instanceOpClone:
339+
copyCount++
340+
logrus.Debugf("Replicating instance %s (%d/%d)", instance.sourceDigest, copyCount, copyLen)
341+
c.Printf("Replicating image %s (%d/%d)\n", instance.sourceDigest, copyCount, copyLen)
342+
unparsedInstance := image.UnparsedInstance(c.rawSource, &instanceOpList[i].sourceDigest)
343+
updated, err := c.copySingleImage(ctx, unparsedInstance, &instanceOpList[i].sourceDigest, copySingleImageOptions{
313344
requireCompressionFormatMatch: true,
314345
compressionFormat: &instance.cloneCompressionVariant.Algorithm,
315346
compressionLevel: instance.cloneCompressionVariant.Level,
316347
})
317348
if err != nil {
318-
return nil, fmt.Errorf("replicating image %d/%d from manifest list: %w", i+1, len(instanceCopyList), err)
349+
return nil, fmt.Errorf("replicating image %d/%d from manifest list: %w", copyCount, copyLen, err)
319350
}
320351
// Record the result of a possible conversion here.
321352
instanceEdits = append(instanceEdits, internalManifest.ListEdit{
@@ -328,12 +359,17 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
328359
AddAnnotations: instance.cloneAnnotations,
329360
AddCompressionAlgorithms: updated.compressionAlgorithms,
330361
})
362+
case instanceOpDelete:
363+
instanceEdits = append(instanceEdits, internalManifest.ListEdit{
364+
ListOperation: internalManifest.ListOpDelete,
365+
DeleteIndex: instance.deleteIndex,
366+
})
331367
default:
332368
return nil, fmt.Errorf("copying image: invalid copy operation %d", instance.op)
333369
}
334370
}
335371

336-
// Now reset the digest/size/types of the manifests in the list to account for any conversions that we made.
372+
// Now reset the digest/size/types of the manifests in the list and remove deleted instances.
337373
if err = updatedList.EditInstances(instanceEdits, cannotModifyManifestListReason != ""); err != nil {
338374
return nil, fmt.Errorf("updating manifest list: %w", err)
339375
}

0 commit comments

Comments
 (0)