Skip to content

Commit 705ee17

Browse files
committed
introduce sync+exec watch action
Signed-off-by: Nicolas De Loof <[email protected]>
1 parent a8469db commit 705ee17

File tree

6 files changed

+142
-61
lines changed

6 files changed

+142
-61
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,5 @@ require (
196196
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
197197
sigs.k8s.io/yaml v1.3.0 // indirect
198198
)
199+
200+
replace github.com/compose-spec/compose-go/v2 => github.com/ndeloof/compose-go/v2 v2.0.1-0.20241127110655-b1321070b3ab

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,6 @@ github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8E
8585
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
8686
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
8787
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
88-
github.com/compose-spec/compose-go/v2 v2.4.5 h1:p4ih4Jb6VgGPLPxh3fSFVKAjFHtZd+7HVLCSFzcFx9Y=
89-
github.com/compose-spec/compose-go/v2 v2.4.5/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc=
9088
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
9189
github.com/containerd/cgroups/v3 v3.0.2 h1:f5WFqIVSgo5IZmtTT3qVBo6TzI1ON6sycSBKkymb9L0=
9290
github.com/containerd/cgroups/v3 v3.0.2/go.mod h1:JUgITrzdFqp42uI2ryGA+ge0ap/nxzYgkGmIcetmErE=
@@ -359,6 +357,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
359357
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
360358
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
361359
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
360+
github.com/ndeloof/compose-go/v2 v2.0.1-0.20241127110655-b1321070b3ab h1:3Q4/1sAnPv4nMpak/lIzWsQJjX8X5zKZRkDd6mlf2mc=
361+
github.com/ndeloof/compose-go/v2 v2.0.1-0.20241127110655-b1321070b3ab/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc=
362362
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
363363
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
364364
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=

pkg/compose/watch.go

Lines changed: 81 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
4949
type 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
}

pkg/compose/watch_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,16 @@ func TestDebounceBatching(t *testing.T) {
4848
if i%2 == 0 {
4949
action = "b"
5050
}
51-
ch <- fileEvent{Action: action}
51+
ch <- fileEvent{Trigger: types.Trigger{Action: action}}
5252
}
5353
// we sent 100 events + the debouncer
5454
clock.BlockUntil(101)
5555
clock.Advance(quietPeriod)
5656
select {
5757
case batch := <-eventBatchCh:
5858
require.ElementsMatch(t, batch, []fileEvent{
59-
{Action: "a"},
60-
{Action: "b"},
59+
{Trigger: types.Trigger{Action: "a"}},
60+
{Trigger: types.Trigger{Action: "b"}},
6161
})
6262
case <-time.After(50 * time.Millisecond):
6363
t.Fatal("timed out waiting for events")

pkg/e2e/fixtures/watch/exec.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
services:
2+
test:
3+
build:
4+
dockerfile_inline: FROM alpine
5+
command: ping localhost
6+
volumes:
7+
- /data
8+
develop:
9+
watch:
10+
- path: .
11+
target: /data
12+
action: sync+exec
13+
exec:
14+
command: echo "SUCCESS"

pkg/e2e/watch_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package e2e
1818

1919
import (
20+
"bytes"
2021
"crypto/rand"
2122
"fmt"
2223
"os"
@@ -293,3 +294,42 @@ func doTest(t *testing.T, svcName string) {
293294

294295
testComplete.Store(true)
295296
}
297+
298+
func TestWatchExec(t *testing.T) {
299+
cli := NewCLI(t)
300+
const projectName = "test_watch_exec"
301+
302+
t.Cleanup(func() {
303+
cli.RunDockerComposeCmd(t, "-p", projectName, "down")
304+
})
305+
306+
tmpdir := t.TempDir()
307+
composeFilePath := filepath.Join(tmpdir, "compose.yaml")
308+
CopyFile(t, filepath.Join("fixtures", "watch", "exec.yaml"), composeFilePath)
309+
cmd := cli.NewDockerComposeCmd(t, "-p", projectName, "-f", composeFilePath, "up", "--watch")
310+
buffer := bytes.NewBuffer(nil)
311+
cmd.Stdout = buffer
312+
watch := icmd.StartCmd(cmd)
313+
314+
poll.WaitOn(t, func(l poll.LogT) poll.Result {
315+
out := buffer.String()
316+
if strings.Contains(out, "64 bytes from") {
317+
return poll.Success()
318+
}
319+
return poll.Continue("%v", watch.Stdout())
320+
})
321+
322+
t.Logf("Create new file")
323+
324+
testFile := filepath.Join(tmpdir, "test")
325+
require.NoError(t, os.WriteFile(testFile, []byte("test\n"), 0o600))
326+
327+
poll.WaitOn(t, func(l poll.LogT) poll.Result {
328+
out := buffer.String()
329+
if strings.Contains(out, "SUCCESS") {
330+
return poll.Success()
331+
}
332+
return poll.Continue("%v", out)
333+
})
334+
cli.RunDockerComposeCmdNoCheck(t, "-p", projectName, "kill", "-s", "9")
335+
}

0 commit comments

Comments
 (0)