|
|
@@ -17,6 +17,9 @@ package compose
|
|
|
import (
|
|
|
"context"
|
|
|
"fmt"
|
|
|
+ "io/fs"
|
|
|
+ "os"
|
|
|
+ "path"
|
|
|
"path/filepath"
|
|
|
"strings"
|
|
|
"time"
|
|
|
@@ -50,9 +53,30 @@ type Trigger struct {
|
|
|
|
|
|
const quietPeriod = 2 * time.Second
|
|
|
|
|
|
-func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error { //nolint:gocyclo
|
|
|
- needRebuild := make(chan string)
|
|
|
- needSync := make(chan api.CopyOptions, 5)
|
|
|
+// fileMapping contains the Compose service and modified host system path.
|
|
|
+//
|
|
|
+// For file sync, the container path is also included.
|
|
|
+// For rebuild, there is no container path, so it is always empty.
|
|
|
+type fileMapping struct {
|
|
|
+ // service that the file event is for.
|
|
|
+ service string
|
|
|
+ // hostPath that was created/modified/deleted outside the container.
|
|
|
+ //
|
|
|
+ // This is the path as seen from the user's perspective, e.g.
|
|
|
+ // - C:\Users\moby\Documents\hello-world\main.go
|
|
|
+ // - /Users/moby/Documents/hello-world/main.go
|
|
|
+ hostPath string
|
|
|
+ // containerPath for the target file inside the container (only populated
|
|
|
+ // for sync events, not rebuild).
|
|
|
+ //
|
|
|
+ // This is the path as used in Docker CLI commands, e.g.
|
|
|
+ // - /workdir/main.go
|
|
|
+ containerPath string
|
|
|
+}
|
|
|
+
|
|
|
+func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, _ api.WatchOptions) error { //nolint:gocyclo
|
|
|
+ needRebuild := make(chan fileMapping)
|
|
|
+ needSync := make(chan fileMapping)
|
|
|
|
|
|
eg, ctx := errgroup.WithContext(ctx)
|
|
|
eg.Go(func() error {
|
|
|
@@ -120,38 +144,37 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
|
|
|
case <-ctx.Done():
|
|
|
return nil
|
|
|
case event := <-watcher.Events():
|
|
|
- path := event.Path()
|
|
|
+ hostPath := event.Path()
|
|
|
|
|
|
for _, trigger := range config.Watch {
|
|
|
- logrus.Debugf("change detected on %s - comparing with %s", path, trigger.Path)
|
|
|
- if watch.IsChild(trigger.Path, path) {
|
|
|
- fmt.Fprintf(s.stderr(), "change detected on %s\n", path)
|
|
|
+ logrus.Debugf("change detected on %s - comparing with %s", hostPath, trigger.Path)
|
|
|
+ if watch.IsChild(trigger.Path, hostPath) {
|
|
|
+ fmt.Fprintf(s.stderr(), "change detected on %s\n", hostPath)
|
|
|
+
|
|
|
+ f := fileMapping{
|
|
|
+ hostPath: hostPath,
|
|
|
+ service: name,
|
|
|
+ }
|
|
|
|
|
|
switch trigger.Action {
|
|
|
case WatchActionSync:
|
|
|
- logrus.Debugf("modified file %s triggered sync", path)
|
|
|
- rel, err := filepath.Rel(trigger.Path, path)
|
|
|
+ logrus.Debugf("modified file %s triggered sync", hostPath)
|
|
|
+ rel, err := filepath.Rel(trigger.Path, hostPath)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
- dest := filepath.Join(trigger.Target, rel)
|
|
|
- needSync <- api.CopyOptions{
|
|
|
- Source: path,
|
|
|
- Destination: fmt.Sprintf("%s:%s", name, dest),
|
|
|
- }
|
|
|
+ // always use Unix-style paths for inside the container
|
|
|
+ f.containerPath = path.Join(trigger.Target, rel)
|
|
|
+ needSync <- f
|
|
|
case WatchActionRebuild:
|
|
|
- logrus.Debugf("modified file %s requires image to be rebuilt", path)
|
|
|
- needRebuild <- name
|
|
|
+ logrus.Debugf("modified file %s requires image to be rebuilt", hostPath)
|
|
|
+ needRebuild <- f
|
|
|
default:
|
|
|
return fmt.Errorf("watch action %q is not supported", trigger)
|
|
|
}
|
|
|
continue WATCH
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // default
|
|
|
- needRebuild <- name
|
|
|
-
|
|
|
case err := <-watcher.Errors():
|
|
|
return err
|
|
|
}
|
|
|
@@ -183,11 +206,25 @@ func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project)
|
|
|
return config, nil
|
|
|
}
|
|
|
|
|
|
-func (s *composeService) makeRebuildFn(ctx context.Context, project *types.Project) func(services []string) {
|
|
|
- return func(services []string) {
|
|
|
- fmt.Fprintf(s.stderr(), "Updating %s after changes were detected\n", strings.Join(services, ", "))
|
|
|
+func (s *composeService) makeRebuildFn(ctx context.Context, project *types.Project) func(services rebuildServices) {
|
|
|
+ return func(services rebuildServices) {
|
|
|
+ serviceNames := make([]string, 0, len(services))
|
|
|
+ allPaths := make(utils.Set[string])
|
|
|
+ for serviceName, paths := range services {
|
|
|
+ serviceNames = append(serviceNames, serviceName)
|
|
|
+ for p := range paths {
|
|
|
+ allPaths.Add(p)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fmt.Fprintf(
|
|
|
+ s.stderr(),
|
|
|
+ "Rebuilding %s after changes were detected:%s\n",
|
|
|
+ strings.Join(serviceNames, ", "),
|
|
|
+ strings.Join(append([]string{""}, allPaths.Elements()...), "\n - "),
|
|
|
+ )
|
|
|
imageIds, err := s.build(ctx, project, api.BuildOptions{
|
|
|
- Services: services,
|
|
|
+ Services: serviceNames,
|
|
|
})
|
|
|
if err != nil {
|
|
|
fmt.Fprintf(s.stderr(), "Build failed\n")
|
|
|
@@ -201,11 +238,11 @@ func (s *composeService) makeRebuildFn(ctx context.Context, project *types.Proje
|
|
|
|
|
|
err = s.Up(ctx, project, api.UpOptions{
|
|
|
Create: api.CreateOptions{
|
|
|
- Services: services,
|
|
|
+ Services: serviceNames,
|
|
|
Inherit: true,
|
|
|
},
|
|
|
Start: api.StartOptions{
|
|
|
- Services: services,
|
|
|
+ Services: serviceNames,
|
|
|
Project: project,
|
|
|
},
|
|
|
})
|
|
|
@@ -215,39 +252,61 @@ func (s *composeService) makeRebuildFn(ctx context.Context, project *types.Proje
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func (s *composeService) makeSyncFn(ctx context.Context, project *types.Project, needSync chan api.CopyOptions) func() error {
|
|
|
+func (s *composeService) makeSyncFn(ctx context.Context, project *types.Project, needSync <-chan fileMapping) func() error {
|
|
|
return func() error {
|
|
|
for {
|
|
|
select {
|
|
|
case <-ctx.Done():
|
|
|
return nil
|
|
|
case opt := <-needSync:
|
|
|
- err := s.Copy(ctx, project.Name, opt)
|
|
|
- if err != nil {
|
|
|
- return err
|
|
|
+ if fi, statErr := os.Stat(opt.hostPath); statErr == nil && !fi.IsDir() {
|
|
|
+ err := s.Copy(ctx, project.Name, api.CopyOptions{
|
|
|
+ Source: opt.hostPath,
|
|
|
+ Destination: fmt.Sprintf("%s:%s", opt.service, opt.containerPath),
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ fmt.Fprintf(s.stderr(), "%s updated\n", opt.containerPath)
|
|
|
+ } else if errors.Is(statErr, fs.ErrNotExist) {
|
|
|
+ _, err := s.Exec(ctx, project.Name, api.RunOptions{
|
|
|
+ Service: opt.service,
|
|
|
+ Command: []string{"rm", "-rf", opt.containerPath},
|
|
|
+ Index: 1,
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ logrus.Warnf("failed to delete %q from %s: %v", opt.containerPath, opt.service, err)
|
|
|
+ }
|
|
|
+ fmt.Fprintf(s.stderr(), "%s deleted from container\n", opt.containerPath)
|
|
|
}
|
|
|
- fmt.Fprintf(s.stderr(), "%s updated\n", opt.Destination)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func debounce(ctx context.Context, clock clockwork.Clock, delay time.Duration, input chan string, fn func(services []string)) {
|
|
|
- services := utils.Set[string]{}
|
|
|
+type rebuildServices map[string]utils.Set[string]
|
|
|
+
|
|
|
+func debounce(ctx context.Context, clock clockwork.Clock, delay time.Duration, input <-chan fileMapping, fn func(services rebuildServices)) {
|
|
|
+ services := make(rebuildServices)
|
|
|
t := clock.AfterFunc(delay, func() {
|
|
|
if len(services) > 0 {
|
|
|
- refresh := services.Elements()
|
|
|
- services.Clear()
|
|
|
- fn(refresh)
|
|
|
+ fn(services)
|
|
|
+ // TODO(milas): this is a data race!
|
|
|
+ services = make(rebuildServices)
|
|
|
}
|
|
|
})
|
|
|
for {
|
|
|
select {
|
|
|
case <-ctx.Done():
|
|
|
return
|
|
|
- case service := <-input:
|
|
|
+ case e := <-input:
|
|
|
t.Reset(delay)
|
|
|
- services.Add(service)
|
|
|
+ svc, ok := services[e.service]
|
|
|
+ if !ok {
|
|
|
+ svc = make(utils.Set[string])
|
|
|
+ services[e.service] = svc
|
|
|
+ }
|
|
|
+ svc.Add(e.hostPath)
|
|
|
}
|
|
|
}
|
|
|
}
|