watch.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837
  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/go-viper/mapstructure/v2"
  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. shouldInitialSync := trigger.InitialSync
  211. // Check legacy extension attribute for backward compatibility
  212. if !shouldInitialSync {
  213. var legacyInitialSync bool
  214. success, err := trigger.Extensions.Get("x-initialSync", &legacyInitialSync)
  215. if err == nil && success && legacyInitialSync {
  216. shouldInitialSync = true
  217. logrus.Warnf("x-initialSync is DEPRECATED, please use the official `initial_sync` attribute\n")
  218. }
  219. }
  220. if shouldInitialSync && isSync(trigger) {
  221. // Need to check initial files are in container that are meant to be synced from watch action
  222. err := s.initialSync(ctx, project, service, trigger, syncer)
  223. if err != nil {
  224. return nil, err
  225. }
  226. }
  227. }
  228. paths = append(paths, trigger.Path)
  229. }
  230. serviceWatchRules, err := getWatchRules(config, service)
  231. if err != nil {
  232. return nil, err
  233. }
  234. rules = append(rules, serviceWatchRules...)
  235. }
  236. if len(paths) == 0 {
  237. return nil, fmt.Errorf("none of the selected services is configured for watch, consider setting a 'develop' section")
  238. }
  239. watcher, err := watch.NewWatcher(paths)
  240. if err != nil {
  241. return nil, err
  242. }
  243. err = watcher.Start()
  244. if err != nil {
  245. return nil, err
  246. }
  247. eg.Go(func() error {
  248. return s.watchEvents(ctx, project, options, watcher, syncer, rules)
  249. })
  250. options.LogTo.Log(api.WatchLogger, "Watch enabled")
  251. return func() error {
  252. err := eg.Wait()
  253. if werr := watcher.Close(); werr != nil {
  254. logrus.Debugf("Error closing Watcher: %v", werr)
  255. }
  256. return err
  257. }, nil
  258. }
  259. func getWatchRules(config *types.DevelopConfig, service types.ServiceConfig) ([]watchRule, error) {
  260. var rules []watchRule
  261. dockerIgnores, err := watch.LoadDockerIgnore(service.Build)
  262. if err != nil {
  263. return nil, err
  264. }
  265. // add a hardcoded set of ignores on top of what came from .dockerignore
  266. // some of this should likely be configurable (e.g. there could be cases
  267. // where you want `.git` to be synced) but this is suitable for now
  268. dotGitIgnore, err := watch.NewDockerPatternMatcher("/", []string{".git/"})
  269. if err != nil {
  270. return nil, err
  271. }
  272. for _, trigger := range config.Watch {
  273. ignore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
  274. if err != nil {
  275. return nil, err
  276. }
  277. var include watch.PathMatcher
  278. if len(trigger.Include) == 0 {
  279. include = watch.AnyMatcher{}
  280. } else {
  281. include, err = watch.NewDockerPatternMatcher(trigger.Path, trigger.Include)
  282. if err != nil {
  283. return nil, err
  284. }
  285. }
  286. rules = append(rules, watchRule{
  287. Trigger: trigger,
  288. include: include,
  289. ignore: watch.NewCompositeMatcher(
  290. dockerIgnores,
  291. watch.EphemeralPathMatcher(),
  292. dotGitIgnore,
  293. ignore,
  294. ),
  295. service: service.Name,
  296. })
  297. }
  298. return rules, nil
  299. }
  300. func isSync(trigger types.Trigger) bool {
  301. return trigger.Action == types.WatchActionSync || trigger.Action == types.WatchActionSyncRestart
  302. }
  303. func (s *composeService) watchEvents(ctx context.Context, project *types.Project, options api.WatchOptions, watcher watch.Notify, syncer sync.Syncer, rules []watchRule) error {
  304. ctx, cancel := context.WithCancel(ctx)
  305. defer cancel()
  306. // debounce and group filesystem events so that we capture IDE saving many files as one "batch" event
  307. batchEvents := watch.BatchDebounceEvents(ctx, s.clock, watcher.Events())
  308. for {
  309. select {
  310. case <-ctx.Done():
  311. options.LogTo.Log(api.WatchLogger, "Watch disabled")
  312. return nil
  313. case err, open := <-watcher.Errors():
  314. if err != nil {
  315. options.LogTo.Err(api.WatchLogger, "Watch disabled with errors: "+err.Error())
  316. }
  317. if open {
  318. continue
  319. }
  320. return err
  321. case batch := <-batchEvents:
  322. start := time.Now()
  323. logrus.Debugf("batch start: count[%d]", len(batch))
  324. err := s.handleWatchBatch(ctx, project, options, batch, rules, syncer)
  325. if err != nil {
  326. logrus.Warnf("Error handling changed files: %v", err)
  327. }
  328. logrus.Debugf("batch complete: duration[%s] count[%d]", time.Since(start), len(batch))
  329. }
  330. }
  331. }
  332. func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project) (*types.DevelopConfig, error) {
  333. var config types.DevelopConfig
  334. y, ok := service.Extensions["x-develop"]
  335. if !ok {
  336. return nil, nil
  337. }
  338. logrus.Warnf("x-develop is DEPRECATED, please use the official `develop` attribute")
  339. err := mapstructure.Decode(y, &config)
  340. if err != nil {
  341. return nil, err
  342. }
  343. baseDir, err := filepath.EvalSymlinks(project.WorkingDir)
  344. if err != nil {
  345. return nil, fmt.Errorf("resolving symlink for %q: %w", project.WorkingDir, err)
  346. }
  347. for i, trigger := range config.Watch {
  348. if !filepath.IsAbs(trigger.Path) {
  349. trigger.Path = filepath.Join(baseDir, trigger.Path)
  350. }
  351. if p, err := filepath.EvalSymlinks(trigger.Path); err == nil {
  352. // this might fail because the path doesn't exist, etc.
  353. trigger.Path = p
  354. }
  355. trigger.Path = filepath.Clean(trigger.Path)
  356. if trigger.Path == "" {
  357. return nil, errors.New("watch rules MUST define a path")
  358. }
  359. if trigger.Action == types.WatchActionRebuild && service.Build == nil {
  360. return nil, fmt.Errorf("service %s doesn't have a build section, can't apply %s on watch", types.WatchActionRebuild, service.Name)
  361. }
  362. if trigger.Action == types.WatchActionSyncExec && len(trigger.Exec.Command) == 0 {
  363. return nil, fmt.Errorf("can't watch with action %q on service %s without a command", types.WatchActionSyncExec, service.Name)
  364. }
  365. config.Watch[i] = trigger
  366. }
  367. return &config, nil
  368. }
  369. func checkIfPathAlreadyBindMounted(watchPath string, volumes []types.ServiceVolumeConfig) bool {
  370. for _, volume := range volumes {
  371. if volume.Bind != nil {
  372. relPath, err := filepath.Rel(volume.Source, watchPath)
  373. if err == nil && !strings.HasPrefix(relPath, "..") {
  374. return true
  375. }
  376. }
  377. }
  378. return false
  379. }
  380. type tarDockerClient struct {
  381. s *composeService
  382. }
  383. func (t tarDockerClient) ContainersForService(ctx context.Context, projectName string, serviceName string) ([]container.Summary, error) {
  384. containers, err := t.s.getContainers(ctx, projectName, oneOffExclude, true, serviceName)
  385. if err != nil {
  386. return nil, err
  387. }
  388. return containers, nil
  389. }
  390. func (t tarDockerClient) Exec(ctx context.Context, containerID string, cmd []string, in io.Reader) error {
  391. execCfg := container.ExecOptions{
  392. Cmd: cmd,
  393. AttachStdout: false,
  394. AttachStderr: true,
  395. AttachStdin: in != nil,
  396. Tty: false,
  397. }
  398. execCreateResp, err := t.s.apiClient().ContainerExecCreate(ctx, containerID, execCfg)
  399. if err != nil {
  400. return err
  401. }
  402. startCheck := container.ExecStartOptions{Tty: false, Detach: false}
  403. conn, err := t.s.apiClient().ContainerExecAttach(ctx, execCreateResp.ID, startCheck)
  404. if err != nil {
  405. return err
  406. }
  407. defer conn.Close()
  408. var eg errgroup.Group
  409. if in != nil {
  410. eg.Go(func() error {
  411. defer func() {
  412. _ = conn.CloseWrite()
  413. }()
  414. _, err := io.Copy(conn.Conn, in)
  415. return err
  416. })
  417. }
  418. eg.Go(func() error {
  419. _, err := io.Copy(t.s.stdinfo(), conn.Reader)
  420. return err
  421. })
  422. err = t.s.apiClient().ContainerExecStart(ctx, execCreateResp.ID, startCheck)
  423. if err != nil {
  424. return err
  425. }
  426. // although the errgroup is not tied directly to the context, the operations
  427. // in it are reading/writing to the connection, which is tied to the context,
  428. // so they won't block indefinitely
  429. if err := eg.Wait(); err != nil {
  430. return err
  431. }
  432. execResult, err := t.s.apiClient().ContainerExecInspect(ctx, execCreateResp.ID)
  433. if err != nil {
  434. return err
  435. }
  436. if execResult.Running {
  437. return errors.New("process still running")
  438. }
  439. if execResult.ExitCode != 0 {
  440. return fmt.Errorf("exit code %d", execResult.ExitCode)
  441. }
  442. return nil
  443. }
  444. func (t tarDockerClient) Untar(ctx context.Context, id string, archive io.ReadCloser) error {
  445. return t.s.apiClient().CopyToContainer(ctx, id, "/", archive, container.CopyToContainerOptions{
  446. CopyUIDGID: true,
  447. })
  448. }
  449. //nolint:gocyclo
  450. func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Project, options api.WatchOptions, batch []watch.FileEvent, rules []watchRule, syncer sync.Syncer) error {
  451. var (
  452. restart = map[string]bool{}
  453. syncfiles = map[string][]*sync.PathMapping{}
  454. exec = map[string][]int{}
  455. rebuild = map[string]bool{}
  456. )
  457. for _, event := range batch {
  458. for i, rule := range rules {
  459. mapping := rule.Matches(event)
  460. if mapping == nil {
  461. continue
  462. }
  463. switch rule.Action {
  464. case types.WatchActionRebuild:
  465. rebuild[rule.service] = true
  466. case types.WatchActionSync:
  467. syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
  468. case types.WatchActionRestart:
  469. restart[rule.service] = true
  470. case types.WatchActionSyncRestart:
  471. syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
  472. restart[rule.service] = true
  473. case types.WatchActionSyncExec:
  474. syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
  475. // We want to run exec hooks only once after syncfiles if multiple file events match
  476. // as we can't compare ServiceHook to sort and compact a slice, collect rule indexes
  477. exec[rule.service] = append(exec[rule.service], i)
  478. }
  479. }
  480. }
  481. logrus.Debugf("watch actions: rebuild %d sync %d restart %d", len(rebuild), len(syncfiles), len(restart))
  482. if len(rebuild) > 0 {
  483. err := s.rebuild(ctx, project, utils.MapKeys(rebuild), options)
  484. if err != nil {
  485. return err
  486. }
  487. }
  488. for serviceName, pathMappings := range syncfiles {
  489. writeWatchSyncMessage(options.LogTo, serviceName, pathMappings)
  490. err := syncer.Sync(ctx, serviceName, pathMappings)
  491. if err != nil {
  492. return err
  493. }
  494. }
  495. if len(restart) > 0 {
  496. services := utils.MapKeys(restart)
  497. err := s.restart(ctx, project.Name, api.RestartOptions{
  498. Services: services,
  499. Project: project,
  500. NoDeps: false,
  501. })
  502. if err != nil {
  503. return err
  504. }
  505. options.LogTo.Log(
  506. api.WatchLogger,
  507. fmt.Sprintf("service(s) %q restarted", services))
  508. }
  509. eg, ctx := errgroup.WithContext(ctx)
  510. for service, rulesToExec := range exec {
  511. slices.Sort(rulesToExec)
  512. for _, i := range slices.Compact(rulesToExec) {
  513. err := s.exec(ctx, project, service, rules[i].Exec, eg)
  514. if err != nil {
  515. return err
  516. }
  517. }
  518. }
  519. return eg.Wait()
  520. }
  521. func (s *composeService) exec(ctx context.Context, project *types.Project, serviceName string, x types.ServiceHook, eg *errgroup.Group) error {
  522. containers, err := s.getContainers(ctx, project.Name, oneOffExclude, false, serviceName)
  523. if err != nil {
  524. return err
  525. }
  526. for _, c := range containers {
  527. eg.Go(func() error {
  528. exec := ccli.NewExecOptions()
  529. exec.User = x.User
  530. exec.Privileged = x.Privileged
  531. exec.Command = x.Command
  532. exec.Workdir = x.WorkingDir
  533. for _, v := range x.Environment.ToMapping().Values() {
  534. err := exec.Env.Set(v)
  535. if err != nil {
  536. return err
  537. }
  538. }
  539. return ccli.RunExec(ctx, s.dockerCli, c.ID, exec)
  540. })
  541. }
  542. return nil
  543. }
  544. func (s *composeService) rebuild(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error {
  545. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Rebuilding service(s) %q after changes were detected...", services))
  546. // restrict the build to ONLY this service, not any of its dependencies
  547. options.Build.Services = services
  548. options.Build.Progress = progress.ModePlain
  549. options.Build.Out = cutils.GetWriter(func(line string) {
  550. options.LogTo.Log(api.WatchLogger, line)
  551. })
  552. var (
  553. imageNameToIdMap map[string]string
  554. err error
  555. )
  556. err = tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(ctx, project),
  557. func(ctx context.Context) error {
  558. imageNameToIdMap, err = s.build(ctx, project, *options.Build, nil)
  559. return err
  560. })(ctx)
  561. if err != nil {
  562. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Build failed. Error: %v", err))
  563. return err
  564. }
  565. if options.Prune {
  566. s.pruneDanglingImagesOnRebuild(ctx, project.Name, imageNameToIdMap)
  567. }
  568. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("service(s) %q successfully built", services))
  569. err = s.create(ctx, project, api.CreateOptions{
  570. Services: services,
  571. Inherit: true,
  572. Recreate: api.RecreateForce,
  573. })
  574. if err != nil {
  575. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Failed to recreate services after update. Error: %v", err))
  576. return err
  577. }
  578. p, err := project.WithSelectedServices(services, types.IncludeDependents)
  579. if err != nil {
  580. return err
  581. }
  582. err = s.start(ctx, project.Name, api.StartOptions{
  583. Project: p,
  584. Services: services,
  585. AttachTo: services,
  586. }, nil)
  587. if err != nil {
  588. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Application failed to start after update. Error: %v", err))
  589. }
  590. return nil
  591. }
  592. // writeWatchSyncMessage prints out a message about the sync for the changed paths.
  593. func writeWatchSyncMessage(log api.LogConsumer, serviceName string, pathMappings []*sync.PathMapping) {
  594. if logrus.IsLevelEnabled(logrus.DebugLevel) {
  595. hostPathsToSync := make([]string, len(pathMappings))
  596. for i := range pathMappings {
  597. hostPathsToSync[i] = pathMappings[i].HostPath
  598. }
  599. log.Log(
  600. api.WatchLogger,
  601. fmt.Sprintf(
  602. "Syncing service %q after changes were detected: %s",
  603. serviceName,
  604. strings.Join(hostPathsToSync, ", "),
  605. ),
  606. )
  607. } else {
  608. log.Log(
  609. api.WatchLogger,
  610. fmt.Sprintf("Syncing service %q after %d changes were detected", serviceName, len(pathMappings)),
  611. )
  612. }
  613. }
  614. func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, projectName string, imageNameToIdMap map[string]string) {
  615. images, err := s.apiClient().ImageList(ctx, image.ListOptions{
  616. Filters: filters.NewArgs(
  617. filters.Arg("dangling", "true"),
  618. filters.Arg("label", api.ProjectLabel+"="+projectName),
  619. ),
  620. })
  621. if err != nil {
  622. logrus.Debugf("Failed to list images: %v", err)
  623. return
  624. }
  625. for _, img := range images {
  626. if _, ok := imageNameToIdMap[img.ID]; !ok {
  627. _, err := s.apiClient().ImageRemove(ctx, img.ID, image.RemoveOptions{})
  628. if err != nil {
  629. logrus.Debugf("Failed to remove image %s: %v", img.ID, err)
  630. }
  631. }
  632. }
  633. }
  634. // Walks develop.watch.path and checks which files should be copied inside the container
  635. // ignores develop.watch.ignore, Dockerfile, compose files, bind mounted paths and .git
  636. func (s *composeService) initialSync(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, syncer sync.Syncer) error {
  637. dockerIgnores, err := watch.LoadDockerIgnore(service.Build)
  638. if err != nil {
  639. return err
  640. }
  641. dotGitIgnore, err := watch.NewDockerPatternMatcher("/", []string{".git/"})
  642. if err != nil {
  643. return err
  644. }
  645. triggerIgnore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
  646. if err != nil {
  647. return err
  648. }
  649. // FIXME .dockerignore
  650. ignoreInitialSync := watch.NewCompositeMatcher(
  651. dockerIgnores,
  652. watch.EphemeralPathMatcher(),
  653. dotGitIgnore,
  654. triggerIgnore)
  655. pathsToCopy, err := s.initialSyncFiles(ctx, project, service, trigger, ignoreInitialSync)
  656. if err != nil {
  657. return err
  658. }
  659. return syncer.Sync(ctx, service.Name, pathsToCopy)
  660. }
  661. // Syncs files from develop.watch.path if thy have been modified after the image has been created
  662. //
  663. //nolint:gocyclo
  664. func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) {
  665. fi, err := os.Stat(trigger.Path)
  666. if err != nil {
  667. return nil, err
  668. }
  669. timeImageCreated, err := s.imageCreatedTime(ctx, project, service.Name)
  670. if err != nil {
  671. return nil, err
  672. }
  673. var pathsToCopy []*sync.PathMapping
  674. switch mode := fi.Mode(); {
  675. case mode.IsDir():
  676. // process directory
  677. err = filepath.WalkDir(trigger.Path, func(path string, d fs.DirEntry, err error) error {
  678. if err != nil {
  679. // handle possible path err, just in case...
  680. return err
  681. }
  682. if trigger.Path == path {
  683. // walk starts at the root directory
  684. return nil
  685. }
  686. if shouldIgnore(filepath.Base(path), ignore) || checkIfPathAlreadyBindMounted(path, service.Volumes) {
  687. // By definition sync ignores bind mounted paths
  688. if d.IsDir() {
  689. // skip folder
  690. return fs.SkipDir
  691. }
  692. return nil // skip file
  693. }
  694. info, err := d.Info()
  695. if err != nil {
  696. return err
  697. }
  698. if !d.IsDir() {
  699. if info.ModTime().Before(timeImageCreated) {
  700. // skip file if it was modified before image creation
  701. return nil
  702. }
  703. rel, err := filepath.Rel(trigger.Path, path)
  704. if err != nil {
  705. return err
  706. }
  707. // only copy files (and not full directories)
  708. pathsToCopy = append(pathsToCopy, &sync.PathMapping{
  709. HostPath: path,
  710. ContainerPath: filepath.Join(trigger.Target, rel),
  711. })
  712. }
  713. return nil
  714. })
  715. case mode.IsRegular():
  716. // process file
  717. if fi.ModTime().After(timeImageCreated) && !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
  718. pathsToCopy = append(pathsToCopy, &sync.PathMapping{
  719. HostPath: trigger.Path,
  720. ContainerPath: trigger.Target,
  721. })
  722. }
  723. }
  724. return pathsToCopy, err
  725. }
  726. func shouldIgnore(name string, ignore watch.PathMatcher) bool {
  727. shouldIgnore, _ := ignore.Matches(name)
  728. // ignore files that match any ignore pattern
  729. return shouldIgnore
  730. }
  731. // gets the image creation time for a service
  732. func (s *composeService) imageCreatedTime(ctx context.Context, project *types.Project, serviceName string) (time.Time, error) {
  733. containers, err := s.apiClient().ContainerList(ctx, container.ListOptions{
  734. All: true,
  735. Filters: filters.NewArgs(
  736. filters.Arg("label", fmt.Sprintf("%s=%s", api.ProjectLabel, project.Name)),
  737. filters.Arg("label", fmt.Sprintf("%s=%s", api.ServiceLabel, serviceName))),
  738. })
  739. if err != nil {
  740. return time.Now(), err
  741. }
  742. if len(containers) == 0 {
  743. return time.Now(), fmt.Errorf("could not get created time for service's image")
  744. }
  745. img, err := s.apiClient().ImageInspect(ctx, containers[0].ImageID)
  746. if err != nil {
  747. return time.Now(), err
  748. }
  749. // Need to get the oldest one?
  750. timeCreated, err := time.Parse(time.RFC3339Nano, img.Created)
  751. if err != nil {
  752. return time.Now(), err
  753. }
  754. return timeCreated, nil
  755. }