watch.go 24 KB

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