@@ -23,12 +23,12 @@ import (
2323 "os"
2424 "path"
2525 "path/filepath"
26- "sort"
2726 "strconv"
2827 "strings"
2928 "time"
3029
3130 "github.com/compose-spec/compose-go/v2/types"
31+ ccli "github.com/docker/cli/cli/command/container"
3232 pathutil "github.com/docker/compose/v2/internal/paths"
3333 "github.com/docker/compose/v2/internal/sync"
3434 "github.com/docker/compose/v2/pkg/api"
@@ -48,7 +48,7 @@ const quietPeriod = 500 * time.Millisecond
4848// fileEvent contains the Compose service and modified host system path.
4949type fileEvent struct {
5050 sync.PathMapping
51- Action types.WatchAction
51+ Trigger types.Trigger
5252}
5353
5454// getSyncImplementation returns an appropriate sync implementation for the
@@ -298,7 +298,7 @@ func maybeFileEvent(trigger types.Trigger, hostPath string, ignore watch.PathMat
298298 }
299299
300300 return & fileEvent {
301- Action : trigger . Action ,
301+ Trigger : trigger ,
302302 PathMapping : sync.PathMapping {
303303 HostPath : hostPath ,
304304 ContainerPath : containerPath ,
@@ -338,6 +338,9 @@ func loadDevelopmentConfig(service types.ServiceConfig, project *types.Project)
338338 if trigger .Action == types .WatchActionRebuild && service .Build == nil {
339339 return nil , fmt .Errorf ("service %s doesn't have a build section, can't apply 'rebuild' on watch" , service .Name )
340340 }
341+ if trigger .Action == types .WatchActionSyncExec && len (trigger .Exec .Command ) == 0 {
342+ return nil , fmt .Errorf ("can't watch with action 'sync+exec' on service %s wihtout a command" , service .Name )
343+ }
341344
342345 config .Watch [i ] = trigger
343346 }
@@ -352,24 +355,17 @@ func batchDebounceEvents(ctx context.Context, clock clockwork.Clock, delay time.
352355 out := make (chan []fileEvent )
353356 go func () {
354357 defer close (out )
355- seen := make (map [fileEvent ]time. Time )
358+ seen := make (map [sync. PathMapping ] fileEvent )
356359 flushEvents := func () {
357360 if len (seen ) == 0 {
358361 return
359362 }
360363 events := make ([]fileEvent , 0 , len (seen ))
361- for e := range seen {
364+ for _ , e := range seen {
362365 events = append (events , e )
363366 }
364- // sort batch by oldest -> newest
365- // (if an event is seen > 1 per batch, it gets the latest timestamp)
366- sort .SliceStable (events , func (i , j int ) bool {
367- x := events [i ]
368- y := events [j ]
369- return seen [x ].Before (seen [y ])
370- })
371367 out <- events
372- seen = make (map [fileEvent ]time. Time )
368+ seen = make (map [sync. PathMapping ] fileEvent )
373369 }
374370
375371 t := clock .NewTicker (delay )
@@ -386,7 +382,7 @@ func batchDebounceEvents(ctx context.Context, clock clockwork.Clock, delay time.
386382 flushEvents ()
387383 return
388384 }
389- seen [e ] = time . Now ()
385+ seen [e . PathMapping ] = e
390386 t .Reset (delay )
391387 }
392388 }
@@ -485,49 +481,10 @@ func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Pr
485481 pathMappings := make ([]sync.PathMapping , len (batch ))
486482 restartService := false
487483 for i := range batch {
488- if batch [i ].Action == types .WatchActionRebuild {
489- options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("Rebuilding service %q after changes were detected..." , serviceName ))
490- // restrict the build to ONLY this service, not any of its dependencies
491- options .Build .Services = []string {serviceName }
492- imageNameToIdMap , err := s .build (ctx , project , * options .Build , nil )
493-
494- if err != nil {
495- options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("Build failed. Error: %v" , err ))
496- return err
497- }
498-
499- if options .Prune {
500- s .pruneDanglingImagesOnRebuild (ctx , project .Name , imageNameToIdMap )
501- }
502-
503- options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("service %q successfully built" , serviceName ))
504-
505- err = s .create (ctx , project , api.CreateOptions {
506- Services : []string {serviceName },
507- Inherit : true ,
508- Recreate : api .RecreateForce ,
509- })
510- if err != nil {
511- options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("Failed to recreate service after update. Error: %v" , err ))
512- return err
513- }
514-
515- services := []string {serviceName }
516- p , err := project .WithSelectedServices (services )
517- if err != nil {
518- return err
519- }
520- err = s .start (ctx , project .Name , api.StartOptions {
521- Project : p ,
522- Services : services ,
523- AttachTo : services ,
524- }, nil )
525- if err != nil {
526- options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("Application failed to start after update. Error: %v" , err ))
527- }
528- return nil
484+ if batch [i ].Trigger .Action == types .WatchActionRebuild {
485+ return s .rebuild (ctx , project , serviceName , options )
529486 }
530- if batch [i ].Action == types .WatchActionSyncRestart {
487+ if batch [i ].Trigger . Action == types .WatchActionSyncRestart {
531488 restartService = true
532489 }
533490 pathMappings [i ] = batch [i ].PathMapping
@@ -554,7 +511,75 @@ func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Pr
554511 options .LogTo .Log (
555512 api .WatchLogger ,
556513 fmt .Sprintf ("service %q restarted" , serviceName ))
514+ }
515+ eg , ctx := errgroup .WithContext (ctx )
516+ for _ , b := range batch {
517+ if b .Trigger .Action == "sync+exec" {
518+ containers , err := s .getContainers (ctx , project .Name , oneOffExclude , false , serviceName )
519+ if err != nil {
520+ return err
521+ }
522+ x := b .Trigger .Exec
523+ for _ , c := range containers {
524+ eg .Go (func () error {
525+ exec := ccli .NewExecOptions ()
526+ exec .User = x .User
527+ exec .Privileged = x .Privileged
528+ exec .Command = x .Command
529+ exec .Workdir = x .WorkingDir
530+ for _ , v := range x .Environment .ToMapping ().Values () {
531+ err := exec .Env .Set (v )
532+ if err != nil {
533+ return err
534+ }
535+ }
536+ return ccli .RunExec (ctx , s .dockerCli , c .ID , exec )
537+ })
538+ }
539+ }
540+ }
541+ return eg .Wait ()
542+ }
543+
544+ func (s * composeService ) rebuild (ctx context.Context , project * types.Project , serviceName string , options api.WatchOptions ) error {
545+ options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("Rebuilding service %q after changes were detected..." , serviceName ))
546+ // restrict the build to ONLY this service, not any of its dependencies
547+ options .Build .Services = []string {serviceName }
548+ imageNameToIdMap , err := s .build (ctx , project , * options .Build , nil )
549+
550+ if err != nil {
551+ options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("Build failed. Error: %v" , err ))
552+ return err
553+ }
557554
555+ if options .Prune {
556+ s .pruneDanglingImagesOnRebuild (ctx , project .Name , imageNameToIdMap )
557+ }
558+
559+ options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("service %q successfully built" , serviceName ))
560+
561+ err = s .create (ctx , project , api.CreateOptions {
562+ Services : []string {serviceName },
563+ Inherit : true ,
564+ Recreate : api .RecreateForce ,
565+ })
566+ if err != nil {
567+ options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("Failed to recreate service after update. Error: %v" , err ))
568+ return err
569+ }
570+
571+ services := []string {serviceName }
572+ p , err := project .WithSelectedServices (services )
573+ if err != nil {
574+ return err
575+ }
576+ err = s .start (ctx , project .Name , api.StartOptions {
577+ Project : p ,
578+ Services : services ,
579+ AttachTo : services ,
580+ }, nil )
581+ if err != nil {
582+ options .LogTo .Log (api .WatchLogger , fmt .Sprintf ("Application failed to start after update. Error: %v" , err ))
558583 }
559584 return nil
560585}
0 commit comments