watch.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840
  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. 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.stdinfo(), 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. for _, v := range x.Environment.ToMapping().Values() {
  537. err := exec.Env.Set(v)
  538. if err != nil {
  539. return err
  540. }
  541. }
  542. return ccli.RunExec(ctx, s.dockerCli, c.ID, exec)
  543. })
  544. }
  545. return nil
  546. }
  547. func (s *composeService) rebuild(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error {
  548. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Rebuilding service(s) %q after changes were detected...", services))
  549. // restrict the build to ONLY this service, not any of its dependencies
  550. options.Build.Services = services
  551. options.Build.Progress = progress.ModePlain
  552. options.Build.Out = cutils.GetWriter(func(line string) {
  553. options.LogTo.Log(api.WatchLogger, line)
  554. })
  555. var (
  556. imageNameToIdMap map[string]string
  557. err error
  558. )
  559. err = tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(ctx, project),
  560. func(ctx context.Context) error {
  561. imageNameToIdMap, err = s.build(ctx, project, *options.Build, nil)
  562. return err
  563. })(ctx)
  564. if err != nil {
  565. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Build failed. Error: %v", err))
  566. return err
  567. }
  568. if options.Prune {
  569. s.pruneDanglingImagesOnRebuild(ctx, project.Name, imageNameToIdMap)
  570. }
  571. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("service(s) %q successfully built", services))
  572. err = s.create(ctx, project, api.CreateOptions{
  573. Services: services,
  574. Inherit: true,
  575. Recreate: api.RecreateForce,
  576. })
  577. if err != nil {
  578. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Failed to recreate services after update. Error: %v", err))
  579. return err
  580. }
  581. p, err := project.WithSelectedServices(services, types.IncludeDependents)
  582. if err != nil {
  583. return err
  584. }
  585. err = s.start(ctx, project.Name, api.StartOptions{
  586. Project: p,
  587. Services: services,
  588. AttachTo: services,
  589. }, nil)
  590. if err != nil {
  591. options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Application failed to start after update. Error: %v", err))
  592. }
  593. return nil
  594. }
  595. // writeWatchSyncMessage prints out a message about the sync for the changed paths.
  596. func writeWatchSyncMessage(log api.LogConsumer, serviceName string, pathMappings []*sync.PathMapping) {
  597. if logrus.IsLevelEnabled(logrus.DebugLevel) {
  598. hostPathsToSync := make([]string, len(pathMappings))
  599. for i := range pathMappings {
  600. hostPathsToSync[i] = pathMappings[i].HostPath
  601. }
  602. log.Log(
  603. api.WatchLogger,
  604. fmt.Sprintf(
  605. "Syncing service %q after changes were detected: %s",
  606. serviceName,
  607. strings.Join(hostPathsToSync, ", "),
  608. ),
  609. )
  610. } else {
  611. log.Log(
  612. api.WatchLogger,
  613. fmt.Sprintf("Syncing service %q after %d changes were detected", serviceName, len(pathMappings)),
  614. )
  615. }
  616. }
  617. func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, projectName string, imageNameToIdMap map[string]string) {
  618. images, err := s.apiClient().ImageList(ctx, image.ListOptions{
  619. Filters: filters.NewArgs(
  620. filters.Arg("dangling", "true"),
  621. filters.Arg("label", api.ProjectLabel+"="+projectName),
  622. ),
  623. })
  624. if err != nil {
  625. logrus.Debugf("Failed to list images: %v", err)
  626. return
  627. }
  628. for _, img := range images {
  629. if _, ok := imageNameToIdMap[img.ID]; !ok {
  630. _, err := s.apiClient().ImageRemove(ctx, img.ID, image.RemoveOptions{})
  631. if err != nil {
  632. logrus.Debugf("Failed to remove image %s: %v", img.ID, err)
  633. }
  634. }
  635. }
  636. }
  637. // Walks develop.watch.path and checks which files should be copied inside the container
  638. // ignores develop.watch.ignore, Dockerfile, compose files, bind mounted paths and .git
  639. func (s *composeService) initialSync(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, syncer sync.Syncer) error {
  640. dockerIgnores, err := watch.LoadDockerIgnore(service.Build)
  641. if err != nil {
  642. return err
  643. }
  644. dotGitIgnore, err := watch.NewDockerPatternMatcher("/", []string{".git/"})
  645. if err != nil {
  646. return err
  647. }
  648. triggerIgnore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
  649. if err != nil {
  650. return err
  651. }
  652. // FIXME .dockerignore
  653. ignoreInitialSync := watch.NewCompositeMatcher(
  654. dockerIgnores,
  655. watch.EphemeralPathMatcher(),
  656. dotGitIgnore,
  657. triggerIgnore)
  658. pathsToCopy, err := s.initialSyncFiles(ctx, project, service, trigger, ignoreInitialSync)
  659. if err != nil {
  660. return err
  661. }
  662. return syncer.Sync(ctx, service.Name, pathsToCopy)
  663. }
  664. // Syncs files from develop.watch.path if thy have been modified after the image has been created
  665. //
  666. //nolint:gocyclo
  667. func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) {
  668. fi, err := os.Stat(trigger.Path)
  669. if err != nil {
  670. return nil, err
  671. }
  672. timeImageCreated, err := s.imageCreatedTime(ctx, project, service.Name)
  673. if err != nil {
  674. return nil, err
  675. }
  676. var pathsToCopy []*sync.PathMapping
  677. switch mode := fi.Mode(); {
  678. case mode.IsDir():
  679. // process directory
  680. err = filepath.WalkDir(trigger.Path, func(path string, d fs.DirEntry, err error) error {
  681. if err != nil {
  682. // handle possible path err, just in case...
  683. return err
  684. }
  685. if trigger.Path == path {
  686. // walk starts at the root directory
  687. return nil
  688. }
  689. if shouldIgnore(filepath.Base(path), ignore) || checkIfPathAlreadyBindMounted(path, service.Volumes) {
  690. // By definition sync ignores bind mounted paths
  691. if d.IsDir() {
  692. // skip folder
  693. return fs.SkipDir
  694. }
  695. return nil // skip file
  696. }
  697. info, err := d.Info()
  698. if err != nil {
  699. return err
  700. }
  701. if !d.IsDir() {
  702. if info.ModTime().Before(timeImageCreated) {
  703. // skip file if it was modified before image creation
  704. return nil
  705. }
  706. rel, err := filepath.Rel(trigger.Path, path)
  707. if err != nil {
  708. return err
  709. }
  710. // only copy files (and not full directories)
  711. pathsToCopy = append(pathsToCopy, &sync.PathMapping{
  712. HostPath: path,
  713. ContainerPath: filepath.Join(trigger.Target, rel),
  714. })
  715. }
  716. return nil
  717. })
  718. case mode.IsRegular():
  719. // process file
  720. if fi.ModTime().After(timeImageCreated) && !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
  721. pathsToCopy = append(pathsToCopy, &sync.PathMapping{
  722. HostPath: trigger.Path,
  723. ContainerPath: trigger.Target,
  724. })
  725. }
  726. }
  727. return pathsToCopy, err
  728. }
  729. func shouldIgnore(name string, ignore watch.PathMatcher) bool {
  730. shouldIgnore, _ := ignore.Matches(name)
  731. // ignore files that match any ignore pattern
  732. return shouldIgnore
  733. }
  734. // gets the image creation time for a service
  735. func (s *composeService) imageCreatedTime(ctx context.Context, project *types.Project, serviceName string) (time.Time, error) {
  736. containers, err := s.apiClient().ContainerList(ctx, container.ListOptions{
  737. All: true,
  738. Filters: filters.NewArgs(
  739. filters.Arg("label", fmt.Sprintf("%s=%s", api.ProjectLabel, project.Name)),
  740. filters.Arg("label", fmt.Sprintf("%s=%s", api.ServiceLabel, serviceName))),
  741. })
  742. if err != nil {
  743. return time.Now(), err
  744. }
  745. if len(containers) == 0 {
  746. return time.Now(), fmt.Errorf("could not get created time for service's image")
  747. }
  748. img, err := s.apiClient().ImageInspect(ctx, containers[0].ImageID)
  749. if err != nil {
  750. return time.Now(), err
  751. }
  752. // Need to get the oldest one?
  753. timeCreated, err := time.Parse(time.RFC3339Nano, img.Created)
  754. if err != nil {
  755. return time.Now(), err
  756. }
  757. return timeCreated, nil
  758. }