watch.go 22 KB

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