Skip to content

Commit 8318f66

Browse files
authored
Merge pull request #10791 from milas/watch-refactor-sync
watch: move sync logic into separate package
2 parents 9174a99 + cb17c3c commit 8318f66

File tree

4 files changed

+188
-75
lines changed

4 files changed

+188
-75
lines changed

internal/sync/docker_cp.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
Copyright 2023 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
Unless required by applicable law or agreed to in writing, software
9+
distributed under the License is distributed on an "AS IS" BASIS,
10+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
See the License for the specific language governing permissions and
12+
limitations under the License.
13+
*/
14+
15+
package sync
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"io"
22+
"io/fs"
23+
"os"
24+
25+
"github.com/compose-spec/compose-go/types"
26+
"github.com/docker/compose/v2/pkg/api"
27+
"github.com/sirupsen/logrus"
28+
)
29+
30+
type ComposeClient interface {
31+
Exec(ctx context.Context, projectName string, options api.RunOptions) (int, error)
32+
33+
Copy(ctx context.Context, projectName string, options api.CopyOptions) error
34+
}
35+
36+
type DockerCopy struct {
37+
client ComposeClient
38+
39+
projectName string
40+
41+
infoWriter io.Writer
42+
}
43+
44+
var _ Syncer = &DockerCopy{}
45+
46+
func NewDockerCopy(projectName string, client ComposeClient, infoWriter io.Writer) *DockerCopy {
47+
return &DockerCopy{
48+
projectName: projectName,
49+
client: client,
50+
infoWriter: infoWriter,
51+
}
52+
}
53+
54+
func (d *DockerCopy) Sync(ctx context.Context, service types.ServiceConfig, paths []PathMapping) error {
55+
var errs []error
56+
for i := range paths {
57+
if err := d.sync(ctx, service, paths[i]); err != nil {
58+
errs = append(errs, err)
59+
}
60+
}
61+
return errors.Join(errs...)
62+
}
63+
64+
func (d *DockerCopy) sync(ctx context.Context, service types.ServiceConfig, pathMapping PathMapping) error {
65+
scale := 1
66+
if service.Deploy != nil && service.Deploy.Replicas != nil {
67+
scale = int(*service.Deploy.Replicas)
68+
}
69+
70+
if fi, statErr := os.Stat(pathMapping.HostPath); statErr == nil {
71+
if fi.IsDir() {
72+
for i := 1; i <= scale; i++ {
73+
_, err := d.client.Exec(ctx, d.projectName, api.RunOptions{
74+
Service: pathMapping.Service,
75+
Command: []string{"mkdir", "-p", pathMapping.ContainerPath},
76+
Index: i,
77+
})
78+
if err != nil {
79+
logrus.Warnf("failed to create %q from %s: %v", pathMapping.ContainerPath, pathMapping.Service, err)
80+
}
81+
}
82+
fmt.Fprintf(d.infoWriter, "%s created\n", pathMapping.ContainerPath)
83+
} else {
84+
err := d.client.Copy(ctx, d.projectName, api.CopyOptions{
85+
Source: pathMapping.HostPath,
86+
Destination: fmt.Sprintf("%s:%s", pathMapping.Service, pathMapping.ContainerPath),
87+
})
88+
if err != nil {
89+
return err
90+
}
91+
fmt.Fprintf(d.infoWriter, "%s updated\n", pathMapping.ContainerPath)
92+
}
93+
} else if errors.Is(statErr, fs.ErrNotExist) {
94+
for i := 1; i <= scale; i++ {
95+
_, err := d.client.Exec(ctx, d.projectName, api.RunOptions{
96+
Service: pathMapping.Service,
97+
Command: []string{"rm", "-rf", pathMapping.ContainerPath},
98+
Index: i,
99+
})
100+
if err != nil {
101+
logrus.Warnf("failed to delete %q from %s: %v", pathMapping.ContainerPath, pathMapping.Service, err)
102+
}
103+
}
104+
fmt.Fprintf(d.infoWriter, "%s deleted from service\n", pathMapping.ContainerPath)
105+
}
106+
return nil
107+
}

internal/sync/shared.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
Copyright 2023 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
Unless required by applicable law or agreed to in writing, software
9+
distributed under the License is distributed on an "AS IS" BASIS,
10+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
See the License for the specific language governing permissions and
12+
limitations under the License.
13+
*/
14+
15+
package sync
16+
17+
import (
18+
"context"
19+
20+
"github.com/compose-spec/compose-go/types"
21+
)
22+
23+
// PathMapping contains the Compose service and modified host system path.
24+
type PathMapping struct {
25+
// Service that the file event is for.
26+
Service string
27+
// HostPath that was created/modified/deleted outside the container.
28+
//
29+
// This is the path as seen from the user's perspective, e.g.
30+
// - C:\Users\moby\Documents\hello-world\main.go (file on Windows)
31+
// - /Users/moby/Documents/hello-world (directory on macOS)
32+
HostPath string
33+
// ContainerPath for the target file inside the container (only populated
34+
// for sync events, not rebuild).
35+
//
36+
// This is the path as used in Docker CLI commands, e.g.
37+
// - /workdir/main.go
38+
// - /workdir/subdir
39+
ContainerPath string
40+
}
41+
42+
type Syncer interface {
43+
Sync(ctx context.Context, service types.ServiceConfig, paths []PathMapping) error
44+
}

pkg/compose/watch.go

Lines changed: 30 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
2-
32
Copyright 2020 Docker Compose CLI authors
3+
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
66
You may obtain a copy of the License at
@@ -17,13 +17,13 @@ package compose
1717
import (
1818
"context"
1919
"fmt"
20-
"io/fs"
21-
"os"
2220
"path"
2321
"path/filepath"
2422
"strings"
2523
"time"
2624

25+
"github.com/docker/compose/v2/internal/sync"
26+
2727
"github.com/compose-spec/compose-go/types"
2828
"github.com/jonboulle/clockwork"
2929
"github.com/mitchellh/mapstructure"
@@ -54,11 +54,8 @@ type Trigger struct {
5454

5555
const quietPeriod = 2 * time.Second
5656

57-
// fileMapping contains the Compose service and modified host system path.
58-
//
59-
// For file sync, the container path is also included.
60-
// For rebuild, there is no container path, so it is always empty.
61-
type fileMapping struct {
57+
// fileEvent contains the Compose service and modified host system path.
58+
type fileEvent struct {
6259
// Service that the file event is for.
6360
Service string
6461
// HostPath that was created/modified/deleted outside the container.
@@ -67,17 +64,11 @@ type fileMapping struct {
6764
// - C:\Users\moby\Documents\hello-world\main.go
6865
// - /Users/moby/Documents/hello-world/main.go
6966
HostPath string
70-
// ContainerPath for the target file inside the container (only populated
71-
// for sync events, not rebuild).
72-
//
73-
// This is the path as used in Docker CLI commands, e.g.
74-
// - /workdir/main.go
75-
ContainerPath string
7667
}
7768

7869
func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, _ api.WatchOptions) error { //nolint: gocyclo
79-
needRebuild := make(chan fileMapping)
80-
needSync := make(chan fileMapping)
70+
needRebuild := make(chan fileEvent)
71+
needSync := make(chan sync.PathMapping)
8172

8273
_, err := s.prepareProjectForBuild(project, nil)
8374
if err != nil {
@@ -175,7 +166,7 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
175166
return eg.Wait()
176167
}
177168

178-
func (s *composeService) watch(ctx context.Context, name string, watcher watch.Notify, triggers []Trigger, needSync chan fileMapping, needRebuild chan fileMapping) error {
169+
func (s *composeService) watch(ctx context.Context, name string, watcher watch.Notify, triggers []Trigger, needSync chan sync.PathMapping, needRebuild chan fileEvent) error {
179170
ignores := make([]watch.PathMatcher, len(triggers))
180171
for i, trigger := range triggers {
181172
ignore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
@@ -209,24 +200,25 @@ WATCH:
209200

210201
fmt.Fprintf(s.stdinfo(), "change detected on %s\n", hostPath)
211202

212-
f := fileMapping{
213-
HostPath: hostPath,
214-
Service: name,
215-
}
216-
217203
switch trigger.Action {
218204
case WatchActionSync:
219205
logrus.Debugf("modified file %s triggered sync", hostPath)
220206
rel, err := filepath.Rel(trigger.Path, hostPath)
221207
if err != nil {
222208
return err
223209
}
224-
// always use Unix-style paths for inside the container
225-
f.ContainerPath = path.Join(trigger.Target, rel)
226-
needSync <- f
210+
needSync <- sync.PathMapping{
211+
Service: name,
212+
HostPath: hostPath,
213+
// always use Unix-style paths for inside the container
214+
ContainerPath: path.Join(trigger.Target, rel),
215+
}
227216
case WatchActionRebuild:
228217
logrus.Debugf("modified file %s requires image to be rebuilt", hostPath)
229-
needRebuild <- f
218+
needRebuild <- fileEvent{
219+
HostPath: hostPath,
220+
Service: name,
221+
}
230222
default:
231223
return fmt.Errorf("watch action %q is not supported", trigger)
232224
}
@@ -304,57 +296,25 @@ func (s *composeService) makeRebuildFn(ctx context.Context, project *types.Proje
304296
}
305297
}
306298

307-
func (s *composeService) makeSyncFn(ctx context.Context, project *types.Project, needSync <-chan fileMapping) func() error {
299+
func (s *composeService) makeSyncFn(
300+
ctx context.Context,
301+
project *types.Project,
302+
needSync <-chan sync.PathMapping,
303+
) func() error {
304+
syncer := sync.NewDockerCopy(project.Name, s, s.stdinfo())
305+
308306
return func() error {
309307
for {
310308
select {
311309
case <-ctx.Done():
312310
return nil
313-
case opt := <-needSync:
314-
service, err := project.GetService(opt.Service)
311+
case pathMapping := <-needSync:
312+
service, err := project.GetService(pathMapping.Service)
315313
if err != nil {
316314
return err
317315
}
318-
scale := 1
319-
if service.Deploy != nil && service.Deploy.Replicas != nil {
320-
scale = int(*service.Deploy.Replicas)
321-
}
322-
323-
if fi, statErr := os.Stat(opt.HostPath); statErr == nil {
324-
if fi.IsDir() {
325-
for i := 1; i <= scale; i++ {
326-
_, err := s.Exec(ctx, project.Name, api.RunOptions{
327-
Service: opt.Service,
328-
Command: []string{"mkdir", "-p", opt.ContainerPath},
329-
Index: i,
330-
})
331-
if err != nil {
332-
logrus.Warnf("failed to create %q from %s: %v", opt.ContainerPath, opt.Service, err)
333-
}
334-
}
335-
fmt.Fprintf(s.stdinfo(), "%s created\n", opt.ContainerPath)
336-
} else {
337-
err := s.Copy(ctx, project.Name, api.CopyOptions{
338-
Source: opt.HostPath,
339-
Destination: fmt.Sprintf("%s:%s", opt.Service, opt.ContainerPath),
340-
})
341-
if err != nil {
342-
return err
343-
}
344-
fmt.Fprintf(s.stdinfo(), "%s updated\n", opt.ContainerPath)
345-
}
346-
} else if errors.Is(statErr, fs.ErrNotExist) {
347-
for i := 1; i <= scale; i++ {
348-
_, err := s.Exec(ctx, project.Name, api.RunOptions{
349-
Service: opt.Service,
350-
Command: []string{"rm", "-rf", opt.ContainerPath},
351-
Index: i,
352-
})
353-
if err != nil {
354-
logrus.Warnf("failed to delete %q from %s: %v", opt.ContainerPath, opt.Service, err)
355-
}
356-
}
357-
fmt.Fprintf(s.stdinfo(), "%s deleted from service\n", opt.ContainerPath)
316+
if err := syncer.Sync(ctx, service, []sync.PathMapping{pathMapping}); err != nil {
317+
return err
358318
}
359319
}
360320
}
@@ -363,7 +323,7 @@ func (s *composeService) makeSyncFn(ctx context.Context, project *types.Project,
363323

364324
type rebuildServices map[string]utils.Set[string]
365325

366-
func debounce(ctx context.Context, clock clockwork.Clock, delay time.Duration, input <-chan fileMapping, fn func(services rebuildServices)) {
326+
func debounce(ctx context.Context, clock clockwork.Clock, delay time.Duration, input <-chan fileEvent, fn func(services rebuildServices)) {
367327
services := make(rebuildServices)
368328
t := clock.NewTimer(delay)
369329
defer t.Stop()

pkg/compose/watch_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
"testing"
2020
"time"
2121

22+
"github.com/docker/compose/v2/internal/sync"
23+
2224
"github.com/docker/cli/cli/command"
2325
"github.com/docker/compose/v2/pkg/watch"
2426
"github.com/jonboulle/clockwork"
@@ -27,7 +29,7 @@ import (
2729
)
2830

2931
func Test_debounce(t *testing.T) {
30-
ch := make(chan fileMapping)
32+
ch := make(chan fileEvent)
3133
var (
3234
ran int
3335
got []string
@@ -47,7 +49,7 @@ func Test_debounce(t *testing.T) {
4749
return nil
4850
})
4951
for i := 0; i < 100; i++ {
50-
ch <- fileMapping{Service: "test"}
52+
ch <- fileEvent{Service: "test"}
5153
}
5254
assert.Equal(t, ran, 0)
5355
clock.Advance(quietPeriod)
@@ -79,8 +81,8 @@ func (t testWatcher) Errors() chan error {
7981
}
8082

8183
func Test_sync(t *testing.T) {
82-
needSync := make(chan fileMapping)
83-
needRebuild := make(chan fileMapping)
84+
needSync := make(chan sync.PathMapping)
85+
needRebuild := make(chan fileEvent)
8486
ctx, cancelFunc := context.WithCancel(context.TODO())
8587
defer cancelFunc()
8688

@@ -119,7 +121,7 @@ func Test_sync(t *testing.T) {
119121
watcher.Events() <- watch.NewFileEvent("/src/changed")
120122
select {
121123
case actual := <-needSync:
122-
assert.DeepEqual(t, fileMapping{Service: "test", HostPath: "/src/changed", ContainerPath: "/work/changed"}, actual)
124+
assert.DeepEqual(t, sync.PathMapping{Service: "test", HostPath: "/src/changed", ContainerPath: "/work/changed"}, actual)
123125
case <-time.After(100 * time.Millisecond):
124126
t.Error("timeout")
125127
}

0 commit comments

Comments
 (0)