diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index c485267c0c..ed5e84a543 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -726,8 +726,14 @@ func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, proje return } + // imageNameToIdMap is keyed by image name; the freshly built images to + // spare are its VALUES (image IDs), matched against the dangling IDs + builtIDs := make(map[string]struct{}, len(imageNameToIdMap)) + for _, id := range imageNameToIdMap { + builtIDs[id] = struct{}{} + } for _, img := range images.Items { - if _, ok := imageNameToIdMap[img.ID]; !ok { + if _, ok := builtIDs[img.ID]; !ok { _, err := s.apiClient().ImageRemove(ctx, img.ID, client.ImageRemoveOptions{}) if err != nil { logrus.Debugf("Failed to remove image %s: %v", img.ID, err) diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 0c59b884ba..913d302b78 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -194,3 +194,29 @@ func (f *fakeSyncer) Sync(ctx context.Context, service string, paths []*sync.Pat f.synced <- paths return nil } + +// TestPruneDanglingImagesOnRebuild verifies the post-rebuild prune only +// removes superseded dangling images: a dangling image whose ID matches one +// of the freshly built images must be spared. The lookup used to probe the +// name-keyed map with an image ID, matching nothing — every dangling image +// of the project was removed on each rebuild. +func TestPruneDanglingImagesOnRebuild(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + apiMock, cli := prepareMocks(mockCtrl) + tested, err := NewComposeService(cli) + assert.NilError(t, err) + + apiMock.EXPECT().ImageList(gomock.Any(), gomock.Any()). + Return(client.ImageListResult{Items: []image.Summary{ + {ID: "sha256:justbuilt"}, + {ID: "sha256:superseded"}, + }}, nil) + // only the superseded image may be removed; removing the just-built one + // would be an unexpected call and fail the test + apiMock.EXPECT().ImageRemove(gomock.Any(), "sha256:superseded", gomock.Any()). + Return(client.ImageRemoveResult{}, nil) + + tested.(*composeService).pruneDanglingImagesOnRebuild(t.Context(), "proj", + map[string]string{"app-image:latest": "sha256:justbuilt"}) +}