From 395e345f9ec9eca254353de8d8e5a45a33a858a7 Mon Sep 17 00:00:00 2001 From: qianlongzt <18493471+qianlongzt@users.noreply.github.com> Date: Fri, 22 May 2026 23:23:40 +0800 Subject: [PATCH] feat: add confirmation label when volume spec changes close: https://github.com/docker/compose/issues/13807 Signed-off-by: qianlongzt <18493471+qianlongzt@users.noreply.github.com> --- pkg/api/labels.go | 2 + pkg/compose/create.go | 16 +++- pkg/compose/create_test.go | 82 +++++++++++++++++++ .../fixtures/recreate-volumes/label-new.yml | 11 +++ .../fixtures/recreate-volumes/label-old.yml | 12 +++ pkg/e2e/volumes_test.go | 18 ++++ 6 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 pkg/e2e/fixtures/recreate-volumes/label-new.yml create mode 100644 pkg/e2e/fixtures/recreate-volumes/label-old.yml diff --git a/pkg/api/labels.go b/pkg/api/labels.go index 3a0f684b98b..6bea192f89e 100644 --- a/pkg/api/labels.go +++ b/pkg/api/labels.go @@ -33,6 +33,8 @@ const ( ContainerNumberLabel = "com.docker.compose.container-number" // VolumeLabel allow to track resource related to a compose volume VolumeLabel = "com.docker.compose.volume" + // VolumeRecreateWhenSpecUpdatedLabel when set to true, volume will be recreated when spec updated + VolumeRecreateWhenSpecUpdatedLabel = "com.docker.compose.volume.recreate-when-spec-updated" // NetworkLabel allow to track resource related to a compose network NetworkLabel = "com.docker.compose.network" // WorkingDirLabel stores absolute path to compose project working directory diff --git a/pkg/compose/create.go b/pkg/compose/create.go index cc0b4afbce3..d9629d6f217 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -43,6 +43,7 @@ import ( cdi "tags.cncf.io/container-device-interface/pkg/parser" "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/utils" ) type createOptions struct { @@ -1624,7 +1625,7 @@ func (s *composeService) ensureVolume(ctx context.Context, name string, volume t actual, ok := inspected.Volume.Labels[api.ConfigHashLabel] if ok && actual != expected { msg := fmt.Sprintf("Volume %q exists but doesn't match configuration in compose file. Recreate (data will be lost)?", volume.Name) - confirm, err := s.prompt(msg, false) + confirm, err := confirmVolumeRecreate(inspected.Volume.Labels, s.prompt, msg) if err != nil { return "", err } @@ -1639,6 +1640,19 @@ func (s *composeService) ensureVolume(ctx context.Context, name string, volume t return inspected.Volume.Name, nil } +func confirmVolumeRecreate(labels map[string]string, prompt Prompt, promptMsg string) (bool, error) { + recreate, ok := labels[api.VolumeRecreateWhenSpecUpdatedLabel] + if ok { + return utils.StringToBool(recreate), nil + } else { + c, err := prompt(promptMsg, false) + if err != nil { + return false, err + } + return c, nil + } +} + func (s *composeService) removeDivergedVolume(ctx context.Context, name string, volume types.VolumeConfig, project *types.Project) error { // Remove services mounting divergent volume var services []string diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index e08e4227dae..59e7d58247e 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -17,11 +17,13 @@ package compose import ( + "io" "net" "net/netip" "os" "path/filepath" "sort" + "strings" "testing" composeloader "github.com/compose-spec/compose-go/v2/loader" @@ -35,6 +37,8 @@ import ( "gotest.tools/v3/assert" "gotest.tools/v3/assert/cmp" + "github.com/docker/cli/cli/streams" + "github.com/docker/compose/v5/cmd/prompt" "github.com/docker/compose/v5/pkg/api" ) @@ -484,3 +488,81 @@ volumes: }) } } + +func Test_composeService_confirmVolumeRecreate(t *testing.T) { + tests := []struct { + name string + labels map[string]string + input string + want bool + wantErr bool + }{ + { + name: "no labels no input", + labels: nil, + input: "", + want: false, + wantErr: false, + }, + { + name: "no labels and input is y", + labels: nil, + input: "y", + want: true, + wantErr: false, + }, + { + name: "no labels and input is true", + labels: nil, + input: "true", + want: true, + wantErr: false, + }, + { + name: "no labels and input is no", + labels: nil, + input: "no", + want: false, + wantErr: false, + }, + { + name: "no input, has labels recreate true", + labels: map[string]string{api.VolumeRecreateWhenSpecUpdatedLabel: "true"}, + want: true, + wantErr: false, + }, + { + name: "no input, has labels recreate TRUE", + labels: map[string]string{api.VolumeRecreateWhenSpecUpdatedLabel: "TRUE"}, + want: true, + wantErr: false, + }, + { + name: "no input, has labels recreate false", + labels: map[string]string{api.VolumeRecreateWhenSpecUpdatedLabel: "false"}, + want: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prompt := prompt.NewPrompt( + streams.NewIn(io.NopCloser(strings.NewReader(tt.input))), + streams.NewOut(t.Output())).Confirm + + got, gotErr := confirmVolumeRecreate(tt.labels, prompt, "promptMsg") + if gotErr != nil { + if !tt.wantErr { + t.Errorf("confirmVolumeRecreate() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("confirmVolumeRecreate() succeeded unexpectedly") + } + if tt.want != got { + t.Errorf("confirmVolumeRecreate() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/e2e/fixtures/recreate-volumes/label-new.yml b/pkg/e2e/fixtures/recreate-volumes/label-new.yml new file mode 100644 index 00000000000..87e71fd2af3 --- /dev/null +++ b/pkg/e2e/fixtures/recreate-volumes/label-new.yml @@ -0,0 +1,11 @@ +services: + app: + image: alpine + volumes: + - my_vol:/my_vol + +volumes: + my_vol: + labels: + com.docker.compose.volume.recreate-when-spec-updated: "true" + foo: zot diff --git a/pkg/e2e/fixtures/recreate-volumes/label-old.yml b/pkg/e2e/fixtures/recreate-volumes/label-old.yml new file mode 100644 index 00000000000..7e4010cef38 --- /dev/null +++ b/pkg/e2e/fixtures/recreate-volumes/label-old.yml @@ -0,0 +1,12 @@ +services: + app: + image: alpine + volumes: + - my_vol:/my_vol + +volumes: + my_vol: + labels: + com.docker.compose.volume.recreate-when-spec-updated: "true" + foo: bar + diff --git a/pkg/e2e/volumes_test.go b/pkg/e2e/volumes_test.go index d3e5787fa02..b9289e48233 100644 --- a/pkg/e2e/volumes_test.go +++ b/pkg/e2e/volumes_test.go @@ -172,6 +172,24 @@ func TestUpRecreateVolumes_IgnoreBinds(t *testing.T) { assert.Check(t, !strings.Contains(res.Combined(), "Recreated")) } +func TestUpRecreateVolumes_RecreateLabel(t *testing.T) { + c := NewCLI(t) + const projectName = "compose-e2e-recreate-volumes-recreate-label" + t.Cleanup(func() { + c.cleanupWithDown(t, projectName) + }) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/label-old.yml", "--project-name", projectName, "up", "-d") + + res := c.RunDockerCmd(t, "volume", "inspect", fmt.Sprintf("%s_my_vol", projectName), "-f", "{{ index .Labels \"foo\" }}") + res.Assert(t, icmd.Expected{Out: "bar"}) + + res = c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/label-new.yml", "--project-name", projectName, "up", "-d") + + res = c.RunDockerCmd(t, "volume", "inspect", fmt.Sprintf("%s_my_vol", projectName), "-f", "{{ index .Labels \"foo\" }}") + res.Assert(t, icmd.Expected{Out: "zot"}) +} + func TestImageVolume(t *testing.T) { c := NewCLI(t) const projectName = "compose-e2e-image-volume"