diff --git a/loader/loader_test.go b/loader/loader_test.go index c0c75656..6dee4943 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -3051,7 +3051,8 @@ services: } func TestLoadDevelopConfig(t *testing.T) { - project, err := LoadWithContext(context.TODO(), buildConfigDetails(` + t.Run("successfully load watch config", func(t *testing.T) { + project, err := LoadWithContext(context.Background(), buildConfigDetails(` name: load-develop services: frontend: @@ -3066,15 +3067,16 @@ services: target: /var/www ignore: - node_modules/ - backend: image: example/backend build: ./backend develop: watch: # rebuild image and recreate service - - path: ./backend/src - action: rebuild + - action: rebuild + path: + - ./backend/src + - ./backend proxy: image: example/proxy build: ./proxy @@ -3085,50 +3087,54 @@ services: action: sync+restart target: /etc/nginx/proxy.conf `, nil), func(options *Options) { - options.ResolvePaths = false - options.SkipValidation = true - }) - assert.NilError(t, err) - frontend, err := project.GetService("frontend") - assert.NilError(t, err) - assert.DeepEqual(t, *frontend.Develop, types.DevelopConfig{ - Watch: []types.Trigger{ - { - Path: "./webapp/html", - Action: types.WatchActionSync, - Target: "/var/www", - Ignore: []string{"node_modules/"}, - Extensions: types.Extensions{ - "x-initialSync": true, + options.ResolvePaths = false + options.SkipValidation = true + }) + assert.NilError(t, err) + frontend, err := project.GetService("frontend") + assert.NilError(t, err) + assert.DeepEqual(t, *frontend.Develop, types.DevelopConfig{ + Watch: []types.Trigger{ + { + Path: "./webapp/html", + Action: types.WatchActionSync, + Target: "/var/www", + Ignore: []string{"node_modules/"}, + Extensions: types.Extensions{ + "x-initialSync": true, + }, }, }, - }, - }) - backend, err := project.GetService("backend") - assert.NilError(t, err) - assert.DeepEqual(t, *backend.Develop, types.DevelopConfig{ - Watch: []types.Trigger{ - { - Path: "./backend/src", - Action: types.WatchActionRebuild, + }) + backend, err := project.GetService("backend") + assert.NilError(t, err) + assert.DeepEqual(t, *backend.Develop, types.DevelopConfig{ + Watch: []types.Trigger{ + { + Path: "./backend/src", + Action: types.WatchActionRebuild, + }, + { + Path: "./backend", + Action: types.WatchActionRebuild, + }, }, - }, - }) - proxy, err := project.GetService("proxy") - assert.NilError(t, err) - assert.DeepEqual(t, *proxy.Develop, types.DevelopConfig{ - Watch: []types.Trigger{ - { - Path: "./proxy/proxy.conf", - Action: types.WatchActionSyncRestart, - Target: "/etc/nginx/proxy.conf", + }) + proxy, err := project.GetService("proxy") + assert.NilError(t, err) + assert.DeepEqual(t, *proxy.Develop, types.DevelopConfig{ + Watch: []types.Trigger{ + { + Path: "./proxy/proxy.conf", + Action: types.WatchActionSyncRestart, + Target: "/etc/nginx/proxy.conf", + }, }, - }, + }) }) -} -func TestBadDevelopConfig(t *testing.T) { - _, err := LoadWithContext(context.TODO(), buildConfigDetails(` + t.Run("should not load successfully bad watch config", func(t *testing.T) { + _, err := LoadWithContext(context.TODO(), buildConfigDetails(` name: load-develop services: frontend: @@ -3136,16 +3142,16 @@ services: build: ./webapp develop: watch: - # sync static content + # sync static content - path: ./webapp/html target: /var/www ignore: - node_modules/ - `, nil), func(options *Options) { - options.ResolvePaths = false + options.ResolvePaths = false + }) + assert.ErrorContains(t, err, "validating filename0.yml: services.frontend.develop.watch.0 action is required") }) - assert.ErrorContains(t, err, "validating filename0.yml: services.frontend.develop.watch.0 action is required") } func TestBadServiceConfig(t *testing.T) { diff --git a/loader/testdata/watch/compose-test-watch-star.yaml b/loader/testdata/watch/compose-test-watch-star.yaml new file mode 100644 index 00000000..7cfcc3cb --- /dev/null +++ b/loader/testdata/watch/compose-test-watch-star.yaml @@ -0,0 +1,11 @@ +name: compose-test-watch-star +services: + app: + image: example/app + develop: + watch: + - path: ./watch/*.txt + action: rebuild + # - path: ./watch/* + # target: ./app + # action: sync diff --git a/loader/testdata/watch/other.txt b/loader/testdata/watch/other.txt new file mode 100644 index 00000000..e69de29b diff --git a/loader/testdata/watch/some-text.txt b/loader/testdata/watch/some-text.txt new file mode 100644 index 00000000..e69de29b diff --git a/loader/validate.go b/loader/validate.go index aa570888..b528cb8c 100644 --- a/loader/validate.go +++ b/loader/validate.go @@ -167,8 +167,11 @@ func checkConsistency(project *types.Project) error { //nolint:gocyclo if s.Develop != nil && s.Develop.Watch != nil { for _, watch := range s.Develop.Watch { - if watch.Target == "" && watch.Action != types.WatchActionRebuild && watch.Action != types.WatchActionRestart { - return fmt.Errorf("services.%s.develop.watch: target is required for non-rebuild actions: %w", s.Name, errdefs.ErrInvalid) + if watch.Action != types.WatchActionRebuild && watch.Action != types.WatchActionRestart { + if watch.Target == "" { + return fmt.Errorf("services.%s.develop.watch: target is required for %s, %s and %s actions: %w", + s.Name, types.WatchActionSync, types.WatchActionSyncExec, types.WatchActionSyncRestart, errdefs.ErrInvalid) + } } } } diff --git a/loader/validate_test.go b/loader/validate_test.go index 5c1aa958..020a8fdd 100644 --- a/loader/validate_test.go +++ b/loader/validate_test.go @@ -17,6 +17,7 @@ package loader import ( + "fmt" "strings" "testing" @@ -303,69 +304,6 @@ func TestValidateWatch(t *testing.T) { assert.NilError(t, err) }) - t.Run("watch missing target for sync action", func(t *testing.T) { - project := types.Project{ - Services: types.Services{ - "myservice": { - Name: "myservice", - Image: "scratch", - Develop: &types.DevelopConfig{ - Watch: []types.Trigger{ - { - Action: types.WatchActionSync, - Path: "/app", - }, - }, - }, - }, - }, - } - err := checkConsistency(&project) - assert.Error(t, err, "services.myservice.develop.watch: target is required for non-rebuild actions: invalid compose project") - }) - - t.Run("watch missing target for sync+restart action", func(t *testing.T) { - project := types.Project{ - Services: types.Services{ - "myservice": { - Name: "myservice", - Image: "scratch", - Develop: &types.DevelopConfig{ - Watch: []types.Trigger{ - { - Action: types.WatchActionSyncRestart, - Path: "/app", - }, - }, - }, - }, - }, - } - err := checkConsistency(&project) - assert.Error(t, err, "services.myservice.develop.watch: target is required for non-rebuild actions: invalid compose project") - }) - - t.Run("watch config valid with missing target for rebuild action", func(t *testing.T) { - project := types.Project{ - Services: types.Services{ - "myservice": { - Name: "myservice", - Image: "scratch", - Develop: &types.DevelopConfig{ - Watch: []types.Trigger{ - { - Action: types.WatchActionRebuild, - Path: "/app", - }, - }, - }, - }, - }, - } - err := checkConsistency(&project) - assert.NilError(t, err) - }) - t.Run("depends on disabled service", func(t *testing.T) { project := types.Project{ Services: types.Services{ @@ -406,4 +344,87 @@ func TestValidateWatch(t *testing.T) { err := checkConsistency(&project) assert.ErrorContains(t, err, "depends on undefined service") }) + + type WatchActionTest struct { + action types.WatchAction + } + tests := []WatchActionTest{ + {action: types.WatchActionSync}, + {action: types.WatchActionSyncRestart}, + {action: types.WatchActionSyncExec}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("watch config is INVALID when missing target for %s action", tt.action), func(t *testing.T) { + project := types.Project{ + Services: types.Services{ + "myservice": { + Name: "myservice", + Image: "scratch", + Develop: &types.DevelopConfig{ + Watch: []types.Trigger{ + { + Action: tt.action, + Path: "/app", + // Missing Target + }, + }, + }, + }, + }, + } + err := checkConsistency(&project) + assert.Error(t, err, "services.myservice.develop.watch: target is required for sync, sync+exec and sync+restart actions: invalid compose project") + }) + } + tests = []WatchActionTest{ + {action: types.WatchActionRebuild}, + {action: types.WatchActionRestart}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("watch config is VALID with missing target for %s action", tt.action), func(t *testing.T) { + project := types.Project{ + Services: types.Services{ + "myservice": { + Name: "myservice", + Image: "scratch", + Develop: &types.DevelopConfig{ + Watch: []types.Trigger{ + { + Action: tt.action, + Path: "/app", + }, + }, + }, + }, + }, + } + err := checkConsistency(&project) + assert.NilError(t, err) + }) + + t.Run(fmt.Sprintf("watch config is VALID with one or more paths for %s action", tt.action), func(t *testing.T) { + project := types.Project{ + Services: types.Services{ + "myservice": { + Name: "myservice", + Image: "scratch", + Develop: &types.DevelopConfig{ + Watch: []types.Trigger{ + { + Action: tt.action, + Path: "/app", + }, + { + Action: tt.action, + Path: "/app2", + }, + }, + }, + }, + }, + } + err := checkConsistency(&project) + assert.NilError(t, err) + }) + } } diff --git a/schema/compose-spec.json b/schema/compose-spec.json index 2faa3127..4bc0fb28 100644 --- a/schema/compose-spec.json +++ b/schema/compose-spec.json @@ -492,7 +492,7 @@ "required": ["path", "action"], "properties": { "ignore": {"type": "array", "items": {"type": "string"}}, - "path": {"type": "string"}, + "path": {"$ref": "#/definitions/string_or_list"}, "action": {"type": "string", "enum": ["rebuild", "sync", "restart", "sync+restart", "sync+exec"]}, "target": {"type": "string"}, "exec": {"$ref": "#/definitions/service_hook"} diff --git a/transform/canonical.go b/transform/canonical.go index d37eb1e2..abb62698 100644 --- a/transform/canonical.go +++ b/transform/canonical.go @@ -36,6 +36,7 @@ func init() { transformers["services.*.networks"] = transformServiceNetworks transformers["services.*.volumes.*"] = transformVolumeMount transformers["services.*.dns"] = transformStringOrList + transformers["services.*.develop.watch"] = transformWatch transformers["services.*.devices.*"] = transformDeviceMapping transformers["services.*.secrets.*"] = transformFileMount transformers["services.*.configs.*"] = transformFileMount @@ -51,6 +52,51 @@ func init() { transformers["include.*"] = transformInclude } +func transformWatch(data any, _ tree.Path, _ bool) (any, error) { + t, ok := data.([]interface{}) + if !ok { + return data, nil + } + + for i, w := range t { + watchConf, ok := w.(map[string]interface{}) + if !ok { + continue + } + path, ok := watchConf["path"] + if !ok { + // This should not happen + continue + } + paths, ok := path.([]interface{}) + if !ok { + // if path is a string there is nothing to do + continue + } + + // remove the current path that is a list + if i == len(t)-1 { + t = t[:i] + } else { + t = append(t[:i], t[i+1:]) + } + + // transform each element into a watch item + for _, p := range paths { + extend := make(map[string]interface{}) + for k, v := range watchConf { + if k == "path" { + extend[k] = p + continue + } + extend[k] = v + } + t = append(t, extend) + } + } + return t, nil +} + func transformStringOrList(data any, _ tree.Path, _ bool) (any, error) { switch t := data.(type) { case string: diff --git a/types/develop.go b/types/develop.go index 8f7c8fa5..51025c85 100644 --- a/types/develop.go +++ b/types/develop.go @@ -16,6 +16,11 @@ package types +import ( + "path/filepath" + "strings" +) + type DevelopConfig struct { Watch []Trigger `yaml:"watch,omitempty" json:"watch,omitempty"` @@ -40,3 +45,27 @@ type Trigger struct { Ignore []string `yaml:"ignore,omitempty" json:"ignore,omitempty"` Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"` } + +func (t Trigger) AnchorPath() string { + if t.IsGlobPath() { + pathList := strings.Split(filepath.FromSlash(t.Path), "/") + path := []string{} + + for _, a := range pathList { + if strings.Contains(a, "*") { + break + } + path = append(path, a) + } + return strings.Join(path, string(filepath.Separator)) + } + return t.Path +} + +func (t Trigger) IsGlobPath() bool { + return strings.Contains(t.Path, "*") +} + +func (t Trigger) IsSyncAction() bool { + return t.Action == WatchActionSync || t.Action == WatchActionSyncRestart +} diff --git a/validation/validation.go b/validation/validation.go index 793c1930..613919b2 100644 --- a/validation/validation.go +++ b/validation/validation.go @@ -93,6 +93,7 @@ func checkPath(value any, p tree.Path) error { if v == "" { return fmt.Errorf("%s: value can't be blank", p) } + return nil }