watch.go 22 KB

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