watch.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827
  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. "errors"
  17. "fmt"
  18. "io"
  19. "io/fs"
  20. "os"
  21. "path"
  22. "path/filepath"
  23. "slices"
  24. "strconv"
  25. "strings"
  26. gsync "sync"
  27. "time"
  28. pathutil "github.com/docker/compose/v2/internal/paths"
  29. "github.com/docker/compose/v2/internal/sync"
  30. "github.com/docker/compose/v2/internal/tracing"
  31. "github.com/docker/compose/v2/pkg/api"
  32. "github.com/docker/compose/v2/pkg/progress"
  33. cutils "github.com/docker/compose/v2/pkg/utils"
  34. "github.com/docker/compose/v2/pkg/watch"
  35. "github.com/compose-spec/compose-go/v2/types"
  36. "github.com/compose-spec/compose-go/v2/utils"
  37. ccli "github.com/docker/cli/cli/command/container"
  38. "github.com/docker/docker/api/types/container"
  39. "github.com/docker/docker/api/types/filters"
  40. "github.com/docker/docker/api/types/image"
  41. "github.com/mitchellh/mapstructure"
  42. "github.com/sirupsen/logrus"
  43. "golang.org/x/sync/errgroup"
  44. )
  45. type WatchFunc func(ctx context.Context, project *types.Project, options api.WatchOptions) (func() error, error)
  46. type Watcher struct {
  47. project *types.Project
  48. options api.WatchOptions
  49. watchFn WatchFunc
  50. stopFn func()
  51. errCh chan error
  52. }
  53. func NewWatcher(project *types.Project, options api.UpOptions, w WatchFunc, consumer api.LogConsumer) (*Watcher, error) {
  54. for i := range project.Services {
  55. service := project.Services[i]
  56. if service.Develop != nil && service.Develop.Watch != nil {
  57. build := options.Create.Build
  58. return &Watcher{
  59. project: project,
  60. options: api.WatchOptions{
  61. LogTo: consumer,
  62. Build: build,
  63. },
  64. watchFn: w,
  65. errCh: make(chan error),
  66. }, nil
  67. }
  68. }
  69. // none of the services is eligible to watch
  70. return nil, fmt.Errorf("none of the selected services is configured for watch, see https://docs.docker.com/compose/how-tos/file-watch/")
  71. }
  72. // ensure state changes are atomic
  73. var mx gsync.Mutex
  74. func (w *Watcher) Start(ctx context.Context) error {
  75. mx.Lock()
  76. defer mx.Unlock()
  77. ctx, cancelFunc := context.WithCancel(ctx)
  78. w.stopFn = cancelFunc
  79. wait, err := w.watchFn(ctx, w.project, w.options)
  80. if err != nil {
  81. return err
  82. }
  83. go func() {
  84. w.errCh <- wait()
  85. }()
  86. return nil
  87. }
  88. func (w *Watcher) Stop() error {
  89. mx.Lock()
  90. defer mx.Unlock()
  91. if w.stopFn == nil {
  92. return nil
  93. }
  94. w.stopFn()
  95. w.stopFn = nil
  96. err := <-w.errCh
  97. return err
  98. }
  99. // getSyncImplementation returns an appropriate sync implementation for the
  100. // project.
  101. //
  102. // Currently, an implementation that batches files and transfers them using
  103. // the Moby `Untar` API.
  104. func (s *composeService) getSyncImplementation(project *types.Project) (sync.Syncer, error) {
  105. var useTar bool
  106. if useTarEnv, ok := os.LookupEnv("COMPOSE_EXPERIMENTAL_WATCH_TAR"); ok {
  107. useTar, _ = strconv.ParseBool(useTarEnv)
  108. } else {
  109. useTar = true
  110. }
  111. if !useTar {
  112. return nil, errors.New("no available sync implementation")
  113. }
  114. return sync.NewTar(project.Name, tarDockerClient{s: s}), nil
  115. }
  116. func (s *composeService) Watch(ctx context.Context, project *types.Project, options api.WatchOptions) error {
  117. wait, err := s.watch(ctx, project, options)
  118. if err != nil {
  119. return err
  120. }
  121. return wait()
  122. }
  123. type watchRule struct {
  124. types.Trigger
  125. include watch.PathMatcher
  126. ignore watch.PathMatcher
  127. service string
  128. }
  129. func (r watchRule) Matches(event watch.FileEvent) *sync.PathMapping {
  130. hostPath := string(event)
  131. if !pathutil.IsChild(r.Path, hostPath) {
  132. return nil
  133. }
  134. included, err := r.include.Matches(hostPath)
  135. if err != nil {
  136. logrus.Warnf("error include matching %q: %v", hostPath, err)
  137. return nil
  138. }
  139. if !included {
  140. logrus.Debugf("%s is not matching include pattern", hostPath)
  141. return nil
  142. }
  143. isIgnored, err := r.ignore.Matches(hostPath)
  144. if err != nil {
  145. logrus.Warnf("error ignore matching %q: %v", hostPath, err)
  146. return nil
  147. }
  148. if isIgnored {
  149. logrus.Debugf("%s is matching ignore pattern", hostPath)
  150. return nil
  151. }
  152. var containerPath string
  153. if r.Target != "" {
  154. rel, err := filepath.Rel(r.Path, hostPath)
  155. if err != nil {
  156. logrus.Warnf("error making %s relative to %s: %v", hostPath, r.Path, err)
  157. return nil
  158. }
  159. // always use Unix-style paths for inside the container
  160. containerPath = path.Join(r.Target, filepath.ToSlash(rel))
  161. }
  162. return &sync.PathMapping{
  163. HostPath: hostPath,
  164. ContainerPath: containerPath,
  165. }
  166. }
  167. func (s *composeService) watch(ctx context.Context, project *types.Project, options api.WatchOptions) (func() error, error) { //nolint: gocyclo
  168. var err error
  169. if project, err = project.WithSelectedServices(options.Services); err != nil {
  170. return nil, err
  171. }
  172. syncer, err := s.getSyncImplementation(project)
  173. if err != nil {
  174. return nil, err
  175. }
  176. eg, ctx := errgroup.WithContext(ctx)
  177. var (
  178. rules []watchRule
  179. paths []string
  180. )
  181. for serviceName, service := range project.Services {
  182. config, err := loadDevelopmentConfig(service, project)
  183. if err != nil {
  184. return nil, err
  185. }
  186. if service.Develop != nil {
  187. config = service.Develop
  188. }
  189. if config == nil {
  190. continue
  191. }
  192. for _, trigger := range config.Watch {
  193. if trigger.Action == types.WatchActionRebuild {
  194. if service.Build == nil {
  195. return nil, fmt.Errorf("can't watch service %q with action %s without a build context", service.Name, types.WatchActionRebuild)
  196. }
  197. if options.Build == nil {
  198. return nil, fmt.Errorf("--no-build is incompatible with watch action %s in service %s", types.WatchActionRebuild, service.Name)
  199. }
  200. // set the service to always be built - watch triggers `Up()` when it receives a rebuild event
  201. service.PullPolicy = types.PullPolicyBuild
  202. project.Services[serviceName] = service
  203. }
  204. }
  205. for _, trigger := range config.Watch {
  206. if isSync(trigger) && checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
  207. logrus.Warnf("path '%s' also declared by a bind mount volume, this path won't be monitored!\n", trigger.Path)
  208. continue
  209. } else {
  210. var initialSync bool
  211. success, err := trigger.Extensions.Get("x-initialSync", &initialSync)
  212. if err == nil && success && initialSync && isSync(trigger) {
  213. // Need to check initial files are in container that are meant to be synced from watch action
  214. err := s.initialSync(ctx, project, service, trigger, syncer)
  215. if err != nil {
  216. return nil, err
  217. }
  218. }
  219. }
  220. paths = append(paths, trigger.Path)
  221. }
  222. serviceWatchRules, err := getWatchRules(config, service)
  223. if err != nil {
  224. return nil, err
  225. }
  226. rules = append(rules, serviceWatchRules...)
  227. }
  228. if len(paths) == 0 {
  229. return nil, fmt.Errorf("none of the selected services is configured for watch, consider setting a 'develop' section")
  230. }
  231. watcher, err := watch.NewWatcher(paths)
  232. if err != nil {
  233. return nil, err
  234. }
  235. err = watcher.Start()
  236. if err != nil {
  237. return nil, err
  238. }
  239. eg.Go(func() error {
  240. return s.watchEvents(ctx, project, options, watcher, syncer, rules)
  241. })
  242. options.LogTo.Log(api.WatchLogger, "Watch enabled")
  243. return func() error {
  244. err := eg.Wait()
  245. if werr := watcher.Close(); werr != nil {
  246. logrus.Debugf("Error closing Watcher: %v", werr)
  247. }
  248. return err
  249. }, nil
  250. }
  251. func getWatchRules(config *types.DevelopConfig, service types.ServiceConfig) ([]watchRule, error) {
  252. var rules []watchRule
  253. dockerIgnores, err := watch.LoadDockerIgnore(service.Build)
  254. if err != nil {
  255. return nil, err
  256. }
  257. // add a hardcoded set of ignores on top of what came from .dockerignore
  258. // some of this should likely be configurable (e.g. there could be cases
  259. // where you want `.git` to be synced) but this is suitable for now
  260. dotGitIgnore, err := watch.NewDockerPatternMatcher("/", []string{".git/"})
  261. if err != nil {
  262. return nil, err
  263. }
  264. for _, trigger := range config.Watch {
  265. ignore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
  266. if err != nil {
  267. return nil, err
  268. }
  269. var include watch.PathMatcher
  270. if len(trigger.Include) == 0 {
  271. include = watch.AnyMatcher{}
  272. } else {
  273. include, err = watch.NewDockerPatternMatcher(trigger.Path, trigger.Include)
  274. if err != nil {
  275. return nil, err
  276. }
  277. }
  278. rules = append(rules, watchRule{
  279. Trigger: trigger,
  280. include: include,
  281. ignore: watch.NewCompositeMatcher(
  282. dockerIgnores,
  283. watch.EphemeralPathMatcher(),
  284. dotGitIgnore,
  285. ignore,
  286. ),
  287. service: service.Name,
  288. })
  289. }
  290. return rules, nil
  291. }
  292. func isSync(trigger types.Trigger) bool {
  293. return trigger.Action == types.WatchActionSync || trigger.Action == types.WatchActionSyncRestart
  294. }
  295. func (s *composeService) watchEvents(ctx context.Context, project *types.Project, options api.WatchOptions, watcher watch.Notify, syncer sync.Syncer, rules []watchRule) error {
  296. ctx, cancel := context.WithCancel(ctx)
  297. defer cancel()
  298. // debounce and group filesystem events so that we capture IDE saving many files as one "batch" event
  299. batchEvents := watch.BatchDebounceEvents(ctx, s.clock, watcher.Events())
  300. for {
  301. select {
  302. case <-ctx.Done():
  303. options.LogTo.Log(api.WatchLogger, "Watch disabled")
  304. return nil
  305. case err, open := <-watcher.Errors():
  306. if err != nil {
  307. options.LogTo.Err(api.WatchLogger, "Watch disabled with errors: "+err.Error())
  308. }
  309. if open {
  310. continue
  311. }
  312. return err
  313. case batch := <-batchEvents:
  314. start := time.Now()
  315. logrus.Debugf("batch start: count[%d]", len(batch))
  316. err := s.handleWatchBatch(ctx, project, options, batch, rules, syncer)
  317. if err != nil {
  318. logrus.Warnf("Error handling changed files: %v", err)
  319. }
  320. logrus.Debugf("batch complete: duration[%s] count[%d]", time.Since(start), len(batch))
  321. }
  322. }
  323. }
  324. func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project) (*types.DevelopConfig, error) {
  325. var config types.DevelopConfig
  326. y, ok := service.Extensions["x-develop"]
  327. if !ok {
  328. return nil, nil
  329. }
  330. logrus.Warnf("x-develop is DEPRECATED, please use the official `develop` attribute")
  331. err := mapstructure.Decode(y, &config)
  332. if err != nil {
  333. return nil, err
  334. }
  335. baseDir, err := filepath.EvalSymlinks(project.WorkingDir)
  336. if err != nil {
  337. return nil, fmt.Errorf("resolving symlink for %q: %w", project.WorkingDir, err)
  338. }
  339. for i, trigger := range config.Watch {
  340. if !filepath.IsAbs(trigger.Path) {
  341. trigger.Path = filepath.Join(baseDir, trigger.Path)
  342. }
  343. if p, err := filepath.EvalSymlinks(trigger.Path); err == nil {
  344. // this might fail because the path doesn't exist, etc.
  345. trigger.Path = p
  346. }
  347. trigger.Path = filepath.Clean(trigger.Path)
  348. if trigger.Path == "" {
  349. return nil, errors.New("watch rules MUST define a path")
  350. }
  351. if trigger.Action == types.WatchActionRebuild && service.Build == nil {
  352. return nil, fmt.Errorf("service %s doesn't have a build section, can't apply %s on watch", types.WatchActionRebuild, service.Name)
  353. }
  354. if trigger.Action == types.WatchActionSyncExec && len(trigger.Exec.Command) == 0 {
  355. return nil, fmt.Errorf("can't watch with action %q on service %s without a command", types.WatchActionSyncExec, service.Name)
  356. }
  357. config.Watch[i] = trigger
  358. }
  359. return &config, nil
  360. }
  361. func checkIfPathAlreadyBindMounted(watchPath string, volumes []types.ServiceVolumeConfig) bool {
  362. for _, volume := range volumes {
  363. if volume.Bind != nil {
  364. relPath, err := filepath.Rel(volume.Source, watchPath)
  365. if err == nil && !strings.HasPrefix(relPath, "..") {
  366. return true
  367. }
  368. }
  369. }
  370. return false
  371. }
  372. type tarDockerClient struct {
  373. s *composeService
  374. }
  375. func (t tarDockerClient) ContainersForService(ctx context.Context, projectName string, serviceName string) ([]container.Summary, error) {
  376. containers, err := t.s.getContainers(ctx, projectName, oneOffExclude, true, serviceName)
  377. if err != nil {
  378. return nil, err
  379. }
  380. return containers, nil
  381. }
  382. func (t tarDockerClient) Exec(ctx context.Context, containerID string, cmd []string, in io.Reader) error {
  383. execCfg := container.ExecOptions{
  384. Cmd: cmd,
  385. AttachStdout: false,
  386. AttachStderr: true,
  387. AttachStdin: in != nil,
  388. Tty: false,
  389. }
  390. execCreateResp, err := t.s.apiClient().ContainerExecCreate(ctx, containerID, execCfg)
  391. if err != nil {
  392. return err
  393. }
  394. startCheck := container.ExecStartOptions{Tty: false, Detach: false}
  395. conn, err := t.s.apiClient().ContainerExecAttach(ctx, execCreateResp.ID, startCheck)
  396. if err != nil {
  397. return err
  398. }
  399. defer conn.Close()
  400. var eg errgroup.Group
  401. if in != nil {
  402. eg.Go(func() error {
  403. defer func() {
  404. _ = conn.CloseWrite()
  405. }()
  406. _, err := io.Copy(conn.Conn, in)
  407. return err
  408. })
  409. }
  410. eg.Go(func() error {
  411. _, err := io.Copy(t.s.stdinfo(), conn.Reader)
  412. return err
  413. })
  414. err = t.s.apiClient().ContainerExecStart(ctx, execCreateResp.ID, startCheck)
  415. if err != nil {
  416. return err
  417. }
  418. // although the errgroup is not tied directly to the context, the operations
  419. // in it are reading/writing to the connection, which is tied to the context,
  420. // so they won't block indefinitely
  421. if err := eg.Wait(); err != nil {
  422. return err
  423. }
  424. execResult, err := t.s.apiClient().ContainerExecInspect(ctx, execCreateResp.ID)
  425. if err != nil {
  426. return err
  427. }
  428. if execResult.Running {
  429. return errors.New("process still running")
  430. }
  431. if execResult.ExitCode != 0 {
  432. return fmt.Errorf("exit code %d", execResult.ExitCode)
  433. }
  434. return nil
  435. }
  436. func (t tarDockerClient) Untar(ctx context.Context, id string, archive io.ReadCloser) error {
  437. return t.s.apiClient().CopyToContainer(ctx, id, "/", archive, container.CopyToContainerOptions{
  438. CopyUIDGID: true,
  439. })
  440. }
  441. //nolint:gocyclo
  442. func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Project, options api.WatchOptions, batch []watch.FileEvent, rules []watchRule, syncer sync.Syncer) error {
  443. var (
  444. restart = map[string]bool{}
  445. syncfiles = map[string][]*sync.PathMapping{}
  446. exec = map[string][]int{}
  447. rebuild = map[string]bool{}
  448. )
  449. for _, event := range batch {
  450. for i, rule := range rules {
  451. mapping := rule.Matches(event)
  452. if mapping == nil {
  453. continue
  454. }
  455. switch rule.Action {
  456. case types.WatchActionRebuild:
  457. rebuild[rule.service] = true
  458. case types.WatchActionSync:
  459. syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
  460. case types.WatchActionRestart:
  461. restart[rule.service] = true
  462. case types.WatchActionSyncRestart:
  463. syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
  464. restart[rule.service] = true
  465. case types.WatchActionSyncExec:
  466. syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
  467. // We want to run exec hooks only once after syncfiles if multiple file events match
  468. // as we can't compare ServiceHook to sort and compact a slice, collect rule indexes
  469. exec[rule.service] = append(exec[rule.service], i)
  470. }
  471. }
  472. }
  473. logrus.Debugf("watch actions: rebuild %d sync %d restart %d", len(rebuild), len(syncfiles), len(restart))
  474. if len(rebuild) > 0 {
  475. err := s.rebuild(ctx, project, utils.MapKeys(rebuild), options)
  476. if err != nil {
  477. return err
  478. }
  479. }
  480. for serviceName, pathMappings := range syncfiles {
  481. writeWatchSyncMessage(options.LogTo, serviceName, pathMappings)
  482. err := syncer.Sync(ctx, serviceName, pathMappings)
  483. if err != nil {
  484. return err
  485. }
  486. }
  487. if len(restart) > 0 {
  488. services := utils.MapKeys(restart)
  489. err := s.restart(ctx, project.Name, api.RestartOptions{
  490. Services: services,
  491. Project: project,
  492. NoDeps: false,
  493. })
  494. if err != nil {
  495. return err
  496. }
  497. options.LogTo.Log(
  498. api.WatchLogger,
  499. fmt.Sprintf("service(s) %q restarted", services))
  500. }
  501. eg, ctx := errgroup.WithContext(ctx)
  502. for service, rulesToExec := range exec {
  503. slices.Sort(rulesToExec)
  504. for _, i := range slices.Compact(rulesToExec) {
  505. err := s.exec(ctx, project, service, rules[i].Exec, eg)
  506. if err != nil {
  507. return err
  508. }
  509. }
  510. }
  511. return eg.Wait()
  512. }
  513. func (s *composeService) exec(ctx context.Context, project *types.Project, serviceName string, x types.ServiceHook, eg *errgroup.Group) error {
  514. containers, err := s.getContainers(ctx, project.Name, oneOffExclude, false, serviceName)
  515. if err != nil {
  516. return err
  517. }
  518. for _, c := range containers {
  519. eg.Go(func() error {
  520. exec := ccli.NewExecOptions()
  521. exec.User = x.User
  522. exec.Privileged = x.Privileged
  523. exec.Command = x.Command
  524. exec.Workdir = x.WorkingDir
  525. for _, v := range x.Environment.ToMapping().Values() {
  526. err := exec.Env.Set(v)
  527. if err != nil {
  528. return err
  529. }
  530. }
  531. return ccli.RunExec(ctx, s.dockerCli, c.ID, exec)
  532. })
  533. }
  534. return nil
  535. }
  536. func (s *composeService) rebuild(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error {
  537. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Rebuilding service(s) %q after changes were detected...", services))
  538. // restrict the build to ONLY this service, not any of its dependencies
  539. options.Build.Services = services
  540. options.Build.Progress = progress.ModePlain
  541. options.Build.Out = cutils.GetWriter(func(line string) {
  542. options.LogTo.Log(api.WatchLogger, line)
  543. })
  544. var (
  545. imageNameToIdMap map[string]string
  546. err error
  547. )
  548. err = tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(ctx, project),
  549. func(ctx context.Context) error {
  550. imageNameToIdMap, err = s.build(ctx, project, *options.Build, nil)
  551. return err
  552. })(ctx)
  553. if err != nil {
  554. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Build failed. Error: %v", err))
  555. return err
  556. }
  557. if options.Prune {
  558. s.pruneDanglingImagesOnRebuild(ctx, project.Name, imageNameToIdMap)
  559. }
  560. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("service(s) %q successfully built", services))
  561. err = s.create(ctx, project, api.CreateOptions{
  562. Services: services,
  563. Inherit: true,
  564. Recreate: api.RecreateForce,
  565. })
  566. if err != nil {
  567. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Failed to recreate services after update. Error: %v", err))
  568. return err
  569. }
  570. p, err := project.WithSelectedServices(services, types.IncludeDependents)
  571. if err != nil {
  572. return err
  573. }
  574. err = s.start(ctx, project.Name, api.StartOptions{
  575. Project: p,
  576. Services: services,
  577. AttachTo: services,
  578. }, nil)
  579. if err != nil {
  580. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Application failed to start after update. Error: %v", err))
  581. }
  582. return nil
  583. }
  584. // writeWatchSyncMessage prints out a message about the sync for the changed paths.
  585. func writeWatchSyncMessage(log api.LogConsumer, serviceName string, pathMappings []*sync.PathMapping) {
  586. if logrus.IsLevelEnabled(logrus.DebugLevel) {
  587. hostPathsToSync := make([]string, len(pathMappings))
  588. for i := range pathMappings {
  589. hostPathsToSync[i] = pathMappings[i].HostPath
  590. }
  591. log.Log(
  592. api.WatchLogger,
  593. fmt.Sprintf(
  594. "Syncing service %q after changes were detected: %s",
  595. serviceName,
  596. strings.Join(hostPathsToSync, ", "),
  597. ),
  598. )
  599. } else {
  600. log.Log(
  601. api.WatchLogger,
  602. fmt.Sprintf("Syncing service %q after %d changes were detected", serviceName, len(pathMappings)),
  603. )
  604. }
  605. }
  606. func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, projectName string, imageNameToIdMap map[string]string) {
  607. images, err := s.apiClient().ImageList(ctx, image.ListOptions{
  608. Filters: filters.NewArgs(
  609. filters.Arg("dangling", "true"),
  610. filters.Arg("label", api.ProjectLabel+"="+projectName),
  611. ),
  612. })
  613. if err != nil {
  614. logrus.Debugf("Failed to list images: %v", err)
  615. return
  616. }
  617. for _, img := range images {
  618. if _, ok := imageNameToIdMap[img.ID]; !ok {
  619. _, err := s.apiClient().ImageRemove(ctx, img.ID, image.RemoveOptions{})
  620. if err != nil {
  621. logrus.Debugf("Failed to remove image %s: %v", img.ID, err)
  622. }
  623. }
  624. }
  625. }
  626. // Walks develop.watch.path and checks which files should be copied inside the container
  627. // ignores develop.watch.ignore, Dockerfile, compose files, bind mounted paths and .git
  628. func (s *composeService) initialSync(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, syncer sync.Syncer) error {
  629. dockerIgnores, err := watch.LoadDockerIgnore(service.Build)
  630. if err != nil {
  631. return err
  632. }
  633. dotGitIgnore, err := watch.NewDockerPatternMatcher("/", []string{".git/"})
  634. if err != nil {
  635. return err
  636. }
  637. triggerIgnore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
  638. if err != nil {
  639. return err
  640. }
  641. // FIXME .dockerignore
  642. ignoreInitialSync := watch.NewCompositeMatcher(
  643. dockerIgnores,
  644. watch.EphemeralPathMatcher(),
  645. dotGitIgnore,
  646. triggerIgnore)
  647. pathsToCopy, err := s.initialSyncFiles(ctx, project, service, trigger, ignoreInitialSync)
  648. if err != nil {
  649. return err
  650. }
  651. return syncer.Sync(ctx, service.Name, pathsToCopy)
  652. }
  653. // Syncs files from develop.watch.path if thy have been modified after the image has been created
  654. //
  655. //nolint:gocyclo
  656. func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) {
  657. fi, err := os.Stat(trigger.Path)
  658. if err != nil {
  659. return nil, err
  660. }
  661. timeImageCreated, err := s.imageCreatedTime(ctx, project, service.Name)
  662. if err != nil {
  663. return nil, err
  664. }
  665. var pathsToCopy []*sync.PathMapping
  666. switch mode := fi.Mode(); {
  667. case mode.IsDir():
  668. // process directory
  669. err = filepath.WalkDir(trigger.Path, func(path string, d fs.DirEntry, err error) error {
  670. if err != nil {
  671. // handle possible path err, just in case...
  672. return err
  673. }
  674. if trigger.Path == path {
  675. // walk starts at the root directory
  676. return nil
  677. }
  678. if shouldIgnore(filepath.Base(path), ignore) || checkIfPathAlreadyBindMounted(path, service.Volumes) {
  679. // By definition sync ignores bind mounted paths
  680. if d.IsDir() {
  681. // skip folder
  682. return fs.SkipDir
  683. }
  684. return nil // skip file
  685. }
  686. info, err := d.Info()
  687. if err != nil {
  688. return err
  689. }
  690. if !d.IsDir() {
  691. if info.ModTime().Before(timeImageCreated) {
  692. // skip file if it was modified before image creation
  693. return nil
  694. }
  695. rel, err := filepath.Rel(trigger.Path, path)
  696. if err != nil {
  697. return err
  698. }
  699. // only copy files (and not full directories)
  700. pathsToCopy = append(pathsToCopy, &sync.PathMapping{
  701. HostPath: path,
  702. ContainerPath: filepath.Join(trigger.Target, rel),
  703. })
  704. }
  705. return nil
  706. })
  707. case mode.IsRegular():
  708. // process file
  709. if fi.ModTime().After(timeImageCreated) && !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
  710. pathsToCopy = append(pathsToCopy, &sync.PathMapping{
  711. HostPath: trigger.Path,
  712. ContainerPath: trigger.Target,
  713. })
  714. }
  715. }
  716. return pathsToCopy, err
  717. }
  718. func shouldIgnore(name string, ignore watch.PathMatcher) bool {
  719. shouldIgnore, _ := ignore.Matches(name)
  720. // ignore files that match any ignore pattern
  721. return shouldIgnore
  722. }
  723. // gets the image creation time for a service
  724. func (s *composeService) imageCreatedTime(ctx context.Context, project *types.Project, serviceName string) (time.Time, error) {
  725. containers, err := s.apiClient().ContainerList(ctx, container.ListOptions{
  726. All: true,
  727. Filters: filters.NewArgs(
  728. filters.Arg("label", fmt.Sprintf("%s=%s", api.ProjectLabel, project.Name)),
  729. filters.Arg("label", fmt.Sprintf("%s=%s", api.ServiceLabel, serviceName))),
  730. })
  731. if err != nil {
  732. return time.Now(), err
  733. }
  734. if len(containers) == 0 {
  735. return time.Now(), fmt.Errorf("could not get created time for service's image")
  736. }
  737. img, err := s.apiClient().ImageInspect(ctx, containers[0].ImageID)
  738. if err != nil {
  739. return time.Now(), err
  740. }
  741. // Need to get the oldest one?
  742. timeCreated, err := time.Parse(time.RFC3339Nano, img.Created)
  743. if err != nil {
  744. return time.Now(), err
  745. }
  746. return timeCreated, nil
  747. }