Skip to content
Draft
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
21 changes: 20 additions & 1 deletion cmd/compose/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"strings"
"time"

"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli/command"
xprogress "github.com/moby/buildkit/util/progress/progressui"
Expand Down Expand Up @@ -111,7 +112,11 @@ func (opts upOptions) OnExit() api.Cascade {
}

func upCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command {
up := upOptions{}
up := upOptions{
composeOptions: &composeOptions{
ProjectOptions: p,
},
}
create := createOptions{}
build := buildOptions{ProjectOptions: p}
upCmd := &cobra.Command{
Expand Down Expand Up @@ -349,6 +354,20 @@ func runUp(
Services: services,
NavigationMenu: upOptions.navigationMenu && display.Mode != "plain" && dockerCli.In().IsTerminal(),
},
ReloadProject: func(ctx context.Context) (*types.Project, error) {
project, _, err := upOptions.ProjectOptions.ToProject(ctx, dockerCli, backend, services, cli.WithoutEnvironmentResolution)
if err != nil {
return nil, err
}
project, err = project.WithServicesEnvironmentResolved(true)
if err != nil {
return nil, err
}
if err := createOptions.Apply(project); err != nil {
return nil, err
}
return upOptions.apply(project, services)
},
})
}

Expand Down
10 changes: 10 additions & 0 deletions cmd/compose/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,15 @@ func runWatch(ctx context.Context, dockerCli command.Cli, backendOptions *Backen
LogTo: consumer,
Prune: watchOpts.prune,
Services: services,
ReloadProject: func(ctx context.Context) (*types.Project, error) {
project, _, err := watchOpts.ToProject(ctx, dockerCli, backend, services)
if err != nil {
return nil, err
}
if err := applyPlatforms(project, true); err != nil {
return nil, err
}
return project, nil
},
})
}
12 changes: 9 additions & 3 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,14 @@ const WatchLogger = "#watch"

// WatchOptions group options of the Watch API
type WatchOptions struct {
Build *BuildOptions
LogTo LogConsumer
Prune bool
Build *BuildOptions
LogTo LogConsumer
Prune bool
// Services passed in the command line to be watched
Services []string
// ReloadProject reloads the compose project before recreating services after
// a rebuild, so long-running watch sessions use current compose/env_file data.
ReloadProject func(ctx context.Context) (*types.Project, error)
}

// BuildOptions group options of the Build API
Expand Down Expand Up @@ -337,6 +341,8 @@ type StopOptions struct {
type UpOptions struct {
Create CreateOptions
Start StartOptions
// ReloadProject reloads the compose project for long-running up --watch sessions.
ReloadProject func(ctx context.Context) (*types.Project, error)
}

// DownOptions group options of the Down API
Expand Down
53 changes: 47 additions & 6 deletions pkg/compose/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ func NewWatcher(project *types.Project, options api.UpOptions, w WatchFunc, cons
return &Watcher{
project: project,
options: api.WatchOptions{
LogTo: consumer,
Build: build,
LogTo: consumer,
Build: build,
ReloadProject: options.ReloadProject,
},
watchFn: w,
errCh: make(chan error),
Expand Down Expand Up @@ -632,8 +633,51 @@ func (s *composeService) exec(ctx context.Context, project *types.Project, servi
return nil
}

func projectForRebuild(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) (*types.Project, error) {
var err error
if options.ReloadProject != nil {
project, err = options.ReloadProject(ctx)
if err != nil {
return nil, fmt.Errorf("reload compose project: %w", err)
}
}
project, err = project.WithSelectedServices(services)
if err != nil {
return nil, err
}
for serviceName, service := range project.Services {
if !slices.Contains(services, serviceName) {
continue
}
config := service.Develop
if config == nil {
config, err = loadDevelopmentConfig(service, project)
if err != nil {
return nil, err
}
}
if config == nil {
continue
}
for _, trigger := range config.Watch {
if trigger.Action == types.WatchActionRebuild {
service.PullPolicy = types.PullPolicyBuild
project.Services[serviceName] = service
break
}
}
}
return project, 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))
var err error
project, err = projectForRebuild(ctx, project, services, options)
if err != nil {
options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Failed to reload compose project after update. Error: %v", err))
return err
}
// Work on a copy so concurrent watch events don't race on the shared
// BuildOptions pointer carried by WatchOptions.
buildOpts := *options.Build
Expand All @@ -648,10 +692,7 @@ func (s *composeService) rebuild(ctx context.Context, project *types.Project, se
options.LogTo.Log(api.WatchLogger, line)
})

var (
imageNameToIdMap map[string]string
err error
)
var imageNameToIdMap map[string]string
err = tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(ctx, project),
func(ctx context.Context) error {
imageNameToIdMap, err = s.build(ctx, project, buildOpts, nil)
Expand Down
48 changes: 48 additions & 0 deletions pkg/compose/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,54 @@ func TestWatch_Sync(t *testing.T) {
// TODO: there's not a great way to assert that the rebuild attempt happened
}

func TestProjectForRebuildReloadsLatestConfig(t *testing.T) {
staleProject := &types.Project{
Name: "myProjectName",
Services: types.Services{
"web": {
Name: "web",
Environment: types.MappingWithEquals{"VALUE": strPtr("initial")},
Build: &types.BuildConfig{},
Develop: &types.DevelopConfig{
Watch: []types.Trigger{{Action: types.WatchActionRebuild, Path: "test"}},
},
},
},
}
freshProject := &types.Project{
Name: "myProjectName",
Services: types.Services{
"web": {
Name: "web",
Environment: types.MappingWithEquals{"VALUE": strPtr("updated")},
Build: &types.BuildConfig{},
Develop: &types.DevelopConfig{
Watch: []types.Trigger{{Action: types.WatchActionRebuild, Path: "test"}},
},
},
},
}

reloadCalled := false
project, err := projectForRebuild(t.Context(), staleProject, []string{"web"}, api.WatchOptions{
ReloadProject: func(ctx context.Context) (*types.Project, error) {
reloadCalled = true
return freshProject, nil
},
})
assert.NilError(t, err)
assert.Assert(t, reloadCalled)

service, err := project.GetService("web")
assert.NilError(t, err)
assert.Equal(t, *service.Environment["VALUE"], "updated")
assert.Equal(t, service.PullPolicy, types.PullPolicyBuild)
}

func strPtr(s string) *string {
return &s
}

type fakeSyncer struct {
synced chan []*sync.PathMapping
}
Expand Down
10 changes: 10 additions & 0 deletions pkg/e2e/fixtures/watch/rebuild-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
services:
web:
build: .
env_file:
- ./watch.env
command: tail -f /dev/null
develop:
watch:
- path: test
action: rebuild
73 changes: 73 additions & 0 deletions pkg/e2e/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,79 @@ func TestWatchRebuildIgnoresDependencies(t *testing.T) {
c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "kill", "-s", "9")
}

func TestWatchRebuildUsesLatestProjectConfig(t *testing.T) {
c := NewCLI(t)
const projectName = "test_watch_rebuild_config"
const serviceName = "web"

defer c.cleanupWithDown(t, projectName, "--rmi=local")

tmpdir := t.TempDir()
composeFilePath := filepath.Join(tmpdir, "compose.yaml")
CopyFile(t, filepath.Join("fixtures", "watch", "rebuild-config.yaml"), composeFilePath)
assert.NilError(t, os.WriteFile(filepath.Join(tmpdir, "Dockerfile"), []byte("FROM alpine\nRUN mkdir /data\nCOPY test /data/web\n"), 0o600))

envFilePath := filepath.Join(tmpdir, "watch.env")
assert.NilError(t, os.WriteFile(envFilePath, []byte("VALUE=initial\n"), 0o600))
testFile := filepath.Join(tmpdir, "test")
assert.NilError(t, os.WriteFile(testFile, []byte("initial"), 0o600))

cmd := c.NewDockerComposeCmd(t, "-p", projectName, "-f", composeFilePath, "up", "--build", "--watch")
buffer := bytes.NewBuffer(nil)
cmd.Stdout = buffer
cmd.Stderr = buffer
watch := icmd.StartCmd(cmd)
assert.NilError(t, watch.Error)
t.Cleanup(func() {
if watch.Cmd.Process != nil {
_ = watch.Cmd.Process.Kill()
}
})

poll.WaitOn(t, func(l poll.LogT) poll.Result {
if strings.Contains(buffer.String(), "Watch enabled") {
return poll.Success()
}
return poll.Continue("waiting for watch to start: %v", buffer.String())
}, poll.WithTimeout(120*time.Second))

poll.WaitOn(t, func(l poll.LogT) poll.Result {
res := c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "exec", serviceName, "printenv", "VALUE")
if strings.Contains(res.Stdout(), "initial") {
return poll.Success()
}
return poll.Continue("expected initial VALUE before rebuild, got: %v", res.Combined())
}, poll.WithTimeout(30*time.Second), poll.WithDelay(time.Second))

logCutoff := buffer.Len()
assert.NilError(t, os.WriteFile(envFilePath, []byte("VALUE=updated\n"), 0o600))
assert.NilError(t, os.WriteFile(testFile, []byte("updated"), 0o600))

poll.WaitOn(t, func(l poll.LogT) poll.Result {
out := buffer.String()
if len(out) <= logCutoff {
return poll.Continue("no rebuild output yet")
}
if strings.Contains(out[logCutoff:], `service(s) ["web"] successfully built`) {
return poll.Success()
}
return poll.Continue("waiting for rebuild to finish: %v", out[logCutoff:])
}, poll.WithTimeout(120*time.Second), poll.WithDelay(time.Second))

poll.WaitOn(t, func(l poll.LogT) poll.Result {
if watch.Cmd.ProcessState != nil {
return poll.Error(fmt.Errorf("watch process exited early: %s", watch.Cmd.ProcessState))
}
res := c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "exec", serviceName, "printenv", "VALUE")
if strings.Contains(res.Stdout(), "updated") {
return poll.Success()
}
return poll.Continue("expected updated VALUE after rebuild, got: %v", res.Combined())
}, poll.WithTimeout(120*time.Second), poll.WithDelay(time.Second))

c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "kill", "-s", "9")
}

func TestWatchIncludes(t *testing.T) {
c := NewCLI(t)
const projectName = "test_watch_includes"
Expand Down