fix(watch): reload compose config before rebuild

Signed-off-by: maxpetrusenkoagent <max.petrusenko.agent@gmail.com>
This commit is contained in:
maxpetrusenkoagent 2026-06-20 09:49:26 -04:00
parent 5d0f6e2ff3
commit d43f6007dc
7 changed files with 217 additions and 10 deletions

View file

@ -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)
},
})
}

View file

@ -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
},
})
}

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -0,0 +1,10 @@
services:
web:
build: .
env_file:
- ./watch.env
command: tail -f /dev/null
develop:
watch:
- path: test
action: rebuild

View file

@ -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"