Files
compose/pkg/compose/watch.go
T
Guillaume LoursandClaude Sonnet 5 467fa8ccc9 fix: share --parallel budget across down's ops and up's log-attach
down's outer ops dispatch bounded only how many ops ran concurrently,
separate from the semaphore already shared by image removal -- a
single network plus --rmi could spend up to 2x the configured
budget. Network and volume removal now share that same semaphore.

up's post-attach log streaming called ContainerInspect directly and
passed no limiter to doLogContainer, unlike `compose logs --follow`'s
equivalent path, so it never shared --parallel's budget either.

The plan executor also held a node's slot for its whole dependency
wait instead of just its own work, and internal/sync/tar.go's file
sync used a hardcoded cap of 16 instead of --parallel.

Signed-off-by: Guillaume Lours <[email protected]>
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-01 16:02:14 +02:00

908 lines
27 KiB
Go

/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package compose
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
gsync "sync"
"time"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/types"
"github.com/compose-spec/compose-go/v2/utils"
ccli "github.com/docker/cli/cli/command/container"
"github.com/go-viper/mapstructure/v2"
"github.com/moby/buildkit/util/progress/progressui"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image"
"github.com/moby/moby/client"
"github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
pathutil "github.com/docker/compose/v5/internal/paths"
"github.com/docker/compose/v5/internal/sync"
"github.com/docker/compose/v5/internal/tracing"
"github.com/docker/compose/v5/pkg/api"
cutils "github.com/docker/compose/v5/pkg/utils"
"github.com/docker/compose/v5/pkg/watch"
)
type WatchFunc func(ctx context.Context, project *types.Project, options api.WatchOptions) (func() error, error)
type Watcher struct {
project *types.Project
options api.WatchOptions
watchFn WatchFunc
stopFn func()
errCh chan error
}
func NewWatcher(project *types.Project, options api.UpOptions, w WatchFunc, consumer api.LogConsumer) (*Watcher, error) {
for i := range project.Services {
service := project.Services[i]
if service.Develop != nil && service.Develop.Watch != nil {
build := options.Create.Build
return &Watcher{
project: project,
options: api.WatchOptions{
LogTo: consumer,
Build: build,
},
watchFn: w,
errCh: make(chan error),
}, nil
}
}
// none of the services is eligible to watch
return nil, errors.New("none of the selected services is configured for watch, see https://docs.docker.com/compose/how-tos/file-watch/")
}
// ensure state changes are atomic
var mx gsync.Mutex
func (w *Watcher) Start(ctx context.Context) error {
mx.Lock()
defer mx.Unlock()
ctx, cancelFunc := context.WithCancel(ctx)
w.stopFn = cancelFunc
wait, err := w.watchFn(ctx, w.project, w.options)
if err != nil {
go func() {
w.errCh <- err
}()
return err
}
go func() {
w.errCh <- wait()
}()
return nil
}
func (w *Watcher) Stop() error {
mx.Lock()
defer mx.Unlock()
if w.stopFn == nil {
return nil
}
w.stopFn()
w.stopFn = nil
err := <-w.errCh
return err
}
// getSyncImplementation returns an appropriate sync implementation for the
// project.
//
// Currently, an implementation that batches files and transfers them using
// the Moby `Untar` API.
func (s *composeService) getSyncImplementation(project *types.Project) (sync.Syncer, error) {
var useTar bool
if useTarEnv, ok := os.LookupEnv("COMPOSE_EXPERIMENTAL_WATCH_TAR"); ok {
useTar, _ = strconv.ParseBool(useTarEnv)
} else {
useTar = true
}
if !useTar {
return nil, errors.New("no available sync implementation")
}
return sync.NewTar(project.Name, tarDockerClient{s: s}, s.maxConcurrency), nil
}
func (s *composeService) Watch(ctx context.Context, project *types.Project, options api.WatchOptions) error {
wait, err := s.watch(ctx, project, options)
if err != nil {
return err
}
return wait()
}
type watchRule struct {
types.Trigger
include watch.PathMatcher
ignore watch.PathMatcher
service string
}
func (r watchRule) Matches(event watch.FileEvent) *sync.PathMapping {
hostPath := string(event)
if !pathutil.IsChild(r.Path, hostPath) {
return nil
}
included, err := r.include.Matches(hostPath)
if err != nil {
logrus.Warnf("error include matching %q: %v", hostPath, err)
return nil
}
if !included {
logrus.Debugf("%s is not matching include pattern", hostPath)
return nil
}
isIgnored, err := r.ignore.Matches(hostPath)
if err != nil {
logrus.Warnf("error ignore matching %q: %v", hostPath, err)
return nil
}
if isIgnored {
logrus.Debugf("%s is matching ignore pattern", hostPath)
return nil
}
var containerPath string
if r.Target != "" {
rel, err := filepath.Rel(r.Path, hostPath)
if err != nil {
logrus.Warnf("error making %s relative to %s: %v", hostPath, r.Path, err)
return nil
}
// always use Unix-style paths for inside the container
containerPath = path.Join(r.Target, filepath.ToSlash(rel))
}
return &sync.PathMapping{
HostPath: hostPath,
ContainerPath: containerPath,
}
}
func (s *composeService) watch(ctx context.Context, project *types.Project, options api.WatchOptions) (func() error, error) {
var err error
if project, err = project.WithSelectedServices(options.Services); err != nil {
return nil, err
}
syncer, err := s.getSyncImplementation(project)
if err != nil {
return nil, err
}
// exactly one goroutine is ever submitted to eg (below); the real
// --parallel bound for watch is handleWatchBatch's per-batch fan-out.
eg, ctx := errgroup.WithContext(ctx)
var (
rules []watchRule
paths []string
)
for serviceName, service := range project.Services {
config, err := loadDevelopmentConfig(service, project)
if err != nil {
return nil, err
}
if service.Develop != nil {
config = service.Develop
}
if config == nil {
continue
}
service, err = prepareRebuildTriggers(project, serviceName, service, config, options)
if err != nil {
return nil, err
}
triggerPaths, err := s.watchTriggerPaths(ctx, service, config, syncer)
if err != nil {
return nil, err
}
paths = append(paths, triggerPaths...)
serviceWatchRules, err := getWatchRules(config, service)
if err != nil {
return nil, err
}
rules = append(rules, serviceWatchRules...)
}
if len(paths) == 0 {
return nil, errors.New("none of the selected services is configured for watch, consider setting a 'develop' section")
}
watcher, err := watch.NewWatcher(paths)
if err != nil {
return nil, err
}
err = watcher.Start()
if err != nil {
return nil, err
}
eg.Go(func() error {
return s.watchEvents(ctx, project, options, watcher, syncer, rules)
})
options.LogTo.Log(api.WatchLogger, "Watch enabled")
return func() error {
err := eg.Wait()
if werr := watcher.Close(); werr != nil {
logrus.Debugf("Error closing Watcher: %v", werr)
}
return err
}, nil
}
// prepareRebuildTriggers validates rebuild watch actions and marks the
// service to always be built — watch triggers `Up()` when it receives a
// rebuild event. It returns the possibly-updated service config.
func prepareRebuildTriggers(project *types.Project, serviceName string, service types.ServiceConfig, config *types.DevelopConfig, options api.WatchOptions) (types.ServiceConfig, error) {
for _, trigger := range config.Watch {
if trigger.Action != types.WatchActionRebuild {
continue
}
if service.Build == nil {
return service, fmt.Errorf("can't watch service %q with action %s without a build context", service.Name, types.WatchActionRebuild)
}
if options.Build == nil {
return service, fmt.Errorf("--no-build is incompatible with watch action %s in service %s", types.WatchActionRebuild, service.Name)
}
service.PullPolicy = types.PullPolicyBuild
project.Services[serviceName] = service
}
return service, nil
}
// watchTriggerPaths collects the paths to monitor for a service, skipping
// (with a warning) those already covered by a bind mount volume, and runs the
// initial sync for sync triggers requesting one.
func (s *composeService) watchTriggerPaths(ctx context.Context, service types.ServiceConfig, config *types.DevelopConfig, syncer sync.Syncer) ([]string, error) {
var paths []string
for _, trigger := range config.Watch {
if isSync(trigger) && checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
logrus.Warnf("path '%s' also declared by a bind mount volume, this path won't be monitored!\n", trigger.Path)
continue
}
if initialSyncRequested(trigger) && isSync(trigger) {
// Need to check that initial files meant to be synced from the watch action are in the container
err := s.initialSync(ctx, service, trigger, syncer)
if err != nil {
return nil, err
}
}
paths = append(paths, trigger.Path)
}
return paths, nil
}
// initialSyncRequested tells whether a sync trigger requests an initial sync,
// honoring the DEPRECATED x-initialSync extension attribute
func initialSyncRequested(trigger types.Trigger) bool {
if trigger.InitialSync {
return true
}
var legacyInitialSync bool
success, err := trigger.Extensions.Get("x-initialSync", &legacyInitialSync)
if err == nil && success && legacyInitialSync {
logrus.Warnf("x-initialSync is DEPRECATED, please use the official `initial_sync` attribute\n")
return true
}
return false
}
func getWatchRules(config *types.DevelopConfig, service types.ServiceConfig) ([]watchRule, error) {
var rules []watchRule
dockerIgnores, err := watch.LoadDockerIgnore(service.Build)
if err != nil {
return nil, err
}
// add a hardcoded set of ignores on top of what came from .dockerignore
// some of this should likely be configurable (e.g. there could be cases
// where you want `.git` to be synced) but this is suitable for now
dotGitIgnore, err := watch.NewDockerPatternMatcher("/", []string{".git/"})
if err != nil {
return nil, err
}
for _, trigger := range config.Watch {
ignore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
if err != nil {
return nil, err
}
var include watch.PathMatcher
if len(trigger.Include) == 0 {
include = watch.AnyMatcher{}
} else {
include, err = watch.NewDockerPatternMatcher(trigger.Path, trigger.Include)
if err != nil {
return nil, err
}
}
rules = append(rules, watchRule{
Trigger: trigger,
include: include,
ignore: watch.NewCompositeMatcher(
dockerIgnores,
watch.EphemeralPathMatcher(),
dotGitIgnore,
ignore,
),
service: service.Name,
})
}
return rules, nil
}
func isSync(trigger types.Trigger) bool {
return trigger.Action == types.WatchActionSync || trigger.Action == types.WatchActionSyncRestart
}
func (s *composeService) watchEvents(ctx context.Context, project *types.Project, options api.WatchOptions, watcher watch.Notify, syncer sync.Syncer, rules []watchRule) error {
ctx, cancel := context.WithCancel(ctx)
// debounce and group filesystem events so that we capture IDE saving many files as one "batch" event
batchEvents := watch.BatchDebounceEvents(ctx, watcher.Events())
scheduler := newRebuildScheduler(ctx, func(ctx context.Context, services []string) error {
return s.rebuild(ctx, project, services, options)
})
// Rebuilds run asynchronously in their own goroutine(s); cancel first so
// an in-flight one can unwind, then wait for it to settle so none
// outlive this function.
defer scheduler.Wait()
defer cancel()
for {
select {
case <-ctx.Done():
options.LogTo.Log(api.WatchLogger, "Watch disabled")
// Ensure watcher is closed to release resources
_ = watcher.Close()
return nil
case err, open := <-watcher.Errors():
if err != nil {
options.LogTo.Err(api.WatchLogger, "Watch disabled with errors: "+err.Error())
}
if open {
continue
}
_ = watcher.Close()
return err
case batch, ok := <-batchEvents:
if !ok {
options.LogTo.Log(api.WatchLogger, "Watch disabled")
_ = watcher.Close()
return nil
}
if len(batch) > 1000 {
logrus.Warnf("Very large batch of file changes detected: %d files. This may impact performance.", len(batch))
options.LogTo.Log(api.WatchLogger, "Large batch of file changes detected. If you just switched branches, this is expected.")
}
start := time.Now()
logrus.Debugf("batch start: count[%d]", len(batch))
err := s.handleWatchBatch(ctx, project, options, batch, rules, syncer, scheduler)
if err != nil {
logrus.Warnf("Error handling changed files: %v", err)
// If context was canceled, exit immediately
if ctx.Err() != nil {
_ = watcher.Close()
return ctx.Err()
}
}
logrus.Debugf("batch complete: duration[%s] count[%d]", time.Since(start), len(batch))
}
}
}
func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project) (*types.DevelopConfig, error) {
var config types.DevelopConfig
y, ok := service.Extensions["x-develop"]
if !ok {
return nil, nil
}
logrus.Warnf("x-develop is DEPRECATED, please use the official `develop` attribute")
err := mapstructure.Decode(y, &config)
if err != nil {
return nil, err
}
baseDir, err := filepath.EvalSymlinks(project.WorkingDir)
if err != nil {
return nil, fmt.Errorf("resolving symlink for %q: %w", project.WorkingDir, err)
}
for i, trigger := range config.Watch {
if !filepath.IsAbs(trigger.Path) {
trigger.Path = filepath.Join(baseDir, trigger.Path)
}
if p, err := filepath.EvalSymlinks(trigger.Path); err == nil {
// this might fail because the path doesn't exist, etc.
trigger.Path = p
}
trigger.Path = filepath.Clean(trigger.Path)
if trigger.Path == "" {
return nil, errors.New("watch rules MUST define a path")
}
if trigger.Action == types.WatchActionRebuild && service.Build == nil {
return nil, fmt.Errorf("service %s doesn't have a build section, can't apply %s on watch", types.WatchActionRebuild, service.Name)
}
if trigger.Action == types.WatchActionSyncExec && len(trigger.Exec.Command) == 0 {
return nil, fmt.Errorf("can't watch with action %q on service %s without a command", types.WatchActionSyncExec, service.Name)
}
config.Watch[i] = trigger
}
return &config, nil
}
func checkIfPathAlreadyBindMounted(watchPath string, volumes []types.ServiceVolumeConfig) bool {
for _, volume := range volumes {
if volume.Bind != nil {
relPath, err := filepath.Rel(volume.Source, watchPath)
if err == nil && !strings.HasPrefix(relPath, "..") {
return true
}
}
}
return false
}
type tarDockerClient struct {
s *composeService
}
func (t tarDockerClient) ContainersForService(ctx context.Context, projectName string, serviceName string) ([]container.Summary, error) {
containers, err := t.s.getContainers(ctx, projectName, oneOffExclude, true, serviceName)
if err != nil {
return nil, err
}
return containers, nil
}
func (t tarDockerClient) Exec(ctx context.Context, containerID string, cmd []string, in io.Reader) error {
execCreateResp, err := t.s.apiClient().ExecCreate(ctx, containerID, client.ExecCreateOptions{
Cmd: cmd,
AttachStdout: false,
AttachStderr: true,
AttachStdin: in != nil,
TTY: false,
})
if err != nil {
return err
}
conn, err := t.s.apiClient().ExecAttach(ctx, execCreateResp.ID, client.ExecAttachOptions{
TTY: false,
})
if err != nil {
return err
}
defer conn.Close()
var eg errgroup.Group
if in != nil {
eg.Go(func() error {
defer func() {
_ = conn.CloseWrite()
}()
_, err := io.Copy(conn.Conn, in)
return err
})
}
eg.Go(func() error {
_, err := io.Copy(t.s.stdout(), conn.Reader)
return err
})
_, err = t.s.apiClient().ExecStart(ctx, execCreateResp.ID, client.ExecStartOptions{
TTY: false,
Detach: false,
})
if err != nil {
return err
}
// although the errgroup is not tied directly to the context, the operations
// in it are reading/writing to the connection, which is tied to the context,
// so they won't block indefinitely
if err := eg.Wait(); err != nil {
return err
}
execResult, err := t.s.apiClient().ExecInspect(ctx, execCreateResp.ID, client.ExecInspectOptions{})
if err != nil {
return err
}
if execResult.Running {
return errors.New("process still running")
}
if execResult.ExitCode != 0 {
return errors.New("exit code " + strconv.Itoa(execResult.ExitCode))
}
return nil
}
func (t tarDockerClient) Untar(ctx context.Context, id string, archive io.ReadCloser) error {
_, err := t.s.apiClient().CopyToContainer(ctx, id, client.CopyToContainerOptions{
DestinationPath: "/",
Content: archive,
CopyUIDGID: true,
})
return err
}
func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Project, options api.WatchOptions,
batch []watch.FileEvent, rules []watchRule, syncer sync.Syncer, scheduler *rebuildScheduler,
) error {
var (
restart = map[string]bool{}
syncfiles = map[string][]*sync.PathMapping{}
exec = map[string][]int{}
rebuild = map[string]bool{}
)
for _, event := range batch {
for i, rule := range rules {
mapping := rule.Matches(event)
if mapping == nil {
continue
}
switch rule.Action {
case types.WatchActionRebuild:
rebuild[rule.service] = true
case types.WatchActionSync:
syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
case types.WatchActionRestart:
restart[rule.service] = true
case types.WatchActionSyncRestart:
syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
restart[rule.service] = true
case types.WatchActionSyncExec:
syncfiles[rule.service] = append(syncfiles[rule.service], mapping)
// We want to run exec hooks only once after syncfiles if multiple file events match
// as we can't compare ServiceHook to sort and compact a slice, collect rule indexes
exec[rule.service] = append(exec[rule.service], i)
}
}
}
if len(rebuild) > 0 {
scheduler.Request(utils.MapKeys(rebuild))
}
// A rebuild already recreates and starts the service, so a separate
// restart is redundant at best and races the rebuild's create/start at
// worst. Which rebuild matters:
// - pending: it will snapshot the build context after this change —
// drop the restart, the rebuild converges on its own;
// - in flight: its snapshot predates this change, so its outcome is
// stale — fold the restart into a Request, which interrupts the
// doomed run and rebuilds from a fresh snapshot (recreate + start:
// the restart intent, converged).
for service := range restart {
switch {
case scheduler.Pending(service):
logrus.Debugf("skipping restart for service %q: rebuild pending", service)
delete(restart, service)
case scheduler.InFlight(service):
logrus.Debugf("turning restart for service %q into a rebuild: stale rebuild in flight", service)
scheduler.Request([]string{service})
delete(restart, service)
}
}
logrus.Debugf("watch actions: rebuild %d sync %d restart %d", len(rebuild), len(syncfiles), len(restart))
// A sync or exec below may still target a service whose container is
// being replaced by an in-flight asynchronous rebuild (from this batch or
// an earlier one): unlike restart, this is a conscious tradeoff, since
// syncer.Sync/exec resolve containers by lookup and simply fail loudly
// (rather than racing the container lifecycle) if the container is gone.
for serviceName, pathMappings := range syncfiles {
writeWatchSyncMessage(options.LogTo, serviceName, pathMappings)
err := syncer.Sync(ctx, serviceName, pathMappings)
if err != nil {
return err
}
}
if len(restart) > 0 {
services := utils.MapKeys(restart)
err := s.restart(ctx, project.Name, api.RestartOptions{
Services: services,
Project: project,
NoDeps: false,
})
if err != nil {
return err
}
options.LogTo.Log(
api.WatchLogger,
fmt.Sprintf("service(s) %q restarted", services))
}
eg, ctx := newLimitedErrgroup(ctx, s.maxConcurrency)
for service, rulesToExec := range exec {
slices.Sort(rulesToExec)
for _, i := range slices.Compact(rulesToExec) {
err := s.exec(ctx, project, service, rules[i].Exec, eg)
if err != nil {
return err
}
}
}
return eg.Wait()
}
func (s *composeService) exec(ctx context.Context, project *types.Project, serviceName string, x types.ServiceHook, eg *errgroup.Group) error {
containers, err := s.getContainers(ctx, project.Name, oneOffExclude, false, serviceName)
if err != nil {
return err
}
for _, ctr := range containers {
eg.Go(func() error {
exec := ccli.NewExecOptions()
exec.User = x.User
exec.Privileged = x.Privileged
exec.Command = x.Command
exec.Workdir = x.WorkingDir
exec.DetachKeys = s.configFile().DetachKeys
for _, v := range x.Environment.ToMapping().Values() {
err := exec.Env.Set(v)
if err != nil {
return err
}
}
return ccli.RunExec(ctx, s.dockerCli, ctr.ID, exec)
})
}
return nil
}
func (s *composeService) rebuild(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error {
options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Rebuilding service(s) %q after changes were detected...", services))
// Work on a copy so concurrent watch events don't race on the shared
// BuildOptions pointer carried by WatchOptions.
buildOpts := *options.Build
// Restrict the build to ONLY the watched services, not any of their
// dependencies. `up --build` sets Deps=true so initial startup builds
// images for depends_on services; here we must reset it, otherwise a
// rebuild for service A cascades to its upstream dependency B.
buildOpts.Services = services
buildOpts.Deps = false
buildOpts.Progress = string(progressui.PlainMode)
buildOpts.Out = cutils.GetWriter(func(line string) {
options.LogTo.Log(api.WatchLogger, line)
})
var (
imageNameToIdMap map[string]string
err error
)
err = tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(ctx, project),
func(ctx context.Context) error {
imageNameToIdMap, err = s.build(ctx, project, buildOpts, nil)
return err
})(ctx)
if err != nil {
options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Build failed. Error: %v", err))
return err
}
if options.Prune {
s.pruneDanglingImagesOnRebuild(ctx, project.Name, imageNameToIdMap)
}
options.LogTo.Log(api.WatchLogger, fmt.Sprintf("service(s) %q successfully built", services))
err = s.create(ctx, project, api.CreateOptions{
Services: services,
Inherit: true,
Recreate: api.RecreateForce,
SkipProviders: true,
})
if err != nil {
options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Failed to recreate services after update. Error: %v", err))
return err
}
p, err := project.WithSelectedServices(services, types.IncludeDependents)
if err != nil {
return err
}
err = s.start(ctx, project.Name, api.StartOptions{
Project: p,
Services: services,
AttachTo: services,
}, nil)
if err != nil {
options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Application failed to start after update. Error: %v", err))
}
return nil
}
// writeWatchSyncMessage prints out a message about the sync for the changed paths.
func writeWatchSyncMessage(log api.LogConsumer, serviceName string, pathMappings []*sync.PathMapping) {
if logrus.IsLevelEnabled(logrus.DebugLevel) {
hostPathsToSync := make([]string, len(pathMappings))
for i := range pathMappings {
hostPathsToSync[i] = pathMappings[i].HostPath
}
log.Log(
api.WatchLogger,
fmt.Sprintf(
"Syncing service %q after changes were detected: %s",
serviceName,
strings.Join(hostPathsToSync, ", "),
),
)
} else {
log.Log(
api.WatchLogger,
fmt.Sprintf("Syncing service %q after %d changes were detected", serviceName, len(pathMappings)),
)
}
}
func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, projectName string, imageNameToIdMap map[string]string) {
// imageNameToIdMap is keyed by image name; the freshly built images to
// spare are its VALUES (image IDs), matched against the dangling IDs
builtIDs := make(map[string]struct{}, len(imageNameToIdMap))
for _, id := range imageNameToIdMap {
builtIDs[id] = struct{}{}
}
keep := func(img image.Summary) bool {
_, ok := builtIDs[img.ID]
return ok
}
if _, err := s.removeDanglingImages(ctx, projectName, keep); err != nil {
logrus.Debugf("Failed to prune dangling images: %v", err)
}
}
// Walks develop.watch.path and checks which files should be copied inside the container
// ignores develop.watch.ignore, Dockerfile, compose files, bind mounted paths and .git
func (s *composeService) initialSync(ctx context.Context, service types.ServiceConfig, trigger types.Trigger, syncer sync.Syncer) error {
dockerIgnores, err := watch.LoadDockerIgnore(service.Build)
if err != nil {
return err
}
dotGitIgnore, err := watch.NewDockerPatternMatcher("/", []string{".git/"})
if err != nil {
return err
}
triggerIgnore, err := watch.NewDockerPatternMatcher(trigger.Path, trigger.Ignore)
if err != nil {
return err
}
// also exclude override compose files and any custom-named Dockerfile
dockerFilePatterns := append([]string{"Dockerfile"}, cli.DefaultFileNames...)
dockerFilePatterns = append(dockerFilePatterns, cli.DefaultOverrideFileNames...)
if service.Build != nil && service.Build.Dockerfile != "" {
dockerFilePatterns = append(dockerFilePatterns, filepath.Base(service.Build.Dockerfile))
}
dockerFileIgnore, err := watch.NewDockerPatternMatcher("/", dockerFilePatterns)
if err != nil {
return err
}
ignoreInitialSync := watch.NewCompositeMatcher(
dockerIgnores,
watch.EphemeralPathMatcher(),
dotGitIgnore,
dockerFileIgnore,
triggerIgnore)
pathsToCopy, err := s.initialSyncFiles(service, trigger, ignoreInitialSync)
if err != nil {
return err
}
return syncer.Sync(ctx, service.Name, pathsToCopy)
}
// Syncs files from develop.watch.path, ignoring bind-mounted and excluded paths.
func (s *composeService) initialSyncFiles(service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) {
fi, err := os.Stat(trigger.Path)
if err != nil {
return nil, err
}
switch mode := fi.Mode(); {
case mode.IsDir():
// process directory
return initialSyncDirectory(trigger, service, ignore)
case mode.IsRegular():
// process file
if !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
return []*sync.PathMapping{{
HostPath: trigger.Path,
ContainerPath: trigger.Target,
}}, nil
}
}
return nil, nil
}
// initialSyncDirectory collects the files of a watched directory, skipping
// ignored and bind-mounted paths.
func initialSyncDirectory(trigger types.Trigger, service types.ServiceConfig, ignore watch.PathMatcher) ([]*sync.PathMapping, error) {
var pathsToCopy []*sync.PathMapping
err := filepath.WalkDir(trigger.Path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// handle possible path err, just in case...
return err
}
if trigger.Path == path {
// walk starts at the root directory
return nil
}
if shouldIgnore(filepath.Base(path), ignore) || checkIfPathAlreadyBindMounted(path, service.Volumes) {
// By definition sync ignores bind mounted paths
if d.IsDir() {
// skip folder
return fs.SkipDir
}
return nil // skip file
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(trigger.Path, path)
if err != nil {
return err
}
// only copy files (and not full directories)
pathsToCopy = append(pathsToCopy, &sync.PathMapping{
HostPath: path,
ContainerPath: filepath.Join(trigger.Target, rel),
})
return nil
})
return pathsToCopy, err
}
func shouldIgnore(name string, ignore watch.PathMatcher) bool {
shouldIgnore, _ := ignore.Matches(name)
// ignore files that match any ignore pattern
return shouldIgnore
}