Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cmd/compose/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
})
}
1 change: 1 addition & 0 deletions docs/reference/compose_watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Watch build context for service and rebuild/refresh containers when files are up
|:------------|:-----|:--------|:----------------------------------------------|
| `--dry-run` | | | Execute command in dry run mode |
| `--no-up` | | | Do not build & start services before watching |
| `--prune` | | | Prune dangling images on rebuild |
| `--quiet` | | | hide build output |


Expand Down
10 changes: 10 additions & 0 deletions docs/reference/docker_compose_watch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ options:
experimentalcli: false
kubernetes: false
swarm: false
- option: prune
value_type: bool
default_value: "false"
description: Prune dangling images on rebuild
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: quiet
value_type: bool
default_value: "false"
Expand Down
1 change: 1 addition & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const WatchLogger = "#watch"
type WatchOptions struct {
Build *BuildOptions
LogTo LogConsumer
Prune bool
}

// BuildOptions group options of the Build API
Expand Down
39 changes: 37 additions & 2 deletions pkg/compose/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
}
}
}
17 changes: 17 additions & 0 deletions pkg/compose/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading