fix(down): remove dangling images left behind by local builds · docker/compose@caeabae · GitHub
Skip to content

Commit caeabae

Browse files
committed
fix(down): remove dangling images left behind by local builds
Signed-off-by: hiroto.toyoda <hiroto.toyoda@dena.com>
1 parent 727fa0e commit caeabae

4 files changed

Lines changed: 176 additions & 6 deletions

File tree

‎pkg/compose/down.go‎

Lines changed: 28 additions & 0 deletions

‎pkg/compose/down_test.go‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,10 @@ func TestDownRemoveImages(t *testing.T) {
323323
},
324324
}}, nil).AnyTimes()
325325

326+
api.EXPECT().ImageList(gomock.Any(), client.ImageListOptions{
327+
Filters: projectFilter(strings.ToLower(testProject)).Add("dangling", "true"),
328+
}).Return(client.ImageListResult{}, nil).AnyTimes()
329+
326330
imagesToBeInspected := map[string]bool{
327331
"testproject-local-anonymous": true,
328332
"local-named-image": true,
@@ -413,6 +417,10 @@ func TestDownRemoveImages_NoLabel(t *testing.T) {
413417
Filters: projectFilter(strings.ToLower(testProject)).Add("dangling", "false"),
414418
}).Return(client.ImageListResult{}, nil)
415419

420+
api.EXPECT().ImageList(gomock.Any(), client.ImageListOptions{
421+
Filters: projectFilter(strings.ToLower(testProject)).Add("dangling", "true"),
422+
}).Return(client.ImageListResult{}, nil)
423+
416424
api.EXPECT().ImageInspect(gomock.Any(), "testproject-service1", gomock.Any()).Return(client.ImageInspectResult{}, nil)
417425
api.EXPECT().ContainerStop(gomock.Any(), "123", client.ContainerStopOptions{}).Return(client.ContainerStopResult{}, nil)
418426
api.EXPECT().ContainerRemove(gomock.Any(), "123", client.ContainerRemoveOptions{Force: true}).Return(client.ContainerRemoveResult{}, nil)
@@ -452,6 +460,66 @@ func prepareMocks(mockCtrl *gomock.Controller) (*mocks.MockAPIClient, *mocks.Moc
452460
return api, cli
453461
}
454462

463+
// TestPruneDanglingProjectImages_ReportsOnceAndToleratesFailures guards that
464+
// dangling images are reported as a single grouped event regardless of
465+
// count, and that one failed removal doesn't stop the others or the caller.
466+
func TestPruneDanglingProjectImages_ReportsOnceAndToleratesFailures(t *testing.T) {
467+
mockCtrl := gomock.NewController(t)
468+
defer mockCtrl.Finish()
469+
470+
apiClient, cli := prepareMocks(mockCtrl)
471+
rec := &capturingEvents{}
472+
svcIface, err := NewComposeService(cli, WithEventProcessor(rec))
473+
assert.NilError(t, err)
474+
svc := svcIface.(*composeService)
475+
476+
pruner := NewImagePruner(apiClient, &types.Project{Name: "prj"})
477+
apiClient.EXPECT().ImageList(gomock.Any(), client.ImageListOptions{
478+
Filters: projectFilter("prj").Add("dangling", "true"),
479+
}).Return(client.ImageListResult{Items: []image.Summary{
480+
{ID: "sha256:aaa"},
481+
{ID: "sha256:bbb"},
482+
}}, nil)
483+
apiClient.EXPECT().ImageRemove(gomock.Any(), "sha256:aaa", client.ImageRemoveOptions{}).
484+
Return(client.ImageRemoveResult{}, nil)
485+
apiClient.EXPECT().ImageRemove(gomock.Any(), "sha256:bbb", client.ImageRemoveOptions{}).
486+
Return(client.ImageRemoveResult{}, errdefs.ErrNotFound.WithMessage("already removed"))
487+
488+
err = svc.pruneDanglingProjectImages(t.Context(), pruner)
489+
assert.NilError(t, err)
490+
491+
events := make([]string, len(rec.resources))
492+
for i, e := range rec.resources {
493+
events[i] = e.ID + ": " + e.Text
494+
}
495+
assert.DeepEqual(t, events, []string{
496+
"Dangling images: Removing",
497+
"Dangling images: Removed",
498+
})
499+
}
500+
501+
// TestPruneDanglingProjectImages_NoneFound guards that no event is reported
502+
// when there's nothing to prune.
503+
func TestPruneDanglingProjectImages_NoneFound(t *testing.T) {
504+
mockCtrl := gomock.NewController(t)
505+
defer mockCtrl.Finish()
506+
507+
apiClient, cli := prepareMocks(mockCtrl)
508+
rec := &capturingEvents{}
509+
svcIface, err := NewComposeService(cli, WithEventProcessor(rec))
510+
assert.NilError(t, err)
511+
svc := svcIface.(*composeService)
512+
513+
pruner := NewImagePruner(apiClient, &types.Project{Name: "prj"})
514+
apiClient.EXPECT().ImageList(gomock.Any(), client.ImageListOptions{
515+
Filters: projectFilter("prj").Add("dangling", "true"),
516+
}).Return(client.ImageListResult{}, nil)
517+
518+
err = svc.pruneDanglingProjectImages(t.Context(), pruner)
519+
assert.NilError(t, err)
520+
assert.Equal(t, len(rec.resources), 0)
521+
}
522+
455523
// TestDownRemovesRetainedPreStartHookContainers verifies that compose down finds and
456524
// removes pre_start hook containers that were retained after a failed hook run.
457525
// These containers lack ConfigHashLabel so the normal getContainers path never sees them.

‎pkg/compose/image_pruner.go‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,6 @@ func (p *ImagePruner) namedImages(ctx context.Context) ([]string, error) {
150150
// created from the project + service name.
151151
func (p *ImagePruner) labeledLocalImages(ctx context.Context) ([]image.Summary, error) {
152152
res, err := p.client.ImageList(ctx, client.ImageListOptions{
153-
// TODO(milas): we should really clean up the dangling images as
154-
// well (historically we have NOT); need to refactor this to handle
155-
// it gracefully without producing confusing CLI output, i.e. we
156-
// do not want to print out a bunch of untagged/dangling image IDs,
157-
// they should be grouped into a logical operation for the relevant
158-
// service
159153
Filters: projectFilter(p.project.Name).Add("dangling", "false"),
160154
})
161155
if err != nil {
@@ -164,6 +158,24 @@ func (p *ImagePruner) labeledLocalImages(ctx context.Context) ([]image.Summary,
164158
return res.Items, nil
165159
}
166160

161+
// danglingLocalImages are untagged images left behind by earlier local
162+
// builds of the project (e.g. superseded by a rebuild). They carry no name
163+
// usable in CLI progress output, so callers should report their removal as
164+
// a single grouped operation rather than one row per image ID.
165+
func (p *ImagePruner) danglingLocalImages(ctx context.Context) ([]string, error) {
166+
res, err := p.client.ImageList(ctx, client.ImageListOptions{
167+
Filters: projectFilter(p.project.Name).Add("dangling", "true"),
168+
})
169+
if err != nil {
170+
return nil, err
171+
}
172+
ids := make([]string, len(res.Items))
173+
for i, img := range res.Items {
174+
ids[i] = img.ID
175+
}
176+
return ids, nil
177+
}
178+
167179
// unlabeledLocalImages are images that match the implicit naming convention
168180
// for locally-built images but did not get labeled, presumably because they
169181
// were produced by an older version of Compose.

‎pkg/compose/image_pruner_test.go‎

Lines changed: 62 additions & 0 deletions

0 commit comments

Comments
 (0)