feat(watch): Add --prune option to docker-compose watch command

Signed-off-by: Suleiman Dibirov <idsulik@gmail.com>
This commit is contained in:
Suleiman Dibirov 2024-06-24 11:08:14 +03:00 committed by Nicolas De loof
parent da434013e3
commit 9549a213ba
4 changed files with 59 additions and 3 deletions

View File

@ -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,
})
}

View File

@ -121,6 +121,7 @@ const WatchLogger = "#watch"
type WatchOptions struct {
Build *BuildOptions
LogTo LogConsumer
Prune bool
}
// BuildOptions group options of the Build API

View File

@ -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)
}
}
}
}

View File

@ -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",