watch.go 22 KB

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