watch.go 24 KB

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