mirror of
https://github.com/docker/compose.git
synced 2026-08-31 06:49:15 +00:00
fix(watch): reload compose config before rebuild
Signed-off-by: maxpetrusenkoagent <max.petrusenko.agent@gmail.com>
This commit is contained in:
parent
5d0f6e2ff3
commit
d43f6007dc
7 changed files with 217 additions and 10 deletions
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
10
pkg/e2e/fixtures/watch/rebuild-config.yaml
Normal file
10
pkg/e2e/fixtures/watch/rebuild-config.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
services:
|
||||
web:
|
||||
build: .
|
||||
env_file:
|
||||
- ./watch.env
|
||||
command: tail -f /dev/null
|
||||
develop:
|
||||
watch:
|
||||
- path: test
|
||||
action: rebuild
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue