From 9549a213ba6b892dfb1b7a7bf8a595207b714d8b Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Mon, 24 Jun 2024 11:08:14 +0300 Subject: [PATCH] feat(watch): Add --prune option to docker-compose watch command Signed-off-by: Suleiman Dibirov --- cmd/compose/watch.go | 5 ++++- pkg/api/api.go | 1 + pkg/compose/watch.go | 39 +++++++++++++++++++++++++++++++++++++-- pkg/compose/watch_test.go | 17 +++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/cmd/compose/watch.go b/cmd/compose/watch.go index 897da728d3..2a0335850b 100644 --- a/cmd/compose/watch.go +++ b/cmd/compose/watch.go @@ -32,7 +32,8 @@ import ( type watchOptions struct { *ProjectOptions - noUp bool + prune bool + noUp bool } func watchCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command { @@ -58,6 +59,7 @@ func watchCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) } cmd.Flags().BoolVar(&buildOpts.quiet, "quiet", false, "hide build output") + cmd.Flags().BoolVar(&watchOpts.prune, "prune", false, "Prune dangling images on rebuild") cmd.Flags().BoolVar(&watchOpts.noUp, "no-up", false, "Do not build & start services before watching") return cmd } @@ -118,5 +120,6 @@ func runWatch(ctx context.Context, dockerCli command.Cli, backend api.Service, w return backend.Watch(ctx, project, services, api.WatchOptions{ Build: &build, LogTo: consumer, + Prune: watchOpts.prune, }) } diff --git a/pkg/api/api.go b/pkg/api/api.go index 31095ac86a..51f753fb98 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -121,6 +121,7 @@ const WatchLogger = "#watch" type WatchOptions struct { Build *BuildOptions LogTo LogConsumer + Prune bool } // BuildOptions group options of the Build API diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index 36c260aa81..8e1e118a17 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -34,6 +34,8 @@ import ( "github.com/docker/compose/v2/pkg/watch" moby "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" "github.com/jonboulle/clockwork" "github.com/mitchellh/mapstructure" "github.com/sirupsen/logrus" @@ -175,7 +177,11 @@ func (s *composeService) watch(ctx context.Context, syncChannel chan bool, proje } watching = true eg.Go(func() error { - defer watcher.Close() //nolint:errcheck + defer func() { + if err := watcher.Close(); err != nil { + logrus.Debugf("Error closing watcher for service %s: %v", service.Name, err) + } + }() return s.watchEvents(ctx, project, service.Name, options, watcher, syncer, config.Watch) }) } @@ -471,11 +477,17 @@ func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Pr options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Rebuilding service %q after changes were detected...", serviceName)) // restrict the build to ONLY this service, not any of its dependencies options.Build.Services = []string{serviceName} - _, err := s.build(ctx, project, *options.Build, nil) + imageNameToIdMap, err := s.build(ctx, project, *options.Build, nil) + if err != nil { options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Build failed. Error: %v", err)) return err } + + if options.Prune { + s.pruneDanglingImagesOnRebuild(ctx, project.Name, imageNameToIdMap) + } + options.LogTo.Log(api.WatchLogger, fmt.Sprintf("service %q successfully built", serviceName)) err = s.create(ctx, project, api.CreateOptions{ @@ -539,3 +551,26 @@ func writeWatchSyncMessage(log api.LogConsumer, serviceName string, pathMappings log.Log(api.WatchLogger, fmt.Sprintf("Syncing service %q after %d changes were detected", serviceName, len(pathMappings))) } } + +func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, projectName string, imageNameToIdMap map[string]string) { + images, err := s.apiClient().ImageList(ctx, image.ListOptions{ + Filters: filters.NewArgs( + filters.Arg("dangling", "true"), + filters.Arg("label", api.ProjectLabel+"="+projectName), + ), + }) + + if err != nil { + logrus.Debugf("Failed to list images: %v", err) + return + } + + for _, img := range images { + if _, ok := imageNameToIdMap[img.ID]; !ok { + _, err := s.apiClient().ImageRemove(ctx, img.ID, image.RemoveOptions{}) + 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 950e1623bf..40a303998d 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -28,6 +28,8 @@ import ( "github.com/docker/compose/v2/pkg/mocks" "github.com/docker/compose/v2/pkg/watch" moby "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" "github.com/jonboulle/clockwork" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -120,12 +122,26 @@ func TestWatch_Sync(t *testing.T) { apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return([]moby.Container{ testContainer("test", "123", false), }, nil).AnyTimes() + // we expect the image to be pruned + apiClient.EXPECT().ImageList(gomock.Any(), image.ListOptions{ + Filters: filters.NewArgs( + filters.Arg("dangling", "true"), + filters.Arg("label", api.ProjectLabel+"=myProjectName"), + ), + }).Return([]image.Summary{ + {ID: "123"}, + {ID: "456"}, + }, nil).Times(1) + apiClient.EXPECT().ImageRemove(gomock.Any(), "123", image.RemoveOptions{}).Times(1) + apiClient.EXPECT().ImageRemove(gomock.Any(), "456", image.RemoveOptions{}).Times(1) + // cli.EXPECT().Client().Return(apiClient).AnyTimes() ctx, cancelFunc := context.WithCancel(context.Background()) t.Cleanup(cancelFunc) proj := types.Project{ + Name: "myProjectName", Services: types.Services{ "test": { Name: "test", @@ -148,6 +164,7 @@ func TestWatch_Sync(t *testing.T) { err := service.watchEvents(ctx, &proj, "test", api.WatchOptions{ Build: &api.BuildOptions{}, LogTo: stdLogger{}, + Prune: true, }, watcher, syncer, []types.Trigger{ { Path: "/sync",