watch.go 23 KB

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