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 that initial files meant to be synced from the watch action are in the container
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 @@ -744,7 +744,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 @@ -766,26 +766,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 they 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 @@ -807,15 +801,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 @@ -830,7 +816,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 @@ -845,28 +831,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
}
52 changes: 52 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 @@ -61,6 +62,16 @@ func (t testWatcher) Errors() chan error {

type stdLogger struct{}

const (
initialSyncTestFile = "test.txt"
initialSyncTestContent = "hello"
initialSyncTestService = "test"
initialSyncTestDirTarget = "/app/src"
initialSyncTestFileTarget = "/app/test.txt"
initialSyncTestFileMode = 0o600
initialSyncTestFileAge = time.Hour
)

func (s stdLogger) Log(containerName, message string) {
fmt.Printf("%s: %s\n", containerName, message)
}
Expand Down Expand Up @@ -195,6 +206,47 @@ func (f *fakeSyncer) Sync(ctx context.Context, service string, paths []*sync.Pat
return nil
}

func TestInitialSyncFilesIncludesFilesOlderThanImage(t *testing.T) {
hostDir := t.TempDir()
hostFile := filepath.Join(hostDir, initialSyncTestFile)
assert.NilError(t, os.WriteFile(hostFile, []byte(initialSyncTestContent), initialSyncTestFileMode))
oldTime := time.Now().Add(-initialSyncTestFileAge)
assert.NilError(t, os.Chtimes(hostFile, oldTime, oldTime))

paths, err := (&composeService{}).initialSyncFiles(types.ServiceConfig{Name: initialSyncTestService}, types.Trigger{
Path: hostDir,
Target: initialSyncTestDirTarget,
}, watch.EmptyMatcher{})
assert.NilError(t, err)
assert.DeepEqual(t, paths, []*sync.PathMapping{{
HostPath: hostFile,
ContainerPath: filepath.Join(initialSyncTestDirTarget, initialSyncTestFile),
}})
}

func TestInitialSyncIncludesSingleFileOlderThanImage(t *testing.T) {
hostDir := t.TempDir()
hostFile := filepath.Join(hostDir, initialSyncTestFile)
assert.NilError(t, os.WriteFile(hostFile, []byte(initialSyncTestContent), initialSyncTestFileMode))
oldTime := time.Now().Add(-initialSyncTestFileAge)
assert.NilError(t, os.Chtimes(hostFile, oldTime, oldTime))

syncer := &fakeSyncer{synced: make(chan []*sync.PathMapping, 1)}
err := (&composeService{}).initialSync(t.Context(), types.ServiceConfig{
Name: initialSyncTestService,
Build: &types.BuildConfig{Context: hostDir},
}, types.Trigger{
Path: hostFile,
Target: initialSyncTestFileTarget,
}, syncer)
assert.NilError(t, err)

assert.DeepEqual(t, <-syncer.synced, []*sync.PathMapping{{
HostPath: hostFile,
ContainerPath: initialSyncTestFileTarget,
}})
}

// TestPruneDanglingImagesOnRebuild verifies the post-rebuild prune only
// removes superseded dangling images: a dangling image whose ID matches one
// of the freshly built images must be spared. The lookup used to probe the
Expand Down