diff --git a/cmd/compose/up.go b/cmd/compose/up.go index cda2678bbb2..9610cc4db87 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -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" @@ -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{ @@ -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) + }, }) } diff --git a/cmd/compose/watch.go b/cmd/compose/watch.go index c60b243860e..4851c49b578 100644 --- a/cmd/compose/watch.go +++ b/cmd/compose/watch.go @@ -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 + }, }) } diff --git a/pkg/api/api.go b/pkg/api/api.go index 1e84cca2bf7..80ea2eaacaa 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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 @@ -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 diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index c485267c0c1..573eef44e3c 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -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), @@ -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 @@ -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) diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 0c59b884ba6..de62e313fa1 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -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 } diff --git a/pkg/e2e/fixtures/watch/rebuild-config.yaml b/pkg/e2e/fixtures/watch/rebuild-config.yaml new file mode 100644 index 00000000000..0d283bd9a8b --- /dev/null +++ b/pkg/e2e/fixtures/watch/rebuild-config.yaml @@ -0,0 +1,10 @@ +services: + web: + build: . + env_file: + - ./watch.env + command: tail -f /dev/null + develop: + watch: + - path: test + action: rebuild diff --git a/pkg/e2e/watch_test.go b/pkg/e2e/watch_test.go index f4f78617284..f8ed5be04de 100644 --- a/pkg/e2e/watch_test.go +++ b/pkg/e2e/watch_test.go @@ -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"