watch.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package compose
  14. import (
  15. "context"
  16. "fmt"
  17. "io/fs"
  18. "os"
  19. "path"
  20. "path/filepath"
  21. "strings"
  22. "time"
  23. "github.com/compose-spec/compose-go/types"
  24. "github.com/jonboulle/clockwork"
  25. "github.com/mitchellh/mapstructure"
  26. "github.com/pkg/errors"
  27. "github.com/sirupsen/logrus"
  28. "golang.org/x/sync/errgroup"
  29. "github.com/docker/compose/v2/pkg/api"
  30. "github.com/docker/compose/v2/pkg/utils"
  31. "github.com/docker/compose/v2/pkg/watch"
  32. )
  33. type DevelopmentConfig struct {
  34. Watch []Trigger `json:"watch,omitempty"`
  35. }
  36. const (
  37. WatchActionSync = "sync"
  38. WatchActionRebuild = "rebuild"
  39. )
  40. type Trigger struct {
  41. Path string `json:"path,omitempty"`
  42. Action string `json:"action,omitempty"`
  43. Target string `json:"target,omitempty"`
  44. Ignore []string `json:"ignore,omitempty"`
  45. }
  46. const quietPeriod = 2 * time.Second
  47. // fileMapping contains the Compose service and modified host system path.
  48. //
  49. // For file sync, the container path is also included.
  50. // For rebuild, there is no container path, so it is always empty.
  51. type fileMapping struct {
  52. // Service that the file event is for.
  53. Service string
  54. // HostPath that was created/modified/deleted outside the container.
  55. //
  56. // This is the path as seen from the user's perspective, e.g.
  57. // - C:\Users\moby\Documents\hello-world\main.go
  58. // - /Users/moby/Documents/hello-world/main.go
  59. HostPath string
  60. // ContainerPath for the target file inside the container (only populated
  61. // for sync events, not rebuild).
  62. //
  63. // This is the path as used in Docker CLI commands, e.g.
  64. // - /workdir/main.go
  65. ContainerPath string
  66. }
  67. func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, _ api.WatchOptions) error { //nolint: gocyclo
  68. needRebuild := make(chan fileMapping)
  69. needSync := make(chan fileMapping)
  70. err := s.prepareProjectForBuild(project, nil)
  71. if err != nil {
  72. return err
  73. }
  74. eg, ctx := errgroup.WithContext(ctx)
  75. eg.Go(func() error {
  76. clock := clockwork.NewRealClock()
  77. debounce(ctx, clock, quietPeriod, needRebuild, s.makeRebuildFn(ctx, project))
  78. return nil
  79. })
  80. eg.Go(s.makeSyncFn(ctx, project, needSync))
  81. ss, err := project.GetServices(services...)
  82. if err != nil {
  83. return err
  84. }
  85. watching := false
  86. for _, service := range ss {
  87. config, err := loadDevelopmentConfig(service, project)
  88. if err != nil {
  89. return err
  90. }
  91. if config != nil && len(config.Watch) > 0 && service.Build == nil {
  92. // service configured with watchers but no build section
  93. return fmt.Errorf("can't watch service %q without a build context", service.Name)
  94. }
  95. if len(services) > 0 && service.Build == nil {
  96. // service explicitly selected for watch has no build section
  97. return fmt.Errorf("can't watch service %q without a build context", service.Name)
  98. }
  99. if len(services) == 0 && service.Build == nil {
  100. continue
  101. }
  102. if config == nil {
  103. config = &DevelopmentConfig{
  104. Watch: []Trigger{
  105. {
  106. Path: service.Build.Context,
  107. Action: WatchActionRebuild,
  108. },
  109. },
  110. }
  111. }
  112. name := service.Name
  113. dockerIgnores, err := watch.LoadDockerIgnore(service.Build.Context)
  114. if err != nil {
  115. return err
  116. }
  117. // add a hardcoded set of ignores on top of what came from .dockerignore
  118. // some of this should likely be configurable (e.g. there could be cases
  119. // where you want `.git` to be synced) but this is suitable for now
  120. dotGitIgnore, err := watch.NewDockerPatternMatcher("/", []string{".git/"})
  121. if err != nil {
  122. return err
  123. }
  124. ignore := watch.NewCompositeMatcher(
  125. dockerIgnores,
  126. watch.EphemeralPathMatcher(),
  127. dotGitIgnore,
  128. )
  129. var paths []string
  130. for _, trigger := range config.Watch {
  131. paths = append(paths, trigger.Path)
  132. }
  133. watcher, err := watch.NewWatcher(paths, ignore)
  134. if err != nil {
  135. return err
  136. }
  137. fmt.Fprintf(s.stdinfo(), "watching %s\n", paths)
  138. err = watcher.Start()
  139. if err != nil {
  140. return err
  141. }
  142. watching = true
  143. eg.Go(func() error {
  144. defer watcher.Close() //nolint:errcheck
  145. return s.watch(ctx, name, watcher, config.Watch, needSync, needRebuild)
  146. })
  147. }
  148. if !watching {
  149. return fmt.Errorf("none of the selected services is configured for watch, consider setting an 'x-develop' section")
  150. }
  151. return eg.Wait()
  152. }
  153. func (s *composeService) watch(ctx context.Context, name string, watcher watch.Notify, triggers []Trigger, needSync chan fileMapping, needRebuild chan fileMapping) error {
  154. ignores := make([]watch.PathMatcher, len(triggers))
  155. for i, trigger := range triggers {
  156. ignore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
  157. if err != nil {
  158. return err
  159. }
  160. ignores[i] = ignore
  161. }
  162. WATCH:
  163. for {
  164. select {
  165. case <-ctx.Done():
  166. return nil
  167. case event := <-watcher.Events():
  168. hostPath := event.Path()
  169. for i, trigger := range triggers {
  170. logrus.Debugf("change detected on %s - comparing with %s", hostPath, trigger.Path)
  171. if watch.IsChild(trigger.Path, hostPath) {
  172. match, err := ignores[i].Matches(hostPath)
  173. if err != nil {
  174. return err
  175. }
  176. if match {
  177. logrus.Debugf("%s is matching ignore pattern", hostPath)
  178. continue
  179. }
  180. fmt.Fprintf(s.stdinfo(), "change detected on %s\n", hostPath)
  181. f := fileMapping{
  182. HostPath: hostPath,
  183. Service: name,
  184. }
  185. switch trigger.Action {
  186. case WatchActionSync:
  187. logrus.Debugf("modified file %s triggered sync", hostPath)
  188. rel, err := filepath.Rel(trigger.Path, hostPath)
  189. if err != nil {
  190. return err
  191. }
  192. // always use Unix-style paths for inside the container
  193. f.ContainerPath = path.Join(trigger.Target, rel)
  194. needSync <- f
  195. case WatchActionRebuild:
  196. logrus.Debugf("modified file %s requires image to be rebuilt", hostPath)
  197. needRebuild <- f
  198. default:
  199. return fmt.Errorf("watch action %q is not supported", trigger)
  200. }
  201. continue WATCH
  202. }
  203. }
  204. case err := <-watcher.Errors():
  205. return err
  206. }
  207. }
  208. }
  209. func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project) (*DevelopmentConfig, error) {
  210. var config DevelopmentConfig
  211. y, ok := service.Extensions["x-develop"]
  212. if !ok {
  213. return nil, nil
  214. }
  215. err := mapstructure.Decode(y, &config)
  216. if err != nil {
  217. return nil, err
  218. }
  219. for i, trigger := range config.Watch {
  220. if !filepath.IsAbs(trigger.Path) {
  221. trigger.Path = filepath.Join(project.WorkingDir, trigger.Path)
  222. }
  223. trigger.Path = filepath.Clean(trigger.Path)
  224. if trigger.Path == "" {
  225. return nil, errors.New("watch rules MUST define a path")
  226. }
  227. if trigger.Action == WatchActionRebuild && service.Build == nil {
  228. return nil, fmt.Errorf("service %s doesn't have a build section, can't apply 'rebuild' on watch", service.Name)
  229. }
  230. config.Watch[i] = trigger
  231. }
  232. return &config, nil
  233. }
  234. func (s *composeService) makeRebuildFn(ctx context.Context, project *types.Project) func(services rebuildServices) {
  235. for i, service := range project.Services {
  236. service.PullPolicy = types.PullPolicyBuild
  237. project.Services[i] = service
  238. }
  239. return func(services rebuildServices) {
  240. serviceNames := make([]string, 0, len(services))
  241. allPaths := make(utils.Set[string])
  242. for serviceName, paths := range services {
  243. serviceNames = append(serviceNames, serviceName)
  244. for p := range paths {
  245. allPaths.Add(p)
  246. }
  247. }
  248. fmt.Fprintf(
  249. s.stdinfo(),
  250. "Rebuilding %s after changes were detected:%s\n",
  251. strings.Join(serviceNames, ", "),
  252. strings.Join(append([]string{""}, allPaths.Elements()...), "\n - "),
  253. )
  254. err := s.Up(ctx, project, api.UpOptions{
  255. Create: api.CreateOptions{
  256. Services: serviceNames,
  257. Inherit: true,
  258. },
  259. Start: api.StartOptions{
  260. Services: serviceNames,
  261. Project: project,
  262. },
  263. })
  264. if err != nil {
  265. fmt.Fprintf(s.stderr(), "Application failed to start after update\n")
  266. }
  267. }
  268. }
  269. func (s *composeService) makeSyncFn(ctx context.Context, project *types.Project, needSync <-chan fileMapping) func() error {
  270. return func() error {
  271. for {
  272. select {
  273. case <-ctx.Done():
  274. return nil
  275. case opt := <-needSync:
  276. if fi, statErr := os.Stat(opt.HostPath); statErr == nil && !fi.IsDir() {
  277. err := s.Copy(ctx, project.Name, api.CopyOptions{
  278. Source: opt.HostPath,
  279. Destination: fmt.Sprintf("%s:%s", opt.Service, opt.ContainerPath),
  280. })
  281. if err != nil {
  282. return err
  283. }
  284. fmt.Fprintf(s.stdinfo(), "%s updated\n", opt.ContainerPath)
  285. } else if errors.Is(statErr, fs.ErrNotExist) {
  286. _, err := s.Exec(ctx, project.Name, api.RunOptions{
  287. Service: opt.Service,
  288. Command: []string{"rm", "-rf", opt.ContainerPath},
  289. Index: 1,
  290. })
  291. if err != nil {
  292. logrus.Warnf("failed to delete %q from %s: %v", opt.ContainerPath, opt.Service, err)
  293. }
  294. fmt.Fprintf(s.stdinfo(), "%s deleted from container\n", opt.ContainerPath)
  295. }
  296. }
  297. }
  298. }
  299. }
  300. type rebuildServices map[string]utils.Set[string]
  301. func debounce(ctx context.Context, clock clockwork.Clock, delay time.Duration, input <-chan fileMapping, fn func(services rebuildServices)) {
  302. services := make(rebuildServices)
  303. t := clock.NewTimer(delay)
  304. defer t.Stop()
  305. for {
  306. select {
  307. case <-ctx.Done():
  308. return
  309. case <-t.Chan():
  310. if len(services) > 0 {
  311. go fn(services)
  312. services = make(rebuildServices)
  313. }
  314. case e := <-input:
  315. t.Reset(delay)
  316. svc, ok := services[e.Service]
  317. if !ok {
  318. svc = make(utils.Set[string])
  319. services[e.Service] = svc
  320. }
  321. svc.Add(e.HostPath)
  322. }
  323. }
  324. }