Skip to content

Commit 4fa04e2

Browse files
committed
squash! copy: add option to strip sparse manifest lists
copy: implement code review feedback for sparse manifest stripping Address feedback from mtrmac's review on PR podman-container-tools#655: 1. Simplify deletion logic by combining EditInstances calls - Moved deletion tracking into prepareInstanceCopies function - Combined update and delete operations into single EditInstances call - Removed separate digest-based tracking and redundant loops 2. Use slices.Delete for manifest entry removal - Replaced manual slice manipulation with slices.Delete in both docker_schema2_list.go and oci_index.go for cleaner code 3. Integrate deletion tests into existing test methods - Removed standalone TestListDeleteInstances function - Added deletion test cases to TestSchema2ListEditInstances and TestOCI1EditInstances for better test organization 4. Move deletion logic into prepareInstanceCopies (most critical fix) - Modified prepareInstanceCopies to return ([]instanceCopy, []int, error) - Track indices to delete when SparseManifestListAction == StripSparseManifestList - Properly handles duplicate digests by using instance indices instead of digest-based lookup, fixing edge case where same digest appears multiple times in manifest list 5. Use slices.Backward for reverse iteration - Simplified deletion loop using slices.Backward iterator - Maintains high-to-low deletion order to avoid index shifting 6. Move SparseManifestListAction field for better organization - Relocated field from line 150 to line 116 in copy.go - Now positioned near related ImageListSelection and Instances fields - Improves logical grouping of manifest list options All tests pass. The implementation now correctly handles: - Duplicate digests in manifest lists - Single EditInstances call for efficiency - Cleaner separation of concerns - Better code organization Fixes feedback on podman-container-tools#655 Signed-off-by: Alex Guidi <aguidi@redhat.com>
1 parent 2bacf61 commit 4fa04e2

8 files changed

Lines changed: 103 additions & 98 deletions

File tree

image/copy/copy.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ type Options struct {
111111
ForceManifestMIMEType string
112112
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
113113
Instances []digest.Digest // if ImageListSelection is CopySpecificImages, copy only these instances and the list itself
114+
// When only a subset of images of a list is copied, this action indicates if the manifest should be kept or stripped.
115+
// See CopySpecificImages.
116+
SparseManifestListAction SparseManifestListAction
114117
// Give priority to pulling gzip images if multiple images are present when configured to OptionalBoolTrue,
115118
// prefers the best compression if this is configured as OptionalBoolFalse. Choose automatically (and the choice may change over time)
116119
// if this is set to OptionalBoolUndefined (which is the default behavior, and recommended for most callers).
@@ -145,10 +148,6 @@ type Options struct {
145148
// to not indicate "nondistributable".
146149
DownloadForeignLayers bool
147150

148-
// When only a subset of images of a list is copied, this action indicates if the manifest should be kept or stripped.
149-
// See CopySpecificImages.
150-
SparseManifestListAction SparseManifestListAction
151-
152151
// Contains slice of OptionCompressionVariant, where copy will ensure that for each platform
153152
// in the manifest list, a variant with the requested compression will exist.
154153
// Invalid when copying a non-multi-architecture image. That will probably

image/copy/multiple.go

Lines changed: 26 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -100,37 +100,43 @@ func validateCompressionVariantExists(input []OptionCompressionVariant) error {
100100
}
101101

102102
// 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) {
103+
// It returns the list of instances to copy and a list of instance indices to delete (if stripping sparse manifests).
104+
func prepareInstanceCopies(list internalManifest.List, instanceDigests []digest.Digest, options *Options) ([]instanceCopy, []int, error) {
104105
res := []instanceCopy{}
106+
indicesToDelete := []int{}
105107
if options.ImageListSelection == CopySpecificImages && len(options.EnsureCompressionVariantsExist) > 0 {
106108
// List can already contain compressed instance for a compression selected in `EnsureCompressionVariantsExist`
107-
// Its unclear what it means when `CopySpecificImages` includes an instance in options.Instances,
109+
// It's unclear what it means when `CopySpecificImages` includes an instance in options.Instances,
108110
// EnsureCompressionVariantsExist asks for an instance with some compression,
109111
// an instance with that compression already exists, but is not included in options.Instances.
110112
// We might define the semantics and implement this in the future.
111-
return res, fmt.Errorf("EnsureCompressionVariantsExist is not implemented for CopySpecificImages")
113+
return res, indicesToDelete, fmt.Errorf("EnsureCompressionVariantsExist is not implemented for CopySpecificImages")
112114
}
113115
err := validateCompressionVariantExists(options.EnsureCompressionVariantsExist)
114116
if err != nil {
115-
return res, err
117+
return res, indicesToDelete, err
116118
}
117119
compressionsByPlatform, err := platformCompressionMap(list, instanceDigests)
118120
if err != nil {
119-
return nil, err
121+
return nil, indicesToDelete, err
120122
}
121123
for i, instanceDigest := range instanceDigests {
122124
if options.ImageListSelection == CopySpecificImages &&
123125
!slices.Contains(options.Instances, instanceDigest) {
124126
logrus.Debugf("Skipping instance %s (%d/%d)", instanceDigest, i+1, len(instanceDigests))
127+
// Track this index for deletion if we're stripping sparse manifests
128+
if options.SparseManifestListAction == StripSparseManifestList {
129+
indicesToDelete = append(indicesToDelete, i)
130+
}
125131
continue
126132
}
127133
instanceDetails, err := list.Instance(instanceDigest)
128134
if err != nil {
129-
return res, fmt.Errorf("getting details for instance %s: %w", instanceDigest, err)
135+
return res, indicesToDelete, fmt.Errorf("getting details for instance %s: %w", instanceDigest, err)
130136
}
131137
forceCompressionFormat, err := shouldRequireCompressionFormatMatch(options)
132138
if err != nil {
133-
return nil, err
139+
return nil, indicesToDelete, err
134140
}
135141
res = append(res, instanceCopy{
136142
op: instanceCopyCopy,
@@ -149,12 +155,12 @@ func prepareInstanceCopies(list internalManifest.List, instanceDigests []digest.
149155
clonePlatform: instanceDetails.ReadOnly.Platform,
150156
cloneAnnotations: maps.Clone(instanceDetails.ReadOnly.Annotations),
151157
})
152-
// add current compression to the list so that we dont create duplicate clones
158+
// add current compression to the list so that we don't create duplicate clones
153159
compressionList.Add(compressionVariant.Algorithm.Name())
154160
}
155161
}
156162
}
157-
return res, nil
163+
return res, indicesToDelete, nil
158164
}
159165

160166
// copyMultipleImages copies some or all of an image list's instances, using
@@ -230,7 +236,7 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
230236
// Copy each image, or just the ones we want to copy, in turn.
231237
instanceDigests := updatedList.Instances()
232238
instanceEdits := []internalManifest.ListEdit{}
233-
instanceCopyList, err := prepareInstanceCopies(updatedList, instanceDigests, c.options)
239+
instanceCopyList, indicesToDelete, err := prepareInstanceCopies(updatedList, instanceDigests, c.options)
234240
if err != nil {
235241
return nil, fmt.Errorf("preparing instances for copy: %w", err)
236242
}
@@ -284,44 +290,18 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
284290
}
285291
}
286292

287-
// Now reset the digest/size/types of the manifests in the list to account for any conversions that we made.
288-
if err = updatedList.EditInstances(instanceEdits, cannotModifyManifestListReason != ""); err != nil {
289-
return nil, fmt.Errorf("updating manifest list: %w", err)
293+
// Add delete operations for skipped instances if stripping sparse manifests
294+
// Delete from highest to lowest index to avoid shifting
295+
for i := range slices.Backward(indicesToDelete) {
296+
instanceEdits = append(instanceEdits, internalManifest.ListEdit{
297+
ListOperation: internalManifest.ListOpDelete,
298+
DeleteIndex: indicesToDelete[i],
299+
})
290300
}
291301

292-
// Remove skipped instances from the manifest list if StripSparseManifestList is enabled
293-
if c.options.ImageListSelection == CopySpecificImages && c.options.SparseManifestListAction == StripSparseManifestList {
294-
// Build a set of digests that were copied
295-
copiedDigests := set.New[digest.Digest]()
296-
for _, instance := range instanceCopyList {
297-
copiedDigests.Add(instance.sourceDigest)
298-
}
299-
300-
// Find which indices were skipped
301-
var indicesToDelete []int
302-
for i, instanceDigest := range instanceDigests {
303-
if !copiedDigests.Contains(instanceDigest) {
304-
indicesToDelete = append(indicesToDelete, i)
305-
}
306-
}
307-
308-
// Build delete operations for skipped instances
309-
// Delete from highest to lowest index to avoid shifting
310-
var deleteEdits []internalManifest.ListEdit
311-
for i := len(indicesToDelete) - 1; i >= 0; i-- {
312-
deleteEdits = append(deleteEdits, internalManifest.ListEdit{
313-
ListOperation: internalManifest.ListOpDelete,
314-
DeleteIndex: indicesToDelete[i],
315-
})
316-
}
317-
318-
// Remove skipped instances from the manifest list using EditInstances
319-
if len(deleteEdits) > 0 {
320-
logrus.Debugf("Removing %d instances from manifest list", len(deleteEdits))
321-
if err := updatedList.EditInstances(deleteEdits, false); err != nil {
322-
return nil, fmt.Errorf("stripping sparse manifest list: %w", err)
323-
}
324-
}
302+
// Now reset the digest/size/types of the manifests in the list and remove deleted instances.
303+
if err = updatedList.EditInstances(instanceEdits, cannotModifyManifestListReason != ""); err != nil {
304+
return nil, fmt.Errorf("updating manifest list: %w", err)
325305
}
326306

327307
// Iterate through supported list types, preferred format first.

image/copy/multiple_test.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ func TestPrepareCopyInstancesforInstanceCopyCopy(t *testing.T) {
2626
digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
2727
}
2828

29-
instancesToCopy, err := prepareInstanceCopies(list, sourceInstances, &Options{})
29+
instancesToCopy, indicesToDelete, err := prepareInstanceCopies(list, sourceInstances, &Options{})
3030
require.NoError(t, err)
31+
require.Empty(t, indicesToDelete)
3132
compare := []instanceCopy{}
3233

3334
for _, instance := range sourceInstances {
@@ -39,15 +40,30 @@ func TestPrepareCopyInstancesforInstanceCopyCopy(t *testing.T) {
3940
assert.Equal(t, instancesToCopy, compare)
4041

4142
// Test CopySpecificImages where selected instance is sourceInstances[1]
42-
instancesToCopy, err = prepareInstanceCopies(list, sourceInstances, &Options{Instances: []digest.Digest{sourceInstances[1]}, ImageListSelection: CopySpecificImages})
43+
instancesToCopy, indicesToDelete, err = prepareInstanceCopies(list, sourceInstances, &Options{Instances: []digest.Digest{sourceInstances[1]}, ImageListSelection: CopySpecificImages})
4344
require.NoError(t, err)
45+
require.Empty(t, indicesToDelete) // No stripping requested
4446
compare = []instanceCopy{{
4547
op: instanceCopyCopy,
4648
sourceDigest: sourceInstances[1],
4749
}}
4850
assert.Equal(t, instancesToCopy, compare)
4951

50-
_, err = prepareInstanceCopies(list, sourceInstances, &Options{Instances: []digest.Digest{sourceInstances[1]}, ImageListSelection: CopySpecificImages, ForceCompressionFormat: true})
52+
// Test CopySpecificImages with StripSparseManifestList where selected instance is sourceInstances[1]
53+
instancesToCopy, indicesToDelete, err = prepareInstanceCopies(list, sourceInstances, &Options{
54+
Instances: []digest.Digest{sourceInstances[1]},
55+
ImageListSelection: CopySpecificImages,
56+
SparseManifestListAction: StripSparseManifestList,
57+
})
58+
require.NoError(t, err)
59+
assert.Equal(t, []int{0, 2}, indicesToDelete) // Should delete indices 0 and 2
60+
compare = []instanceCopy{{
61+
op: instanceCopyCopy,
62+
sourceDigest: sourceInstances[1],
63+
}}
64+
assert.Equal(t, instancesToCopy, compare)
65+
66+
_, _, err = prepareInstanceCopies(list, sourceInstances, &Options{Instances: []digest.Digest{sourceInstances[1]}, ImageListSelection: CopySpecificImages, ForceCompressionFormat: true})
5167
require.EqualError(t, err, "cannot use ForceCompressionFormat with undefined default compression format")
5268
}
5369

@@ -68,16 +84,17 @@ func TestPrepareCopyInstancesforInstanceCopyClone(t *testing.T) {
6884
}
6985

7086
// CopySpecificImage must fail with error
71-
_, err = prepareInstanceCopies(list, sourceInstances, &Options{
87+
_, _, err = prepareInstanceCopies(list, sourceInstances, &Options{
7288
EnsureCompressionVariantsExist: ensureCompressionVariantsExist,
7389
Instances: []digest.Digest{sourceInstances[1]},
7490
ImageListSelection: CopySpecificImages,
7591
})
7692
require.EqualError(t, err, "EnsureCompressionVariantsExist is not implemented for CopySpecificImages")
7793

7894
// Test copying all images with replication
79-
instancesToCopy, err := prepareInstanceCopies(list, sourceInstances, &Options{EnsureCompressionVariantsExist: ensureCompressionVariantsExist})
95+
instancesToCopy, indicesToDelete, err := prepareInstanceCopies(list, sourceInstances, &Options{EnsureCompressionVariantsExist: ensureCompressionVariantsExist})
8096
require.NoError(t, err)
97+
require.Empty(t, indicesToDelete)
8198

8299
// Following test ensures
83100
// * Still copy gzip variants if they exist in the original
@@ -104,8 +121,9 @@ func TestPrepareCopyInstancesforInstanceCopyClone(t *testing.T) {
104121
// Test option with multiple copy request for same compression format.
105122
// The above expectation should stay the same, if ensureCompressionVariantsExist requests zstd twice.
106123
ensureCompressionVariantsExist = []OptionCompressionVariant{{Algorithm: compression.Zstd}, {Algorithm: compression.Zstd}}
107-
instancesToCopy, err = prepareInstanceCopies(list, sourceInstances, &Options{EnsureCompressionVariantsExist: ensureCompressionVariantsExist})
124+
instancesToCopy, indicesToDelete, err = prepareInstanceCopies(list, sourceInstances, &Options{EnsureCompressionVariantsExist: ensureCompressionVariantsExist})
108125
require.NoError(t, err)
126+
require.Empty(t, indicesToDelete)
109127
expectedResponse = []simplerInstanceCopy{}
110128
for _, instance := range sourceInstances {
111129
expectedResponse = append(expectedResponse, simplerInstanceCopy{
@@ -126,8 +144,9 @@ func TestPrepareCopyInstancesforInstanceCopyClone(t *testing.T) {
126144
digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
127145
digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
128146
}
129-
instancesToCopy, err = prepareInstanceCopies(list, sourceInstances, &Options{EnsureCompressionVariantsExist: ensureCompressionVariantsExist})
147+
instancesToCopy, indicesToDelete, err = prepareInstanceCopies(list, sourceInstances, &Options{EnsureCompressionVariantsExist: ensureCompressionVariantsExist})
130148
require.NoError(t, err)
149+
require.Empty(t, indicesToDelete)
131150
// two copies but clone should happen only once
132151
numberOfCopyClone := 0
133152
for _, instance := range instancesToCopy {

image/internal/manifest/docker_schema2_list.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,7 @@ func (list *Schema2ListPublic) editInstances(editInstances []ListEdit, cannotMod
136136
if editInstance.DeleteIndex < 0 || editInstance.DeleteIndex >= len(list.Manifests) {
137137
return fmt.Errorf("Schema2List.EditInstances: invalid delete index %d (list has %d instances)", editInstance.DeleteIndex, len(list.Manifests))
138138
}
139-
// Remove the element by appending slices before and after the target index
140-
list.Manifests = append(list.Manifests[:editInstance.DeleteIndex], list.Manifests[editInstance.DeleteIndex+1:]...)
139+
list.Manifests = slices.Delete(list.Manifests, editInstance.DeleteIndex, editInstance.DeleteIndex+1)
141140
default:
142141
return fmt.Errorf("internal error: invalid operation: %d", editInstance.ListOperation)
143142
}

image/internal/manifest/docker_schema2_list_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,29 @@ func TestSchema2ListEditInstances(t *testing.T) {
9090
digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
9191
digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
9292
), list.Instances())
93+
94+
// Create a fresh list for delete tests
95+
list, err = ListFromBlob(validManifest, GuessMIMEType(validManifest))
96+
require.NoError(t, err)
97+
originalInstances := list.Instances()
98+
require.GreaterOrEqual(t, len(originalInstances), 1)
99+
100+
// Test successful deletion of first instance
101+
err = list.EditInstances([]ListEdit{{
102+
ListOperation: ListOpDelete,
103+
DeleteIndex: 0,
104+
}}, false)
105+
require.NoError(t, err)
106+
assert.Equal(t, len(originalInstances)-1, len(list.Instances()))
107+
assert.Equal(t, originalInstances[1:], list.Instances())
108+
109+
// Test error on invalid index
110+
err = list.EditInstances([]ListEdit{{
111+
ListOperation: ListOpDelete,
112+
DeleteIndex: 999,
113+
}}, false)
114+
assert.Error(t, err)
115+
assert.Contains(t, err.Error(), "invalid delete index")
93116
}
94117

95118
func TestSchema2ListFromManifest(t *testing.T) {

image/internal/manifest/list_test.go

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -159,40 +159,3 @@ func TestChooseInstance(t *testing.T) {
159159
}
160160
}
161161
}
162-
163-
// TestListDeleteInstances tests the ListOpDelete functionality for both OCI and Docker manifest formats.
164-
func TestListDeleteInstances(t *testing.T) {
165-
manifestFiles := []string{
166-
"ociv1.image.index.json",
167-
"v2list.manifest.json",
168-
}
169-
170-
for _, manifestFile := range manifestFiles {
171-
t.Run(manifestFile, func(t *testing.T) {
172-
validManifest, err := os.ReadFile(filepath.Join("testdata", manifestFile))
173-
require.NoError(t, err)
174-
list, err := ListFromBlob(validManifest, GuessMIMEType(validManifest))
175-
require.NoError(t, err)
176-
177-
originalInstances := list.Instances()
178-
require.GreaterOrEqual(t, len(originalInstances), 1)
179-
180-
// Test successful deletion
181-
err = list.EditInstances([]ListEdit{{
182-
ListOperation: ListOpDelete,
183-
DeleteIndex: 0,
184-
}}, false)
185-
require.NoError(t, err)
186-
assert.Equal(t, len(originalInstances)-1, len(list.Instances()))
187-
assert.Equal(t, originalInstances[1:], list.Instances())
188-
189-
// Test error on invalid index
190-
err = list.EditInstances([]ListEdit{{
191-
ListOperation: ListOpDelete,
192-
DeleteIndex: 999,
193-
}}, false)
194-
assert.Error(t, err)
195-
assert.Contains(t, err.Error(), "invalid delete index")
196-
})
197-
}
198-
}

image/internal/manifest/oci_index.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,7 @@ func (index *OCI1IndexPublic) editInstances(editInstances []ListEdit, cannotModi
180180
if editInstance.DeleteIndex < 0 || editInstance.DeleteIndex >= len(index.Manifests) {
181181
return fmt.Errorf("OCI1Index.EditInstances: invalid delete index %d (list has %d instances)", editInstance.DeleteIndex, len(index.Manifests))
182182
}
183-
// Remove the element by appending slices before and after the target index
184-
index.Manifests = append(index.Manifests[:editInstance.DeleteIndex], index.Manifests[editInstance.DeleteIndex+1:]...)
183+
index.Manifests = slices.Delete(index.Manifests, editInstance.DeleteIndex, editInstance.DeleteIndex+1)
185184
default:
186185
return fmt.Errorf("internal error: invalid operation: %d", editInstance.ListOperation)
187186
}

image/internal/manifest/oci_index_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,29 @@ func TestOCI1EditInstances(t *testing.T) {
228228
assert.Equal(t, originalInstance, instance)
229229
}
230230
}
231+
232+
// Create a fresh list for delete tests
233+
list, err = ListFromBlob(validManifest, GuessMIMEType(validManifest))
234+
require.NoError(t, err)
235+
originalInstances := list.Instances()
236+
require.GreaterOrEqual(t, len(originalInstances), 1)
237+
238+
// Test successful deletion of first instance
239+
err = list.EditInstances([]ListEdit{{
240+
ListOperation: ListOpDelete,
241+
DeleteIndex: 0,
242+
}}, false)
243+
require.NoError(t, err)
244+
assert.Equal(t, len(originalInstances)-1, len(list.Instances()))
245+
assert.Equal(t, originalInstances[1:], list.Instances())
246+
247+
// Test error on invalid index
248+
err = list.EditInstances([]ListEdit{{
249+
ListOperation: ListOpDelete,
250+
DeleteIndex: 999,
251+
}}, false)
252+
assert.Error(t, err)
253+
assert.Contains(t, err.Error(), "invalid delete index")
231254
}
232255

233256
func TestOCI1IndexChooseInstanceByCompression(t *testing.T) {

0 commit comments

Comments
 (0)