Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 6 additions & 45 deletions pkg/compose/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, opti

if shouldInitialSync && isSync(trigger) {
// Need to check initial files are in container that are meant to be synced from watch action
err := s.initialSync(ctx, project, service, trigger, syncer)
err := s.initialSync(ctx, service, trigger, syncer)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -730,7 +730,7 @@ func (s *composeService) pruneDanglingImagesOnRebuild(ctx context.Context, proje

// 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, project *types.Project, service types.ServiceConfig, trigger types.Trigger, syncer sync.Syncer) error {
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
Expand All @@ -752,26 +752,20 @@ func (s *composeService) initialSync(ctx context.Context, project *types.Project
dotGitIgnore,
triggerIgnore)

pathsToCopy, err := s.initialSyncFiles(ctx, project, service, trigger, ignoreInitialSync)
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 if thy have been modified after the image has been created
//
//nolint:gocyclo
func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Project, service types.ServiceConfig, trigger types.Trigger, ignore watch.PathMatcher) ([]*sync.PathMapping, error) {
// 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
}
timeImageCreated, err := s.imageCreatedTime(ctx, project, service.Name)
if err != nil {
return nil, err
}
var pathsToCopy []*sync.PathMapping
switch mode := fi.Mode(); {
case mode.IsDir():
Expand All @@ -793,15 +787,7 @@ func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Pr
}
return nil // skip file
}
info, err := d.Info()
if err != nil {
return err
}
if !d.IsDir() {
if info.ModTime().Before(timeImageCreated) {
// skip file if it was modified before image creation
return nil
}
rel, err := filepath.Rel(trigger.Path, path)
if err != nil {
return err
Expand All @@ -816,7 +802,7 @@ func (s *composeService) initialSyncFiles(ctx context.Context, project *types.Pr
})
case mode.IsRegular():
// process file
if fi.ModTime().After(timeImageCreated) && !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
if !shouldIgnore(filepath.Base(trigger.Path), ignore) && !checkIfPathAlreadyBindMounted(trigger.Path, service.Volumes) {
pathsToCopy = append(pathsToCopy, &sync.PathMapping{
HostPath: trigger.Path,
ContainerPath: trigger.Target,
Expand All @@ -831,28 +817,3 @@ func shouldIgnore(name string, ignore watch.PathMatcher) bool {
// ignore files that match any ignore pattern
return shouldIgnore
}

// gets the image creation time for a service
func (s *composeService) imageCreatedTime(ctx context.Context, project *types.Project, serviceName string) (time.Time, error) {
res, err := s.apiClient().ContainerList(ctx, client.ContainerListOptions{
All: true,
Filters: projectFilter(project.Name).Add("label", serviceFilter(serviceName)),
})
if err != nil {
return time.Now(), err
}
if len(res.Items) == 0 {
return time.Now(), fmt.Errorf("could not get created time for service's image")
}

img, err := s.apiClient().ImageInspect(ctx, res.Items[0].ImageID)
if err != nil {
return time.Now(), err
}
// Need to get the oldest one?
timeCreated, err := time.Parse(time.RFC3339Nano, img.Created)
if err != nil {
return time.Now(), err
}
return timeCreated, nil
}
91 changes: 91 additions & 0 deletions pkg/compose/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"testing"
"time"
Expand Down Expand Up @@ -194,3 +195,93 @@ func (f *fakeSyncer) Sync(ctx context.Context, service string, paths []*sync.Pat
f.synced <- paths
return nil
}

func TestInitialSyncFiles_DirectoryIncludesExistingFilesEvenIfOlderThanImage(t *testing.T) {
dir := t.TempDir()
hostDir := filepath.Join(dir, "src")
assert.NilError(t, os.MkdirAll(hostDir, 0o755))
hostFile := filepath.Join(hostDir, "test.txt")
assert.NilError(t, os.WriteFile(hostFile, []byte("hello"), 0o644))

fileTime := time.Now().Add(-24 * time.Hour)
assert.NilError(t, os.Chtimes(hostFile, fileTime, fileTime))

service := composeService{}
trigger := types.Trigger{
Path: hostDir,
Action: types.WatchActionSync,
Target: "/app/src",
InitialSync: true,
}

got, err := service.initialSyncFiles(types.ServiceConfig{Name: "test"}, trigger, watch.EmptyMatcher{})
assert.NilError(t, err)

expected := []*sync.PathMapping{{
HostPath: hostFile,
ContainerPath: "/app/src/test.txt",
}}
assert.DeepEqual(t, expected, got)
}

func TestInitialSyncFiles_FileIncludesExistingFileEvenIfOlderThanImage(t *testing.T) {
dir := t.TempDir()
hostFile := filepath.Join(dir, "test.txt")
assert.NilError(t, os.WriteFile(hostFile, []byte("hello"), 0o644))

fileTime := time.Now().Add(-24 * time.Hour)
assert.NilError(t, os.Chtimes(hostFile, fileTime, fileTime))

service := composeService{}
trigger := types.Trigger{
Path: hostFile,
Action: types.WatchActionSync,
Target: "/app/test.txt",
InitialSync: true,
}

got, err := service.initialSyncFiles(types.ServiceConfig{Name: "test"}, trigger, watch.EmptyMatcher{})
assert.NilError(t, err)

expected := []*sync.PathMapping{{
HostPath: hostFile,
ContainerPath: "/app/test.txt",
}}
assert.DeepEqual(t, expected, got)
}

func TestInitialSync_SyncsExistingFilesEvenIfOlderThanImage(t *testing.T) {
dir := t.TempDir()
hostDir := filepath.Join(dir, "src")
assert.NilError(t, os.MkdirAll(hostDir, 0o755))
hostFile := filepath.Join(hostDir, "test.txt")
assert.NilError(t, os.WriteFile(hostFile, []byte("hello"), 0o644))

fileTime := time.Now().Add(-24 * time.Hour)
assert.NilError(t, os.Chtimes(hostFile, fileTime, fileTime))

service := composeService{}
syncer := newFakeSyncer()
trigger := types.Trigger{
Path: hostDir,
Action: types.WatchActionSync,
Target: "/app/src",
InitialSync: true,
}
serviceConfig := types.ServiceConfig{
Name: "test",
Build: &types.BuildConfig{Context: dir},
}

errCh := make(chan error, 1)
go func() {
errCh <- service.initialSync(t.Context(), serviceConfig, trigger, syncer)
}()

expected := []*sync.PathMapping{{
HostPath: hostFile,
ContainerPath: "/app/src/test.txt",
}}
assert.DeepEqual(t, expected, <-syncer.synced)
assert.NilError(t, <-errCh)
}