introduce ignore attribute for watch triggers

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2023-03-20 10:25:23 +01:00 committed by Nicolas De loof
parent 6c1f06e420
commit a11515e038
3 changed files with 225 additions and 84 deletions

View File

@ -123,6 +123,9 @@ jobs:
set: | set: |
*.cache-from=type=gha,scope=test *.cache-from=type=gha,scope=test
*.cache-to=type=gha,scope=test *.cache-to=type=gha,scope=test
-
name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -49,6 +49,7 @@ type Trigger struct {
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
Action string `json:"action,omitempty"` Action string `json:"action,omitempty"`
Target string `json:"target,omitempty"` Target string `json:"target,omitempty"`
Ignore []string `json:"ignore,omitempty"`
} }
const quietPeriod = 2 * time.Second const quietPeriod = 2 * time.Second
@ -58,23 +59,23 @@ const quietPeriod = 2 * time.Second
// For file sync, the container path is also included. // For file sync, the container path is also included.
// For rebuild, there is no container path, so it is always empty. // For rebuild, there is no container path, so it is always empty.
type fileMapping struct { type fileMapping struct {
// service that the file event is for. // Service that the file event is for.
service string Service string
// hostPath that was created/modified/deleted outside the container. // HostPath that was created/modified/deleted outside the container.
// //
// This is the path as seen from the user's perspective, e.g. // This is the path as seen from the user's perspective, e.g.
// - C:\Users\moby\Documents\hello-world\main.go // - C:\Users\moby\Documents\hello-world\main.go
// - /Users/moby/Documents/hello-world/main.go // - /Users/moby/Documents/hello-world/main.go
hostPath string HostPath string
// containerPath for the target file inside the container (only populated // ContainerPath for the target file inside the container (only populated
// for sync events, not rebuild). // for sync events, not rebuild).
// //
// This is the path as used in Docker CLI commands, e.g. // This is the path as used in Docker CLI commands, e.g.
// - /workdir/main.go // - /workdir/main.go
containerPath string ContainerPath string
} }
func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, _ api.WatchOptions) error { //nolint:gocyclo func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, _ api.WatchOptions) error {
needRebuild := make(chan fileMapping) needRebuild := make(chan fileMapping)
needSync := make(chan fileMapping) needSync := make(chan fileMapping)
@ -96,20 +97,26 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
if err != nil { if err != nil {
return err return err
} }
watching := false
for _, service := range ss { for _, service := range ss {
config, err := loadDevelopmentConfig(service, project) config, err := loadDevelopmentConfig(service, project)
if err != nil { if err != nil {
return err return err
} }
name := service.Name if config == nil {
if service.Build == nil { if service.Build == nil {
if len(services) != 0 || len(config.Watch) != 0 {
// watch explicitly requested on service, but no build section set
return fmt.Errorf("service %s doesn't have a build section", name)
}
logrus.Infof("service %s ignored. Can't watch a service without a build section", name)
continue continue
} }
config = &DevelopmentConfig{
Watch: []Trigger{
{
Path: service.Build.Context,
Action: WatchActionRebuild,
},
},
}
}
name := service.Name
bc := service.Build.Context bc := service.Build.Context
dockerIgnores, err := watch.LoadDockerIgnore(bc) dockerIgnores, err := watch.LoadDockerIgnore(bc)
@ -140,10 +147,32 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
if err != nil { if err != nil {
return err return err
} }
watching = true
eg.Go(func() error { eg.Go(func() error {
defer watcher.Close() //nolint:errcheck defer watcher.Close() //nolint:errcheck
WATCH: return s.watch(ctx, name, watcher, config.Watch, needSync, needRebuild)
})
}
if !watching {
return fmt.Errorf("none of the selected services is configured for watch, consider setting an 'x-develop' section")
}
return eg.Wait()
}
func (s *composeService) watch(ctx context.Context, name string, watcher watch.Notify, triggers []Trigger, needSync chan fileMapping, needRebuild chan fileMapping) error {
ignores := make([]watch.PathMatcher, len(triggers))
for i, trigger := range triggers {
ignore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
if err != nil {
return err
}
ignores[i] = ignore
}
WATCH:
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -151,14 +180,25 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
case event := <-watcher.Events(): case event := <-watcher.Events():
hostPath := event.Path() hostPath := event.Path()
for _, trigger := range config.Watch { for i, trigger := range triggers {
logrus.Debugf("change detected on %s - comparing with %s", hostPath, trigger.Path) logrus.Debugf("change detected on %s - comparing with %s", hostPath, trigger.Path)
if watch.IsChild(trigger.Path, hostPath) { if watch.IsChild(trigger.Path, hostPath) {
match, err := ignores[i].Matches(hostPath)
if err != nil {
return err
}
if match {
logrus.Debugf("%s is matching ignore pattern", hostPath)
continue
}
fmt.Fprintf(s.stderr(), "change detected on %s\n", hostPath) fmt.Fprintf(s.stderr(), "change detected on %s\n", hostPath)
f := fileMapping{ f := fileMapping{
hostPath: hostPath, HostPath: hostPath,
service: name, Service: name,
} }
switch trigger.Action { switch trigger.Action {
@ -169,7 +209,7 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
return err return err
} }
// always use Unix-style paths for inside the container // always use Unix-style paths for inside the container
f.containerPath = path.Join(trigger.Target, rel) f.ContainerPath = path.Join(trigger.Target, rel)
needSync <- f needSync <- f
case WatchActionRebuild: case WatchActionRebuild:
logrus.Debugf("modified file %s requires image to be rebuilt", hostPath) logrus.Debugf("modified file %s requires image to be rebuilt", hostPath)
@ -184,18 +224,17 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
return err return err
} }
} }
})
}
return eg.Wait()
} }
func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project) (DevelopmentConfig, error) { func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project) (*DevelopmentConfig, error) {
var config DevelopmentConfig var config DevelopmentConfig
if y, ok := service.Extensions["x-develop"]; ok { y, ok := service.Extensions["x-develop"]
if !ok {
return nil, nil
}
err := mapstructure.Decode(y, &config) err := mapstructure.Decode(y, &config)
if err != nil { if err != nil {
return config, err return nil, err
} }
for i, trigger := range config.Watch { for i, trigger := range config.Watch {
if !filepath.IsAbs(trigger.Path) { if !filepath.IsAbs(trigger.Path) {
@ -203,12 +242,16 @@ func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project)
} }
trigger.Path = filepath.Clean(trigger.Path) trigger.Path = filepath.Clean(trigger.Path)
if trigger.Path == "" { if trigger.Path == "" {
return config, errors.New("watch rules MUST define a path") return nil, errors.New("watch rules MUST define a path")
} }
if trigger.Action == WatchActionRebuild && service.Build == nil {
return nil, fmt.Errorf("service %s doesn't have a build section, can't apply 'rebuild' on watch", service.Name)
}
config.Watch[i] = trigger config.Watch[i] = trigger
} }
} return &config, nil
return config, nil
} }
func (s *composeService) makeRebuildFn(ctx context.Context, project *types.Project) func(services rebuildServices) { func (s *composeService) makeRebuildFn(ctx context.Context, project *types.Project) func(services rebuildServices) {
@ -264,25 +307,25 @@ func (s *composeService) makeSyncFn(ctx context.Context, project *types.Project,
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
case opt := <-needSync: case opt := <-needSync:
if fi, statErr := os.Stat(opt.hostPath); statErr == nil && !fi.IsDir() { if fi, statErr := os.Stat(opt.HostPath); statErr == nil && !fi.IsDir() {
err := s.Copy(ctx, project.Name, api.CopyOptions{ err := s.Copy(ctx, project.Name, api.CopyOptions{
Source: opt.hostPath, Source: opt.HostPath,
Destination: fmt.Sprintf("%s:%s", opt.service, opt.containerPath), Destination: fmt.Sprintf("%s:%s", opt.Service, opt.ContainerPath),
}) })
if err != nil { if err != nil {
return err return err
} }
fmt.Fprintf(s.stderr(), "%s updated\n", opt.containerPath) fmt.Fprintf(s.stderr(), "%s updated\n", opt.ContainerPath)
} else if errors.Is(statErr, fs.ErrNotExist) { } else if errors.Is(statErr, fs.ErrNotExist) {
_, err := s.Exec(ctx, project.Name, api.RunOptions{ _, err := s.Exec(ctx, project.Name, api.RunOptions{
Service: opt.service, Service: opt.Service,
Command: []string{"rm", "-rf", opt.containerPath}, Command: []string{"rm", "-rf", opt.ContainerPath},
Index: 1, Index: 1,
}) })
if err != nil { if err != nil {
logrus.Warnf("failed to delete %q from %s: %v", opt.containerPath, opt.service, err) 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 deleted from container\n", opt.ContainerPath)
} }
} }
} }
@ -306,12 +349,12 @@ func debounce(ctx context.Context, clock clockwork.Clock, delay time.Duration, i
return return
case e := <-input: case e := <-input:
t.Reset(delay) t.Reset(delay)
svc, ok := services[e.service] svc, ok := services[e.Service]
if !ok { if !ok {
svc = make(utils.Set[string]) svc = make(utils.Set[string])
services[e.service] = svc services[e.Service] = svc
} }
svc.Add(e.hostPath) svc.Add(e.HostPath)
} }
} }
} }

View File

@ -17,7 +17,10 @@ package compose
import ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/pkg/watch"
"github.com/jonboulle/clockwork" "github.com/jonboulle/clockwork"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
@ -44,7 +47,7 @@ func Test_debounce(t *testing.T) {
return nil return nil
}) })
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
ch <- fileMapping{service: "test"} ch <- fileMapping{Service: "test"}
} }
assert.Equal(t, ran, 0) assert.Equal(t, ran, 0)
clock.Advance(quietPeriod) clock.Advance(quietPeriod)
@ -53,3 +56,95 @@ func Test_debounce(t *testing.T) {
assert.Equal(t, ran, 1) assert.Equal(t, ran, 1)
assert.DeepEqual(t, got, []string{"test"}) assert.DeepEqual(t, got, []string{"test"})
} }
type testWatcher struct {
events chan watch.FileEvent
errors chan error
}
func (t testWatcher) Start() error {
return nil
}
func (t testWatcher) Close() error {
return nil
}
func (t testWatcher) Events() chan watch.FileEvent {
return t.events
}
func (t testWatcher) Errors() chan error {
return t.errors
}
func Test_sync(t *testing.T) {
needSync := make(chan fileMapping)
needRebuild := make(chan fileMapping)
ctx, cancelFunc := context.WithCancel(context.TODO())
defer cancelFunc()
run := func() watch.Notify {
watcher := testWatcher{
events: make(chan watch.FileEvent, 1),
errors: make(chan error),
}
go func() {
cli, err := command.NewDockerCli()
assert.NilError(t, err)
service := composeService{
dockerCli: cli,
}
err = service.watch(ctx, "test", watcher, []Trigger{
{
Path: "/src",
Action: "sync",
Target: "/work",
Ignore: []string{"ignore"},
},
{
Path: "/",
Action: "rebuild",
},
}, needSync, needRebuild)
assert.NilError(t, err)
}()
return watcher
}
t.Run("synchronize file", func(t *testing.T) {
watcher := run()
watcher.Events() <- watch.NewFileEvent("/src/changed")
select {
case actual := <-needSync:
assert.DeepEqual(t, fileMapping{Service: "test", HostPath: "/src/changed", ContainerPath: "/work/changed"}, actual)
case <-time.After(100 * time.Millisecond):
t.Error("timeout")
}
})
t.Run("ignore", func(t *testing.T) {
watcher := run()
watcher.Events() <- watch.NewFileEvent("/src/ignore")
select {
case <-needSync:
t.Error("file event should have been ignored")
case <-time.After(100 * time.Millisecond):
// expected
}
})
t.Run("rebuild", func(t *testing.T) {
watcher := run()
watcher.Events() <- watch.NewFileEvent("/dependencies.yaml")
select {
case event := <-needRebuild:
assert.Equal(t, "test", event.Service)
case <-time.After(100 * time.Millisecond):
t.Error("timeout")
}
})
}