watch.go 23 KB

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