From 6ab5db97481ac34590b69b4bf3fdcbaa24ffadca Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 22 Jul 2026 16:08:05 +0200 Subject: [PATCH 1/4] feat(reconcile): model volume recreation in the plan Move volume divergence detection and recreation out of the imperative pre-reconcile path (ensureVolume/removeDivergedVolume) and into the reconciliation plan, activating the dormant planRecreateVolume seam. A diverged volume now produces an explicit, forward-only sequence: stop containers -> remove containers -> remove volume -> create volume -> create containers. Container re-creation is delegated to reconcileContainers (affected services are cleared from the observed snapshot so they are scheduled fresh, gated on the CreateVolume node), and the recreation cascades to namespace/volume-sharing dependents. User confirmation (recreate, data will be lost) is consulted while building the plan via reconciler.prompt; declining leaves the volume untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Nicolas De Loof --- AI_AGENT_DISCLOSURE.md | 2 + pkg/compose/create.go | 150 +++++--------- pkg/compose/executor_test.go | 63 ++++++ pkg/compose/observed_state.go | 5 +- pkg/compose/reconcile.go | 190 ++++++++++------- pkg/compose/reconcile_test.go | 380 +++++++++++++++++++++++++++++++--- 6 files changed, 587 insertions(+), 203 deletions(-) create mode 100644 AI_AGENT_DISCLOSURE.md diff --git a/AI_AGENT_DISCLOSURE.md b/AI_AGENT_DISCLOSURE.md new file mode 100644 index 00000000000..37a1b5a1bee --- /dev/null +++ b/AI_AGENT_DISCLOSURE.md @@ -0,0 +1,2 @@ +This contribution was prepared by an AI agent acting on a human's behalf. +The human submitter may not have independently reviewed or tested the change. diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 6d15f28f28d..5cfaea5038e 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -92,7 +92,8 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt return err } - volumes, err := s.ensureProjectVolumes(ctx, project) + prepareVolumes(project) + externalVolumes, err := s.checkVolumes(ctx, project) if err != nil { return err } @@ -108,7 +109,7 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt return err } observed.setResolvedNetworks(networks, project) - observed.setResolvedVolumes(volumes) + observed.setResolvedVolumes(externalVolumes) if len(observed.Orphans) > 0 && !options.IgnoreOrphans && !options.RemoveOrphans { logrus.Warnf("Found orphan containers (%s) for this project. If "+ @@ -152,20 +153,56 @@ func (s *composeService) ensureNetworks(ctx context.Context, project *types.Proj return networks, nil } -func (s *composeService) ensureProjectVolumes(ctx context.Context, project *types.Project) (map[string]string, error) { - ids := map[string]string{} +// prepareVolumes injects the compose-managed labels onto every project volume so +// that createVolume (executed later as a plan operation) persists them and the +// volume can be matched back to the project on the next run. It mirrors +// prepareNetworks and performs no I/O. +func prepareVolumes(project *types.Project) { for k, volume := range project.Volumes { - volume.CustomLabels = volume.CustomLabels.Add(api.VolumeLabel, k) - volume.CustomLabels = volume.CustomLabels.Add(api.ProjectLabel, project.Name) - volume.CustomLabels = volume.CustomLabels.Add(api.VersionLabel, api.ComposeVersion) - id, err := s.ensureVolume(ctx, k, volume, project) + volume.CustomLabels = volume.CustomLabels. + Add(api.VolumeLabel, k). + Add(api.ProjectLabel, project.Name). + Add(api.VersionLabel, api.ComposeVersion) + project.Volumes[k] = volume + } +} + +// checkVolumes validates that external volumes exist and warns about non-external +// volumes whose name collides with a volume not managed by this project. Creation +// and recreation of managed volumes is owned by the reconciliation plan, so this +// function performs no mutation. +// +// It returns the resolved names of external volumes: those are not labelled by +// Compose and are therefore absent from the observed state, so the reconciler +// needs them injected via setResolvedVolumes. +func (s *composeService) checkVolumes(ctx context.Context, project *types.Project) (map[string]string, error) { + external := map[string]string{} + for k, volume := range project.Volumes { + if volume.External { + if _, err := s.apiClient().VolumeInspect(ctx, volume.Name, client.VolumeInspectOptions{}); err != nil { + if errdefs.IsNotFound(err) { + return nil, fmt.Errorf("external volume %q not found", volume.Name) + } + return nil, err + } + external[k] = volume.Name + continue + } + + inspected, err := s.apiClient().VolumeInspect(ctx, volume.Name, client.VolumeInspectOptions{}) if err != nil { + if errdefs.IsNotFound(err) { + continue // absent: it will be created by the reconciliation plan + } return nil, err } - ids[k] = id + if p, ok := inspected.Volume.Labels[api.ProjectLabel]; !ok { + logrus.Warnf("volume %q already exists but was not created by Docker Compose. Use `external: true` to use an existing volume", volume.Name) + } else if p != project.Name { + logrus.Warnf("volume %q already exists but was created for project %q (expected %q). Use `external: true` to use an existing volume", volume.Name, p, project.Name) + } } - - return ids, nil + return external, nil } //nolint:gocyclo @@ -1594,97 +1631,6 @@ func (s *composeService) resolveExternalNetwork(ctx context.Context, n *types.Ne } } -func (s *composeService) ensureVolume(ctx context.Context, name string, volume types.VolumeConfig, project *types.Project) (string, error) { - inspected, err := s.apiClient().VolumeInspect(ctx, volume.Name, client.VolumeInspectOptions{}) - if err != nil { - if !errdefs.IsNotFound(err) { - return "", err - } - if volume.External { - return "", fmt.Errorf("external volume %q not found", volume.Name) - } - err = s.createVolume(ctx, volume) - return volume.Name, err - } - - if volume.External { - return volume.Name, nil - } - - // Volume exists with name, but let's double-check this is the expected one - p, ok := inspected.Volume.Labels[api.ProjectLabel] - if !ok { - logrus.Warnf("volume %q already exists but was not created by Docker Compose. Use `external: true` to use an existing volume", volume.Name) - } - if ok && p != project.Name { - logrus.Warnf("volume %q already exists but was created for project %q (expected %q). Use `external: true` to use an existing volume", volume.Name, p, project.Name) - } - - expected, err := VolumeHash(volume) - if err != nil { - return "", err - } - 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) - if err != nil { - return "", err - } - if confirm { - err = s.removeDivergedVolume(ctx, name, volume, project) - if err != nil { - return "", err - } - return volume.Name, s.createVolume(ctx, volume) - } - } - return inspected.Volume.Name, 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 - for _, service := range project.Services.Filter(func(config types.ServiceConfig) bool { - for _, cfg := range config.Volumes { - if cfg.Source == name { - return true - } - } - return false - }) { - services = append(services, service.Name) - } - - err := s.stop(ctx, project.Name, api.StopOptions{ - Services: services, - Project: project, - }, nil) - if err != nil { - return err - } - - containers, err := s.getContainers(ctx, project.Name, oneOffExclude, true, services...) - if err != nil { - return err - } - - // FIXME (ndeloof) we have to remove container so we can recreate volume - // but doing so we can't inherit anonymous volumes from previous instance - err = s.remove(ctx, containers, api.RemoveOptions{ - Services: services, - Project: project, - }) - if err != nil { - return err - } - - _, err = s.apiClient().VolumeRemove(ctx, volume.Name, client.VolumeRemoveOptions{ - Force: true, - }) - return err -} - func (s *composeService) createVolume(ctx context.Context, volume types.VolumeConfig) error { eventName := fmt.Sprintf("Volume %s", volume.Name) s.events.On(creatingEvent(eventName)) diff --git a/pkg/compose/executor_test.go b/pkg/compose/executor_test.go index 5f4ab88049c..2228361c438 100644 --- a/pkg/compose/executor_test.go +++ b/pkg/compose/executor_test.go @@ -253,6 +253,69 @@ func TestExecutePlanConcurrentRemovesCacheCoherence(t *testing.T) { "all removed containers should be dropped from the live view") } +// TestExecutePlanRecreateVolume drives the destructive core of a volume +// recreation — stop container → remove container → remove volume → create +// volume — end to end through the executor, asserting each Docker API call +// fires. The dependency edges force the destructive order: the volume can only +// be removed once the container referencing it is gone. +func TestExecutePlanRecreateVolume(t *testing.T) { + svc, apiClient := newTestService(t) + + ctr := container.Summary{ + ID: "c1", + Names: []string{"/test-db-1"}, + Labels: map[string]string{ + api.ServiceLabel: "db", + api.ContainerNumberLabel: "1", + }, + } + + apiClient.EXPECT().ContainerStop(gomock.Any(), "c1", gomock.Any()). + Return(client.ContainerStopResult{}, nil) + apiClient.EXPECT().ContainerRemove(gomock.Any(), "c1", gomock.Any()). + Return(client.ContainerRemoveResult{}, nil) + apiClient.EXPECT().VolumeRemove(gomock.Any(), "recreate_data", gomock.Any()). + Return(client.VolumeRemoveResult{}, nil) + apiClient.EXPECT().VolumeCreate(gomock.Any(), gomock.Any()). + Return(client.VolumeCreateResult{}, nil) + + vol := types.VolumeConfig{Name: "recreate_data", Driver: "local"} + project := &types.Project{ + Name: "recreate", + Volumes: types.Volumes{"data": vol}, + } + + plan := &Plan{} + stopNode := plan.addNode(Operation{ + Type: OpStopContainer, + ResourceID: "service:db:1", + Cause: "mounted volume config changed", + Container: &ctr, + }, "") + removeNode := plan.addNode(Operation{ + Type: OpRemoveContainer, + ResourceID: "service:db:1", + Cause: "mounted volume config changed", + Container: &ctr, + }, "", stopNode) + removeVolNode := plan.addNode(Operation{ + Type: OpRemoveVolume, + ResourceID: "volume:data", + Cause: "config hash diverged", + Name: vol.Name, + }, "", removeNode) + plan.addNode(Operation{ + Type: OpCreateVolume, + ResourceID: "volume:data", + Cause: "recreate after config change", + Name: vol.Name, + Volume: &vol, + }, "", removeVolNode) + + err := svc.executePlan(t.Context(), project, emptyObservedState("recreate"), plan) + assert.NilError(t, err) +} + // notFoundError implements the errdefs.ErrNotFound interface for test mocks. type notFoundError struct{} diff --git a/pkg/compose/observed_state.go b/pkg/compose/observed_state.go index 9fd7b42bbb7..4ec472365e7 100644 --- a/pkg/compose/observed_state.go +++ b/pkg/compose/observed_state.go @@ -197,8 +197,9 @@ func (s *ObservedState) setResolvedNetworks(networks map[string]string, project } } -// setResolvedVolumes injects volume names already resolved by ensureProjectVolumes -// into the observed state. +// setResolvedVolumes injects volume names already resolved by checkVolumes +// (external volumes) into the observed state. Managed volumes are discovered +// directly by collectObservedState, so only external ones need injecting. func (s *ObservedState) setResolvedVolumes(volumes map[string]string) { for key, id := range volumes { if obs, exists := s.Volumes[key]; exists { diff --git a/pkg/compose/reconcile.go b/pkg/compose/reconcile.go index 2169d9a5cf6..83a07d6a1dc 100644 --- a/pkg/compose/reconcile.go +++ b/pkg/compose/reconcile.go @@ -61,21 +61,11 @@ type reconciler struct { project *types.Project observed *ObservedState options ReconcileOptions - // Seam-consolidation infrastructure. - // - // Today, divergence detection and recreation for volumes/networks live in - // ensureProjectVolumes/ensureNetworks (called before reconcile). The plan - // is to migrate that responsibility into the reconciler. The hooks below - // are kept so the migration can land in one commit instead of touching - // every caller: - // - // - prompt (this field) — user interaction - // - planRecreateVolume (below) — the volume recreate sequence - // - servicesUsingVolume (below) — its only caller today - // - // When the migration lands, remove all three together if it ends up - // shaped differently. The //nolint:unused markers on the helpers point - // here for context. + // prompt interacts with the user to confirm destructive decisions taken + // while building the plan. Today its only consumer is reconcileVolumes, + // which asks for confirmation before scheduling the recreation of a volume + // whose configuration has diverged (an operation that loses the volume's + // data). Network recreation is not gated: it is not destructive. prompt Prompt plan *Plan @@ -107,8 +97,8 @@ type reconciler struct { } // reconcile is the main entry point: it builds a Plan from desired vs observed state. -// The prompt function is reserved for future interactive decisions (see the -// reconciler.prompt field). +// The prompt function is consulted while planning to confirm destructive +// decisions (see the reconciler.prompt field). func reconcile(_ context.Context, project *types.Project, observed *ObservedState, options ReconcileOptions, prompt Prompt) (*Plan, error) { r := &reconciler{ project: project, @@ -128,7 +118,9 @@ func reconcile(_ context.Context, project *types.Project, observed *ObservedStat return nil, err } - r.reconcileVolumes() + if err := r.reconcileVolumes(); err != nil { + return nil, err + } if err := r.reconcileContainers(); err != nil { return nil, err @@ -238,20 +230,45 @@ func (r *reconciler) planRecreateNetwork(key string, nw *types.NetworkConfig) er return nil } -// reconcileVolumes adds plan nodes for volume creation. Recreation of a -// diverged volume is handled by ensureProjectVolumes (which already prompts -// the user) before reconcile runs, so the reconciler does not duplicate that -// decision here. -func (r *reconciler) reconcileVolumes() { +// reconcileVolumes plans the volume lifecycle: creation of missing volumes and, +// for volumes whose configuration has diverged from the live resource, +// recreation — gated on user confirmation because it destroys the volume's data. +// +// Divergence is detected by comparing VolumeHash(desired) with the config-hash +// persisted on the live volume (observed.ConfigHash). A volume with no recorded +// hash (e.g. created by an older Compose) is left untouched, matching the +// previous ensureVolume behavior. +func (r *reconciler) reconcileVolumes() error { + var diverged []string for _, key := range sortedKeys(r.project.Volumes) { desired := r.project.Volumes[key] if desired.External { continue } - if _, exists := r.observed.Volumes[key]; !exists { + observed, exists := r.observed.Volumes[key] + if !exists { r.planCreateVolume(key, &desired) + continue + } + expected, err := VolumeHash(desired) + if err != nil { + return err + } + if observed.ConfigHash == "" || observed.ConfigHash == expected { + continue + } + confirmed, err := r.prompt( + fmt.Sprintf("Volume %q exists but doesn't match configuration in compose file. Recreate (data will be lost)?", desired.Name), + false) + if err != nil { + return err + } + if confirmed { + diverged = append(diverged, key) } } + r.planRecreateVolumes(diverged) + return nil } // planCreateVolume adds a single CreateVolume node and records it for dependency tracking. @@ -267,59 +284,92 @@ func (r *reconciler) planCreateVolume(key string, vol *types.VolumeConfig) *Plan return node } -// planRecreateVolume adds the full sequence for a diverged volume: -// stop affected containers → remove containers → remove volume → create volume. -// Containers must be removed (not just stopped) because Docker does not allow -// removing a volume that is referenced by any container, even a stopped one. +// planRecreateVolumes schedules the recreation of the given (confirmed) diverged +// volumes and hands the re-creation of the impacted service containers to +// reconcileContainers. The resulting plan, for each affected container/volume, is: // -//nolint:unused // see reconciler.prompt field doc — seam consolidation. -func (r *reconciler) planRecreateVolume(key string, vol *types.VolumeConfig) { - observed := r.observed.Volumes[key] - affectedServices := r.servicesUsingVolume(key) - affectedContainers := r.containersForServices(affectedServices) +// stop containers → remove containers → remove volume → create volume → create containers +// +// Containers must be *removed* (not merely stopped) before a volume can be +// removed: Docker refuses to remove a volume still referenced by any container, +// even a stopped one. They are then recreated once the fresh volume exists. +// +// Rather than re-implementing container creation here, the affected services are +// cleared from the observed snapshot: reconcileContainers (which runs next) then +// sees them as absent and schedules fresh containers that depend on the +// CreateVolume node via infrastructureDeps. Marking those services as recreated +// propagates the cascade to namespace/volume-sharing dependents. +// +// Container stops/removes are planned once per container even when a container +// mounts several diverged volumes, and every RemoveVolume waits for all affected +// container removals, so the ordering holds regardless of which service mounts +// which volume. +func (r *reconciler) planRecreateVolumes(keys []string) { + if len(keys) == 0 { + return + } - // Stop all affected containers - var stopNodes []*PlanNode - for i := range affectedContainers { - oc := &affectedContainers[i] - node := r.plan.addNode(Operation{ - Type: OpStopContainer, - ResourceID: fmt.Sprintf("service:%s:%d", oc.Summary.Labels[api.ServiceLabel], oc.Number), - Cause: fmt.Sprintf("volume %s config changed", key), - Container: &oc.Summary, - }, "") - stopNodes = append(stopNodes, node) + // Collect the services (and their containers) mounting any diverged volume. + serviceSet := map[string]bool{} + for _, key := range keys { + for _, svc := range r.servicesUsingVolume(key) { + serviceSet[svc] = true + } } + services := sortedKeys(serviceSet) + containers := r.containersForServices(services) - // Remove all affected containers (each depends on its own stop) + // Stop then remove every affected container. var removeNodes []*PlanNode - for i, oc := range affectedContainers { - node := r.plan.addNode(Operation{ + for i := range containers { + oc := &containers[i] + resID := fmt.Sprintf("service:%s:%d", oc.Summary.Labels[api.ServiceLabel], oc.Number) + stopNode, alreadyStopped := r.stoppedByPlan[oc.ID] + if !alreadyStopped { + stopNode = r.plan.addNode(Operation{ + Type: OpStopContainer, + ResourceID: resID, + Cause: "mounted volume config changed", + Container: &oc.Summary, + Timeout: r.options.Timeout, + }, "") + r.stoppedByPlan[oc.ID] = stopNode + } + removeNode := r.plan.addNode(Operation{ Type: OpRemoveContainer, - ResourceID: fmt.Sprintf("service:%s:%d", oc.Summary.Labels[api.ServiceLabel], oc.Number), - Cause: fmt.Sprintf("volume %s config changed", key), - Container: &affectedContainers[i].Summary, - }, "", stopNodes[i]) - removeNodes = append(removeNodes, node) + ResourceID: resID, + Cause: "mounted volume config changed", + Container: &oc.Summary, + }, "", stopNode) + removeNodes = append(removeNodes, removeNode) } - // Remove the *observed* volume (depends on all container removals) - removeVolNode := r.plan.addNode(Operation{ - Type: OpRemoveVolume, - ResourceID: fmt.Sprintf("volume:%s", key), - Cause: "config hash diverged", - Name: observed.Name, - }, "", removeNodes...) - - // Create volume (depends on remove) - createNode := r.plan.addNode(Operation{ - Type: OpCreateVolume, - ResourceID: fmt.Sprintf("volume:%s", key), - Cause: "recreate after config change", - Name: vol.Name, - Volume: vol, - }, "", removeVolNode) - r.volumeNodes[key] = createNode + // Remove then recreate each diverged volume once all affected containers are + // gone. Record the CreateVolume node so the fresh containers scheduled by + // reconcileContainers depend on it (via infrastructureDeps). + for _, key := range keys { + desired := r.project.Volumes[key] + removeVolNode := r.plan.addNode(Operation{ + Type: OpRemoveVolume, + ResourceID: fmt.Sprintf("volume:%s", key), + Cause: "config hash diverged", + Name: r.observed.Volumes[key].Name, + }, "", removeNodes...) + createVolNode := r.plan.addNode(Operation{ + Type: OpCreateVolume, + ResourceID: fmt.Sprintf("volume:%s", key), + Cause: "recreate after config change", + Name: desired.Name, + Volume: &desired, + }, "", removeVolNode) + r.volumeNodes[key] = createVolNode + } + + // Hand container re-creation to reconcileContainers. + for _, svc := range services { + r.recreatedServices[svc] = true + r.observed.Containers[svc] = nil + } } // servicesUsingNetwork returns the names of services that reference the given @@ -337,8 +387,6 @@ func (r *reconciler) servicesUsingNetwork(networkKey string) []string { // servicesUsingVolume returns the names of services that mount the given // compose volume key, sorted for deterministic plan output. -// -//nolint:unused // see reconciler.prompt field doc — seam consolidation. func (r *reconciler) servicesUsingVolume(volumeKey string) []string { var names []string for _, key := range sortedKeys(r.project.Services) { diff --git a/pkg/compose/reconcile_test.go b/pkg/compose/reconcile_test.go index 991b7a4f8b7..0afdc63b635 100644 --- a/pkg/compose/reconcile_test.go +++ b/pkg/compose/reconcile_test.go @@ -17,6 +17,8 @@ package compose import ( + "fmt" + "strconv" "strings" "testing" @@ -32,6 +34,27 @@ func noPrompt(msg string, _ bool) (bool, error) { panic("unexpected prompt call: " + msg) } +// yesPrompt confirms every prompt (equivalent to `--yes`). +func yesPrompt(_ string, _ bool) (bool, error) { + return true, nil +} + +// declinePrompt rejects every prompt (the default answer for a non-interactive +// session with no input). +func declinePrompt(_ string, _ bool) (bool, error) { + return false, nil +} + +// recordingPrompt confirms every prompt and captures the messages shown. +type recordingPrompt struct { + messages []string +} + +func (p *recordingPrompt) confirm(msg string, _ bool) (bool, error) { + p.messages = append(p.messages, msg) + return true, nil +} + func defaultReconcileOptions() ReconcileOptions { return ReconcileOptions{ Recreate: api.RecreateDiverged, @@ -279,53 +302,354 @@ func TestReconcileVolumes_ExternalSkipped(t *testing.T) { assert.Assert(t, plan.IsEmpty()) } -// TestReconcileVolumes_DivergedIsIgnored verifies that a diverged volume -// produces no plan operations: recreation of diverged volumes is owned by -// ensureProjectVolumes (which prompts the user) and runs before reconcile, -// so the reconciler must not duplicate that decision. -func TestReconcileVolumes_DivergedIsIgnored(t *testing.T) { +// divergedVolumeProject builds a project with `count` services (db0, db1, ...), +// each scaled to `scale` and mounting the shared "data" volume, plus a matching +// observed state whose volume config-hash is stale ("oldhash"). Service and +// container config-hashes match, so the only divergence is the volume. +func divergedVolumeProject(t *testing.T, count, scale int) (*types.Project, *ObservedState) { + t.Helper() vol := types.VolumeConfig{Name: "myproject_data", Driver: "local"} - project := &types.Project{ - Name: "myproject", - Volumes: types.Volumes{"data": {Name: "myproject_data", Driver: "local"}}, - Services: types.Services{ - "db": { - Name: "db", - Scale: intPtr(1), - Volumes: []types.ServiceVolumeConfig{ - {Source: "data", Type: "volume"}, + Name: "myproject", + Volumes: types.Volumes{"data": vol}, + Services: types.Services{}, + } + observed := &ObservedState{ + ProjectName: "myproject", + Containers: map[string][]ObservedContainer{}, + Networks: map[string]ObservedNetwork{}, + Volumes: map[string]ObservedVolume{"data": {Name: vol.Name, ConfigHash: "oldhash"}}, + } + for s := 0; s < count; s++ { + name := fmt.Sprintf("db%d", s) + svc := types.ServiceConfig{ + Name: name, + Scale: intPtr(scale), + Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}, + } + project.Services[name] = svc + hash := mustServiceHash(t, svc) + for n := 1; n <= scale; n++ { + id := fmt.Sprintf("%s-%d", name, n) + observed.Containers[name] = append(observed.Containers[name], ObservedContainer{ + ID: id, Number: n, State: container.StateRunning, ConfigHash: hash, + Summary: container.Summary{ + ID: id, State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: name, api.ContainerNumberLabel: strconv.Itoa(n), api.ConfigHashLabel: hash}, + Mounts: []container.MountPoint{{Type: "volume", Name: vol.Name}}, }, - }, + }) + } + } + return project, observed +} + +// TestReconcileVolumes_DivergedConfirmed asserts the full recreation sequence for +// a diverged volume mounted by a single service: the container is stopped and +// removed, the volume is removed then recreated, and finally a fresh container is +// scheduled that depends on the new volume. +func TestReconcileVolumes_DivergedConfirmed(t *testing.T) { + project, observed := divergedVolumeProject(t, 1, 1) + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), yesPrompt) + assert.NilError(t, err) + + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 service:db0:1, StopContainer, mounted volume config changed +[1] -> #2 service:db0:1, RemoveContainer, mounted volume config changed +[2] -> #3 volume:data, RemoveVolume, config hash diverged +[3] -> #4 volume:data, CreateVolume, recreate after config change +[4] -> #5 service:db0:1, CreateContainer, no existing container +`)+"\n") +} + +// TestReconcileVolumes_DivergedDeclined verifies that declining the prompt leaves +// the volume (and the service that mounts it) untouched. +func TestReconcileVolumes_DivergedDeclined(t *testing.T) { + project, observed := divergedVolumeProject(t, 1, 1) + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), declinePrompt) + assert.NilError(t, err) + assert.Assert(t, plan.IsEmpty(), "unexpected plan:\n%s", plan.String()) +} + +// TestReconcileVolumes_DivergedNoRecordedHash verifies that a volume with no +// persisted config-hash (e.g. created by an older Compose) is left untouched and +// never prompts — matching the previous ensureVolume behavior. +func TestReconcileVolumes_DivergedNoRecordedHash(t *testing.T) { + project, observed := divergedVolumeProject(t, 1, 1) + obs := observed.Volumes["data"] + obs.ConfigHash = "" + observed.Volumes["data"] = obs + + // noPrompt panics if consulted, proving the empty-hash guard short-circuits. + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt) + assert.NilError(t, err) + assert.Assert(t, plan.IsEmpty(), "unexpected plan:\n%s", plan.String()) +} + +// TestReconcileVolumes_DivergedPromptMessage asserts the confirmation message +// names the volume and warns about data loss. +func TestReconcileVolumes_DivergedPromptMessage(t *testing.T) { + project, observed := divergedVolumeProject(t, 1, 1) + + rec := &recordingPrompt{} + _, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), rec.confirm) + assert.NilError(t, err) + assert.Equal(t, len(rec.messages), 1) + assert.Equal(t, rec.messages[0], `Volume "myproject_data" exists but doesn't match configuration in compose file. Recreate (data will be lost)?`) +} + +// TestReconcileVolumes_DivergedPromptError propagates a prompt failure. +func TestReconcileVolumes_DivergedPromptError(t *testing.T) { + project, observed := divergedVolumeProject(t, 1, 1) + + boom := func(_ string, _ bool) (bool, error) { return false, fmt.Errorf("boom") } + _, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), boom) + assert.ErrorContains(t, err, "boom") +} + +// TestReconcileVolumes_DivergedConfirmedScaleN verifies every replica of a +// service mounting the diverged volume is removed, and the same number of fresh +// replicas is recreated after the volume. +func TestReconcileVolumes_DivergedConfirmedScaleN(t *testing.T) { + project, observed := divergedVolumeProject(t, 1, 2) + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), yesPrompt) + assert.NilError(t, err) + + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 service:db0:1, StopContainer, mounted volume config changed +[1] -> #2 service:db0:1, RemoveContainer, mounted volume config changed +[] -> #3 service:db0:2, StopContainer, mounted volume config changed +[3] -> #4 service:db0:2, RemoveContainer, mounted volume config changed +[2,4] -> #5 volume:data, RemoveVolume, config hash diverged +[5] -> #6 volume:data, CreateVolume, recreate after config change +[6] -> #7 service:db0:1, CreateContainer, no existing container +[6] -> #8 service:db0:2, CreateContainer, no existing container +`)+"\n") +} + +// TestReconcileVolumes_DivergedConfirmedMultipleServices verifies that two +// services mounting the same diverged volume are both recreated, the volume is +// removed only after both services' containers are gone, and both fresh +// containers depend on the single CreateVolume node. +func TestReconcileVolumes_DivergedConfirmedMultipleServices(t *testing.T) { + project, observed := divergedVolumeProject(t, 2, 1) + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), yesPrompt) + assert.NilError(t, err) + + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 service:db0:1, StopContainer, mounted volume config changed +[1] -> #2 service:db0:1, RemoveContainer, mounted volume config changed +[] -> #3 service:db1:1, StopContainer, mounted volume config changed +[3] -> #4 service:db1:1, RemoveContainer, mounted volume config changed +[2,4] -> #5 volume:data, RemoveVolume, config hash diverged +[5] -> #6 volume:data, CreateVolume, recreate after config change +[6] -> #7 service:db0:1, CreateContainer, no existing container +[6] -> #8 service:db1:1, CreateContainer, no existing container +`)+"\n") +} + +// TestReconcileVolumes_DivergedConfirmedSharedContainer verifies that a service +// mounting two diverged volumes has its container stopped/removed only once, both +// volumes are recreated, and the fresh container depends on both CreateVolume +// nodes. +func TestReconcileVolumes_DivergedConfirmedSharedContainer(t *testing.T) { + vol1 := types.VolumeConfig{Name: "myproject_data1", Driver: "local"} + vol2 := types.VolumeConfig{Name: "myproject_data2", Driver: "local"} + svc := types.ServiceConfig{ + Name: "db", + Scale: intPtr(1), + Volumes: []types.ServiceVolumeConfig{ + {Source: "data1", Type: "volume"}, + {Source: "data2", Type: "volume"}, }, } + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{"data1": vol1, "data2": vol2}, + Services: types.Services{"db": svc}, + } + hash := mustServiceHash(t, svc) observed := &ObservedState{ ProjectName: "myproject", Containers: map[string][]ObservedContainer{ "db": {{ - ID: "c1", Number: 1, State: container.StateRunning, - ConfigHash: mustServiceHash(t, project.Services["db"]), + ID: "c1", Number: 1, State: container.StateRunning, ConfigHash: hash, Summary: container.Summary{ - ID: "c1", - State: container.StateRunning, - Labels: map[string]string{ - api.ServiceLabel: "db", - api.ContainerNumberLabel: "1", - api.ConfigHashLabel: mustServiceHash(t, project.Services["db"]), - }, - Mounts: []container.MountPoint{{Type: "volume", Name: vol.Name}}, + ID: "c1", State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: "db", api.ContainerNumberLabel: "1", api.ConfigHashLabel: hash}, + Mounts: []container.MountPoint{{Type: "volume", Name: vol1.Name}, {Type: "volume", Name: vol2.Name}}, }, }}, }, Networks: map[string]ObservedNetwork{}, Volumes: map[string]ObservedVolume{ - "data": {Name: vol.Name, ConfigHash: "oldhash"}, + "data1": {Name: vol1.Name, ConfigHash: "oldhash"}, + "data2": {Name: vol2.Name, ConfigHash: "oldhash"}, }, } - plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt) + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), yesPrompt) assert.NilError(t, err) - assert.Assert(t, plan.IsEmpty()) + + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 service:db:1, StopContainer, mounted volume config changed +[1] -> #2 service:db:1, RemoveContainer, mounted volume config changed +[2] -> #3 volume:data1, RemoveVolume, config hash diverged +[3] -> #4 volume:data1, CreateVolume, recreate after config change +[2] -> #5 volume:data2, RemoveVolume, config hash diverged +[5] -> #6 volume:data2, CreateVolume, recreate after config change +[4,6] -> #7 service:db:1, CreateContainer, no existing container +`)+"\n") +} + +// TestReconcileVolumes_DivergedPartialConfirm verifies that when several volumes +// diverge but the user confirms only one, only the confirmed volume (and the +// services mounting it) is recreated. +func TestReconcileVolumes_DivergedPartialConfirm(t *testing.T) { + vol1 := types.VolumeConfig{Name: "myproject_data1", Driver: "local"} + vol2 := types.VolumeConfig{Name: "myproject_data2", Driver: "local"} + svc1 := types.ServiceConfig{Name: "db1", Scale: intPtr(1), Volumes: []types.ServiceVolumeConfig{{Source: "data1", Type: "volume"}}} + svc2 := types.ServiceConfig{Name: "db2", Scale: intPtr(1), Volumes: []types.ServiceVolumeConfig{{Source: "data2", Type: "volume"}}} + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{"data1": vol1, "data2": vol2}, + Services: types.Services{"db1": svc1, "db2": svc2}, + } + h1, h2 := mustServiceHash(t, svc1), mustServiceHash(t, svc2) + mountedContainer := func(id, service, hash, volName string) ObservedContainer { + return ObservedContainer{ + ID: id, Number: 1, State: container.StateRunning, ConfigHash: hash, + Summary: container.Summary{ + ID: id, State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: service, api.ContainerNumberLabel: "1", api.ConfigHashLabel: hash}, + Mounts: []container.MountPoint{{Type: "volume", Name: volName}}, + }, + } + } + observed := &ObservedState{ + ProjectName: "myproject", + Containers: map[string][]ObservedContainer{ + "db1": {mountedContainer("c1", "db1", h1, vol1.Name)}, + "db2": {mountedContainer("c2", "db2", h2, vol2.Name)}, + }, + Networks: map[string]ObservedNetwork{}, + Volumes: map[string]ObservedVolume{ + "data1": {Name: vol1.Name, ConfigHash: "oldhash"}, + "data2": {Name: vol2.Name, ConfigHash: "oldhash"}, + }, + } + + // Confirm data1 only (sorted order: data1 prompted first). + first := true + prompt := func(_ string, _ bool) (bool, error) { + if first { + first = false + return true, nil + } + return false, nil + } + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), prompt) + assert.NilError(t, err) + + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 service:db1:1, StopContainer, mounted volume config changed +[1] -> #2 service:db1:1, RemoveContainer, mounted volume config changed +[2] -> #3 volume:data1, RemoveVolume, config hash diverged +[3] -> #4 volume:data1, CreateVolume, recreate after config change +[4] -> #5 service:db1:1, CreateContainer, no existing container +`)+"\n") +} + +// TestReconcileVolumes_DivergedCascadesToDependent verifies that recreating a +// volume cascades to a dependent that shares the mounting service's mounts via +// volumes_from: the dependent keeps a "container:" reference at runtime, so +// it must be recreated even though its own config is unchanged. +func TestReconcileVolumes_DivergedCascadesToDependent(t *testing.T) { + vol := types.VolumeConfig{Name: "myproject_data", Driver: "local"} + owner := types.ServiceConfig{ + Name: "owner", + Image: "alpine", + Scale: intPtr(1), + Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}, + } + dependent := types.ServiceConfig{ + Name: "dependent", + Image: "alpine", + Scale: intPtr(1), + VolumesFrom: []string{"owner"}, + DependsOn: types.DependsOnConfig{"owner": {Condition: types.ServiceConditionStarted, Restart: true, Required: true}}, + } + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{"data": vol}, + Services: types.Services{"owner": owner, "dependent": dependent}, + } + + ownerHash := mustServiceHash(t, owner) + ownerSummary := container.Summary{ + ID: "owner-1", State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: "owner", api.ContainerNumberLabel: "1", api.ConfigHashLabel: ownerHash}, + Mounts: []container.MountPoint{{Type: "volume", Name: vol.Name}}, + } + dependentHash := mustResolvedServiceHash(t, dependent, map[string]Containers{"owner": {ownerSummary}}) + observed := &ObservedState{ + ProjectName: "myproject", + Containers: map[string][]ObservedContainer{ + "owner": {{ID: "owner-1", Number: 1, State: container.StateRunning, ConfigHash: ownerHash, Summary: ownerSummary}}, + "dependent": {{ + ID: "dependent-1", Number: 1, State: container.StateRunning, ConfigHash: dependentHash, + Summary: container.Summary{ + ID: "dependent-1", State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: "dependent", api.ContainerNumberLabel: "1", api.ConfigHashLabel: dependentHash}, + }, + }}, + }, + Networks: map[string]ObservedNetwork{}, + Volumes: map[string]ObservedVolume{"data": {Name: vol.Name, ConfigHash: "oldhash"}}, + } + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), yesPrompt) + assert.NilError(t, err) + + planStr := plan.String() + // Volume recreate sequence for the owner. + assert.Assert(t, strings.Contains(planStr, "service:owner:1, RemoveContainer, mounted volume config changed"), planStr) + assert.Assert(t, strings.Contains(planStr, "volume:data, RemoveVolume, config hash diverged"), planStr) + assert.Assert(t, strings.Contains(planStr, "volume:data, CreateVolume, recreate after config change"), planStr) + assert.Assert(t, strings.Contains(planStr, "service:owner:1, CreateContainer"), planStr) + // Cascade: the dependent must be recreated too. + assert.Assert(t, strings.Contains(planStr, "service:dependent:1, CreateContainer"), "dependent must cascade-recreate:\n%s", planStr) +} + +// TestReconcileVolumes_DivergedUnmountedVolume verifies that a diverged volume +// declared by the project but mounted by no running container is still recreated +// (no container operations, just remove + create). +func TestReconcileVolumes_DivergedUnmountedVolume(t *testing.T) { + vol := types.VolumeConfig{Name: "myproject_data", Driver: "local"} + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{"data": vol}, + Services: types.Services{}, + } + observed := &ObservedState{ + ProjectName: "myproject", + Containers: map[string][]ObservedContainer{}, + Networks: map[string]ObservedNetwork{}, + Volumes: map[string]ObservedVolume{"data": {Name: vol.Name, ConfigHash: "oldhash"}}, + } + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), yesPrompt) + assert.NilError(t, err) + + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 volume:data, RemoveVolume, config hash diverged +[1] -> #2 volume:data, CreateVolume, recreate after config change +`)+"\n") } // --- Container tests --- From 72f540c95ec4ce246a1ab59e25d096b5843c898f Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 22 Jul 2026 16:24:28 +0200 Subject: [PATCH 2/4] fix(reconcile): remove volumes_from consumers before volume recreation servicesUsingVolume only matched services mounting the volume directly, so a service reaching it through volumes_from was not stopped/removed before RemoveVolume. Docker materializes the inherited mount on the consumer's container, so its removal would fail with "volume in use". Compute the transitive volumes_from closure so every container referencing the volume is removed first. (network_mode/ipc/pid: service:x share namespaces, not mounts, and are intentionally excluded.) Also reassign the result of Labels.Add in createVolume: it mutates in place only when the map is non-nil, so discarding the return would drop the config-hash label for a volume with no CustomLabels. Addresses review feedback: documents why observed.Containers is cleared without touching the observedContainersByService hashing snapshot, and strengthens the cascade tests to assert the full plan ordering. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Nicolas De Loof --- pkg/compose/create.go | 2 +- pkg/compose/reconcile.go | 58 +++++++++++++++++-- pkg/compose/reconcile_test.go | 103 +++++++++++++++++++++++++++++++--- 3 files changed, 149 insertions(+), 14 deletions(-) diff --git a/pkg/compose/create.go b/pkg/compose/create.go index 5cfaea5038e..cb5f4f91423 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -1638,7 +1638,7 @@ func (s *composeService) createVolume(ctx context.Context, volume types.VolumeCo if err != nil { return err } - volume.CustomLabels.Add(api.ConfigHashLabel, hash) + volume.CustomLabels = volume.CustomLabels.Add(api.ConfigHashLabel, hash) _, err = s.apiClient().VolumeCreate(ctx, client.VolumeCreateOptions{ Labels: mergeLabels(volume.Labels, volume.CustomLabels), Name: volume.Name, diff --git a/pkg/compose/reconcile.go b/pkg/compose/reconcile.go index 83a07d6a1dc..8fa76835aac 100644 --- a/pkg/compose/reconcile.go +++ b/pkg/compose/reconcile.go @@ -365,7 +365,17 @@ func (r *reconciler) planRecreateVolumes(keys []string) { r.volumeNodes[key] = createVolNode } - // Hand container re-creation to reconcileContainers. + // Hand container re-creation to reconcileContainers: cleared services are + // seen as absent and scheduled fresh (gated on their CreateVolume node via + // infrastructureDeps), and marking them recreated cascades to + // namespace/volume-sharing dependents. + // + // Only observed.Containers is cleared, not the observedContainersByService + // snapshot memoized at reconciler init: that snapshot backs config-hash + // resolution (serviceHashWithResolvedRefs), which must mirror the state the + // executor hashed against at create time, whereas clearing here is purely a + // scheduling concern carried by the plan's dependency edges. The two + // intentionally diverge; do not "fix" one to match the other. for _, svc := range services { r.recreatedServices[svc] = true r.observed.Containers[svc] = nil @@ -385,20 +395,56 @@ func (r *reconciler) servicesUsingNetwork(networkKey string) []string { return names } -// servicesUsingVolume returns the names of services that mount the given -// compose volume key, sorted for deterministic plan output. +// servicesUsingVolume returns the names of services whose containers reference +// the given compose volume — either by mounting it directly (service.Volumes) or +// by inheriting the mount transitively through volumes_from. Every such service's +// containers must be removed before the volume can be removed: Docker refuses to +// remove a volume still referenced by any container, and volumes_from +// materializes the source's mounts on the target container. Sorted for +// deterministic plan output. +// +// Only volumes_from propagates a *mount* (and therefore a volume reference); +// network_mode/ipc/pid: service:x share namespaces, not mounts, so they do not +// keep a volume in use and are intentionally excluded here. func (r *reconciler) servicesUsingVolume(volumeKey string) []string { - var names []string + inSet := map[string]bool{} + // Seed with services that mount the volume directly. for _, key := range sortedKeys(r.project.Services) { svc := r.project.Services[key] for _, v := range svc.Volumes { if v.Source == volumeKey { - names = append(names, svc.Name) + inSet[svc.Name] = true break } } } - return names + // Grow the set by transitive volumes_from closure until it stabilizes: a + // service inherits the mount when it draws volumes from a service already in + // the set (references to external containers carry no compose dependency). + for { + added := false + for _, key := range sortedKeys(r.project.Services) { + svc := r.project.Services[key] + if inSet[svc.Name] { + continue + } + for _, vf := range svc.VolumesFrom { + if strings.HasPrefix(vf, types.ContainerPrefix) { + continue + } + name, _, _ := strings.Cut(vf, ":") + if inSet[name] { + inSet[svc.Name] = true + added = true + break + } + } + } + if !added { + break + } + } + return sortedKeys(inSet) } // containersForServices returns all observed containers belonging to the given diff --git a/pkg/compose/reconcile_test.go b/pkg/compose/reconcile_test.go index 0afdc63b635..445990b0e90 100644 --- a/pkg/compose/reconcile_test.go +++ b/pkg/compose/reconcile_test.go @@ -616,14 +616,103 @@ func TestReconcileVolumes_DivergedCascadesToDependent(t *testing.T) { plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), yesPrompt) assert.NilError(t, err) + // The dependent inherits the volume mount via volumes_from, so its container + // must be stopped and removed before RemoveVolume (#5 depends on both #2 and + // #4) — otherwise the removal would fail with "volume in use". Both fresh + // containers are then gated on the new volume: owner (#7) depends on + // CreateVolume (#6), and the dependent (#8) depends on owner (#7). + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 service:dependent:1, StopContainer, mounted volume config changed +[1] -> #2 service:dependent:1, RemoveContainer, mounted volume config changed +[] -> #3 service:owner:1, StopContainer, mounted volume config changed +[3] -> #4 service:owner:1, RemoveContainer, mounted volume config changed +[2,4] -> #5 volume:data, RemoveVolume, config hash diverged +[5] -> #6 volume:data, CreateVolume, recreate after config change +[6] -> #7 service:owner:1, CreateContainer, no existing container +[7] -> #8 service:dependent:1, CreateContainer, no existing container +`)+"\n") +} + +// TestReconcileVolumes_DivergedVolumesFromRemovedBeforeVolume specifically guards +// against a "volume in use" failure: a service reaching the diverged volume only +// through volumes_from (never mounting it directly) must still have its container +// removed before the volume is removed. +func TestReconcileVolumes_DivergedVolumesFromRemovedBeforeVolume(t *testing.T) { + vol := types.VolumeConfig{Name: "myproject_data", Driver: "local"} + owner := types.ServiceConfig{ + Name: "owner", + Image: "alpine", + Scale: intPtr(1), + Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}, + } + // consumer inherits owner's mounts (including data) but never declares the + // volume itself. + consumer := types.ServiceConfig{ + Name: "consumer", + Image: "alpine", + Scale: intPtr(1), + VolumesFrom: []string{"owner"}, + DependsOn: types.DependsOnConfig{"owner": {Condition: types.ServiceConditionStarted, Required: true}}, + } + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{"data": vol}, + Services: types.Services{"owner": owner, "consumer": consumer}, + } + ownerHash := mustServiceHash(t, owner) + ownerSummary := container.Summary{ + ID: "owner-1", State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: "owner", api.ContainerNumberLabel: "1", api.ConfigHashLabel: ownerHash}, + Mounts: []container.MountPoint{{Type: "volume", Name: vol.Name}}, + } + consumerHash := mustResolvedServiceHash(t, consumer, map[string]Containers{"owner": {ownerSummary}}) + observed := &ObservedState{ + ProjectName: "myproject", + Containers: map[string][]ObservedContainer{ + "owner": {{ID: "owner-1", Number: 1, State: container.StateRunning, ConfigHash: ownerHash, Summary: ownerSummary}}, + "consumer": {{ + ID: "consumer-1", Number: 1, State: container.StateRunning, ConfigHash: consumerHash, + Summary: container.Summary{ + ID: "consumer-1", State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: "consumer", api.ContainerNumberLabel: "1", api.ConfigHashLabel: consumerHash}, + // Docker materializes the inherited mount on the consumer. + Mounts: []container.MountPoint{{Type: "volume", Name: vol.Name}}, + }, + }}, + }, + Networks: map[string]ObservedNetwork{}, + Volumes: map[string]ObservedVolume{"data": {Name: vol.Name, ConfigHash: "oldhash"}}, + } + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), yesPrompt) + assert.NilError(t, err) + planStr := plan.String() - // Volume recreate sequence for the owner. - assert.Assert(t, strings.Contains(planStr, "service:owner:1, RemoveContainer, mounted volume config changed"), planStr) - assert.Assert(t, strings.Contains(planStr, "volume:data, RemoveVolume, config hash diverged"), planStr) - assert.Assert(t, strings.Contains(planStr, "volume:data, CreateVolume, recreate after config change"), planStr) - assert.Assert(t, strings.Contains(planStr, "service:owner:1, CreateContainer"), planStr) - // Cascade: the dependent must be recreated too. - assert.Assert(t, strings.Contains(planStr, "service:dependent:1, CreateContainer"), "dependent must cascade-recreate:\n%s", planStr) + // The volumes_from consumer is stopped and removed as part of the volume + // recreation batch, even though it never declares the volume. + assert.Assert(t, strings.Contains(planStr, "service:consumer:1, RemoveContainer, mounted volume config changed"), + "volumes_from consumer must be removed before RemoveVolume:\n%s", planStr) + + // RemoveVolume must depend on the consumer's RemoveContainer node. + var removeConsumer, removeVolume *PlanNode + for _, n := range plan.Nodes { + if n.Operation.Type == OpRemoveContainer && n.Operation.ResourceID == "service:consumer:1" { + removeConsumer = n + } + if n.Operation.Type == OpRemoveVolume { + removeVolume = n + } + } + assert.Assert(t, removeConsumer != nil, "no RemoveContainer for consumer:\n%s", planStr) + assert.Assert(t, removeVolume != nil, "no RemoveVolume:\n%s", planStr) + found := false + for _, dep := range removeVolume.DependsOn { + if dep == removeConsumer { + found = true + break + } + } + assert.Assert(t, found, "RemoveVolume must depend on the consumer's RemoveContainer:\n%s", planStr) } // TestReconcileVolumes_DivergedUnmountedVolume verifies that a diverged volume From 2ba485b137d35dbd7d4f7875c9d80acea3c9b8ec Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 23 Jul 2026 15:17:07 +0200 Subject: [PATCH 3/4] fix(reconcile): preserve backward compatibility for legacy volumes Two edge regressions from the switch to a label-scoped observed state, both reported against the old ensureVolume path: - A same-named volume created manually or by another project (no compose label) was invisible to the observed state, so a VolumeCreate was planned on every up: a hard failure if the driver differed, spurious Creating/Created events otherwise. collectObservedState now discovers such volumes by name (pre-label Compose semantics) and records them as unmanaged matches with an empty config-hash, so the reconciler reuses them untouched. The ownership warnings move to warnUnmanagedVolumes, driven off the observed state; checkVolumes shrinks to external-only validation (checkExternalVolumes). - Renaming a volume hit the diverged path and, with up -y, deleted the old volume and its data (VolumeHash includes Name), where it previously just created the new one. When observed.Name != desired.Name the volume is now created additively, leaving the old one untouched, with no prompt. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Nicolas De Loof --- pkg/compose/create.go | 60 ++++++++------- pkg/compose/observed_state.go | 38 ++++++++++ pkg/compose/observed_state_test.go | 114 +++++++++++++++++++++++++++++ pkg/compose/reconcile.go | 19 +++-- pkg/compose/reconcile_test.go | 68 +++++++++++++++++ 5 files changed, 268 insertions(+), 31 deletions(-) diff --git a/pkg/compose/create.go b/pkg/compose/create.go index cb5f4f91423..434ae7b0e8c 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -93,7 +93,7 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt } prepareVolumes(project) - externalVolumes, err := s.checkVolumes(ctx, project) + externalVolumes, err := s.checkExternalVolumes(ctx, project) if err != nil { return err } @@ -110,6 +110,7 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt } observed.setResolvedNetworks(networks, project) observed.setResolvedVolumes(externalVolumes) + warnUnmanagedVolumes(project, observed) if len(observed.Orphans) > 0 && !options.IgnoreOrphans && !options.RemoveOrphans { logrus.Warnf("Found orphan containers (%s) for this project. If "+ @@ -167,42 +168,51 @@ func prepareVolumes(project *types.Project) { } } -// checkVolumes validates that external volumes exist and warns about non-external -// volumes whose name collides with a volume not managed by this project. Creation -// and recreation of managed volumes is owned by the reconciliation plan, so this -// function performs no mutation. +// checkExternalVolumes validates that every external volume exists and returns +// their resolved names. External volumes carry no compose label and are +// therefore absent from the label-scoped observed state, so the reconciler needs +// them injected via setResolvedVolumes. // -// It returns the resolved names of external volumes: those are not labelled by -// Compose and are therefore absent from the observed state, so the reconciler -// needs them injected via setResolvedVolumes. -func (s *composeService) checkVolumes(ctx context.Context, project *types.Project) (map[string]string, error) { +// Managed and legacy (unlabeled, name-matched) volumes are discovered by +// collectObservedState; their lifecycle is owned by the reconciliation plan, so +// this function performs no mutation on them. +func (s *composeService) checkExternalVolumes(ctx context.Context, project *types.Project) (map[string]string, error) { external := map[string]string{} for k, volume := range project.Volumes { - if volume.External { - if _, err := s.apiClient().VolumeInspect(ctx, volume.Name, client.VolumeInspectOptions{}); err != nil { - if errdefs.IsNotFound(err) { - return nil, fmt.Errorf("external volume %q not found", volume.Name) - } - return nil, err - } - external[k] = volume.Name + if !volume.External { continue } - - inspected, err := s.apiClient().VolumeInspect(ctx, volume.Name, client.VolumeInspectOptions{}) - if err != nil { + if _, err := s.apiClient().VolumeInspect(ctx, volume.Name, client.VolumeInspectOptions{}); err != nil { if errdefs.IsNotFound(err) { - continue // absent: it will be created by the reconciliation plan + return nil, fmt.Errorf("external volume %q not found", volume.Name) } return nil, err } - if p, ok := inspected.Volume.Labels[api.ProjectLabel]; !ok { + external[k] = volume.Name + } + return external, nil +} + +// warnUnmanagedVolumes warns about declared volumes backed by a live volume that +// this project does not own — either created outside Compose (no project label) +// or by another project. Such volumes are matched by name and reused untouched +// (see collectObservedState); the warning tells the user to set `external: true` +// to make the intent explicit. +func warnUnmanagedVolumes(project *types.Project, observed *ObservedState) { + for k, volume := range project.Volumes { + if volume.External { + continue + } + obs, ok := observed.Volumes[k] + if !ok || obs.ProjectName == project.Name { + continue + } + if obs.ProjectName == "" { logrus.Warnf("volume %q already exists but was not created by Docker Compose. Use `external: true` to use an existing volume", volume.Name) - } else if p != project.Name { - logrus.Warnf("volume %q already exists but was created for project %q (expected %q). Use `external: true` to use an existing volume", volume.Name, p, project.Name) + } else { + logrus.Warnf("volume %q already exists but was created for project %q (expected %q). Use `external: true` to use an existing volume", volume.Name, obs.ProjectName, project.Name) } } - return external, nil } //nolint:gocyclo diff --git a/pkg/compose/observed_state.go b/pkg/compose/observed_state.go index 4ec472365e7..8379803f0b6 100644 --- a/pkg/compose/observed_state.go +++ b/pkg/compose/observed_state.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/compose-spec/compose-go/v2/types" + "github.com/containerd/errdefs" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" @@ -155,9 +156,46 @@ func (s *composeService) collectObservedState(ctx context.Context, project *type } } + if err := s.discoverUnmanagedVolumes(ctx, project, state); err != nil { + return nil, err + } + return state, nil } +// discoverUnmanagedVolumes augments the observed state with volumes that match a +// declared volume by name but carry no compose label — pre-label Compose or +// manually created volumes, missed by the label-filtered VolumeList. Each is +// recorded as an unmanaged match with an empty ConfigHash: the reconciler then +// reuses it untouched instead of scheduling a (possibly failing) VolumeCreate. +// See warnUnmanagedVolumes for the accompanying user warning. +func (s *composeService) discoverUnmanagedVolumes(ctx context.Context, project *types.Project, state *ObservedState) error { + for _, key := range project.VolumeNames() { + vol := project.Volumes[key] + if vol.External { + continue + } + if _, ok := state.Volumes[key]; ok { + continue + } + inspected, err := s.apiClient().VolumeInspect(ctx, vol.Name, client.VolumeInspectOptions{}) + if err != nil { + if errdefs.IsNotFound(err) { + continue // absent: it will be created by the reconciliation plan + } + return err + } + state.Volumes[key] = ObservedVolume{ + Name: inspected.Volume.Name, + ProjectName: inspected.Volume.Labels[api.ProjectLabel], + Driver: inspected.Volume.Driver, + // ConfigHash intentionally left empty: the volume is not owned by + // this project, so we must not treat it as diverged and recreate it. + } + } + return nil +} + // toObservedContainer extracts the relevant fields from a container.Summary, // parsing labels into typed values. func toObservedContainer(c container.Summary) ObservedContainer { diff --git a/pkg/compose/observed_state_test.go b/pkg/compose/observed_state_test.go index 0b182642e5b..f6a119c0eb1 100644 --- a/pkg/compose/observed_state_test.go +++ b/pkg/compose/observed_state_test.go @@ -17,6 +17,7 @@ package compose import ( + "strings" "testing" "github.com/compose-spec/compose-go/v2/types" @@ -24,6 +25,8 @@ import ( "github.com/moby/moby/api/types/network" "github.com/moby/moby/api/types/volume" "github.com/moby/moby/client" + "github.com/sirupsen/logrus" + logrustest "github.com/sirupsen/logrus/hooks/test" "go.uber.org/mock/gomock" "gotest.tools/v3/assert" @@ -202,6 +205,117 @@ func TestCollectObservedState(t *testing.T) { assert.Equal(t, vol.ConfigHash, "volhash1") } +// collectVolumesOnly mocks empty container/network/volume lists so that only the +// legacy by-name volume discovery is exercised. +func collectVolumesOnly(t *testing.T, project *types.Project, inspect func(apiClient *mocks.MockAPIClient)) (*ObservedState, error) { + t.Helper() + svc, apiClient := newTestService(t) + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{}, nil) + apiClient.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil) + apiClient.EXPECT().VolumeList(gomock.Any(), gomock.Any()).Return(client.VolumeListResult{}, nil) + inspect(apiClient) + return svc.collectObservedState(t.Context(), project) +} + +// TestCollectObservedState_LegacyVolumeMatchedByName verifies that a volume that +// matches a declared volume by name but carries no compose label (pre-label +// Compose or manually created) is recorded as an unmanaged match with an empty +// ConfigHash, so the reconciler reuses it untouched. +func TestCollectObservedState_LegacyVolumeMatchedByName(t *testing.T) { + project := &types.Project{Name: "myproject", Volumes: types.Volumes{"data": {Name: "myproject_data"}}} + state, err := collectVolumesOnly(t, project, func(apiClient *mocks.MockAPIClient) { + apiClient.EXPECT().VolumeInspect(gomock.Any(), "myproject_data", gomock.Any()).Return(client.VolumeInspectResult{ + Volume: volume.Volume{Name: "myproject_data", Driver: "local"}, + }, nil) + }) + assert.NilError(t, err) + obs, ok := state.Volumes["data"] + assert.Assert(t, ok, "legacy volume must be discovered by name") + assert.Equal(t, obs.Name, "myproject_data") + assert.Equal(t, obs.ProjectName, "") + assert.Equal(t, obs.ConfigHash, "", "unmanaged match must have an empty config hash") +} + +// TestCollectObservedState_ForeignProjectVolumeMatchedByName verifies that a +// volume owned by another project but matching the declared name is recorded +// with an empty ConfigHash (reused untouched, never recreated) and keeps the +// foreign project name for the warning. +func TestCollectObservedState_ForeignProjectVolumeMatchedByName(t *testing.T) { + project := &types.Project{Name: "myproject", Volumes: types.Volumes{"data": {Name: "shared_data"}}} + state, err := collectVolumesOnly(t, project, func(apiClient *mocks.MockAPIClient) { + apiClient.EXPECT().VolumeInspect(gomock.Any(), "shared_data", gomock.Any()).Return(client.VolumeInspectResult{ + Volume: volume.Volume{Name: "shared_data", Driver: "local", Labels: map[string]string{ + api.ProjectLabel: "otherproject", + api.ConfigHashLabel: "foreignhash", + }}, + }, nil) + }) + assert.NilError(t, err) + obs := state.Volumes["data"] + assert.Equal(t, obs.ProjectName, "otherproject") + assert.Equal(t, obs.ConfigHash, "", "foreign volume must not be treated as diverged") +} + +// TestCollectObservedState_VolumeNotFoundByName verifies that a declared volume +// with no live counterpart is left absent so the reconciler schedules a create. +func TestCollectObservedState_VolumeNotFoundByName(t *testing.T) { + project := &types.Project{Name: "myproject", Volumes: types.Volumes{"data": {Name: "myproject_data"}}} + state, err := collectVolumesOnly(t, project, func(apiClient *mocks.MockAPIClient) { + apiClient.EXPECT().VolumeInspect(gomock.Any(), "myproject_data", gomock.Any()).Return(client.VolumeInspectResult{}, notFoundError{}) + }) + assert.NilError(t, err) + _, ok := state.Volumes["data"] + assert.Assert(t, !ok, "absent volume must not be in observed state") +} + +// TestCollectObservedState_ExternalVolumeNotInspectedByName verifies external +// volumes are not part of the legacy by-name discovery (no VolumeInspect call: +// gomock would fail on an unexpected call). +func TestCollectObservedState_ExternalVolumeNotInspectedByName(t *testing.T) { + project := &types.Project{Name: "myproject", Volumes: types.Volumes{"data": {Name: "ext_data", External: true}}} + state, err := collectVolumesOnly(t, project, func(_ *mocks.MockAPIClient) {}) + assert.NilError(t, err) + _, ok := state.Volumes["data"] + assert.Assert(t, !ok) +} + +// TestWarnUnmanagedVolumes verifies the legacy ownership warnings are preserved +// for volumes reused by name, and not emitted for managed or external volumes. +func TestWarnUnmanagedVolumes(t *testing.T) { + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{ + "managed": {Name: "myproject_managed"}, + "unlabel": {Name: "unlabel_data"}, + "foreign": {Name: "foreign_data"}, + "external": {Name: "ext_data", External: true}, + "tocreate": {Name: "myproject_tocreate"}, + }, + } + observed := &ObservedState{ + Volumes: map[string]ObservedVolume{ + "managed": {Name: "myproject_managed", ProjectName: "myproject", ConfigHash: "h"}, + "unlabel": {Name: "unlabel_data", ProjectName: ""}, + "foreign": {Name: "foreign_data", ProjectName: "otherproject"}, + "external": {Name: "ext_data"}, + // "tocreate" absent: will be created, no warning. + }, + } + + hook := logrustest.NewGlobal() + warnUnmanagedVolumes(project, observed) + + var msgs []string + for _, e := range hook.AllEntries() { + assert.Equal(t, e.Level, logrus.WarnLevel) + msgs = append(msgs, e.Message) + } + assert.Equal(t, len(msgs), 2, "expected exactly two warnings, got: %v", msgs) + joined := strings.Join(msgs, "\n") + assert.Assert(t, strings.Contains(joined, `volume "unlabel_data" already exists but was not created by Docker Compose`), joined) + assert.Assert(t, strings.Contains(joined, `volume "foreign_data" already exists but was created for project "otherproject"`), joined) +} + type capturingEvents struct { noopEventProcessor resources []api.Resource diff --git a/pkg/compose/reconcile.go b/pkg/compose/reconcile.go index 8fa76835aac..5f45958c374 100644 --- a/pkg/compose/reconcile.go +++ b/pkg/compose/reconcile.go @@ -247,7 +247,7 @@ func (r *reconciler) reconcileVolumes() error { } observed, exists := r.observed.Volumes[key] if !exists { - r.planCreateVolume(key, &desired) + r.planCreateVolume(key, &desired, "not found") continue } expected, err := VolumeHash(desired) @@ -257,6 +257,15 @@ func (r *reconciler) reconcileVolumes() error { if observed.ConfigHash == "" || observed.ConfigHash == expected { continue } + if observed.Name != desired.Name { + // The volume was renamed: the live volume matched by label carries a + // different name, i.e. a distinct Docker resource. Match the + // historical additive behavior — create the new volume and leave the + // old one (and its data) untouched — instead of prompting to delete + // data under a name that does not exist yet. + r.planCreateVolume(key, &desired, "renamed") + continue + } confirmed, err := r.prompt( fmt.Sprintf("Volume %q exists but doesn't match configuration in compose file. Recreate (data will be lost)?", desired.Name), false) @@ -272,16 +281,14 @@ func (r *reconciler) reconcileVolumes() error { } // planCreateVolume adds a single CreateVolume node and records it for dependency tracking. -func (r *reconciler) planCreateVolume(key string, vol *types.VolumeConfig) *PlanNode { - node := r.plan.addNode(Operation{ +func (r *reconciler) planCreateVolume(key string, vol *types.VolumeConfig, cause string) { + r.volumeNodes[key] = r.plan.addNode(Operation{ Type: OpCreateVolume, ResourceID: fmt.Sprintf("volume:%s", key), - Cause: "not found", + Cause: cause, Name: vol.Name, Volume: vol, }, "") - r.volumeNodes[key] = node - return node } // planRecreateVolumes schedules the recreation of the given (confirmed) diverged diff --git a/pkg/compose/reconcile_test.go b/pkg/compose/reconcile_test.go index 445990b0e90..bb805c60c89 100644 --- a/pkg/compose/reconcile_test.go +++ b/pkg/compose/reconcile_test.go @@ -715,6 +715,67 @@ func TestReconcileVolumes_DivergedVolumesFromRemovedBeforeVolume(t *testing.T) { assert.Assert(t, found, "RemoveVolume must depend on the consumer's RemoveContainer:\n%s", planStr) } +// TestReconcileVolumes_UnmanagedMatchReused verifies that a volume discovered by +// name but not owned by the project (empty ConfigHash — see collectObservedState) +// is reused untouched: no create, no recreation, and no prompt. +func TestReconcileVolumes_UnmanagedMatchReused(t *testing.T) { + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{"data": {Name: "myproject_data", Driver: "local"}}, + Services: types.Services{ + "db": {Name: "db", Scale: intPtr(1), Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}}, + }, + } + dbHash := mustServiceHash(t, project.Services["db"]) + observed := &ObservedState{ + ProjectName: "myproject", + Containers: map[string][]ObservedContainer{ + "db": {{ + ID: "c1", Number: 1, State: container.StateRunning, ConfigHash: dbHash, + Summary: container.Summary{ + ID: "c1", State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: "db", api.ContainerNumberLabel: "1", api.ConfigHashLabel: dbHash}, + Mounts: []container.MountPoint{{Type: "volume", Name: "myproject_data"}}, + }, + }}, + }, + Networks: map[string]ObservedNetwork{}, + // Unmanaged match: name resolved, but no config hash recorded. + Volumes: map[string]ObservedVolume{"data": {Name: "myproject_data", ConfigHash: ""}}, + } + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt) + assert.NilError(t, err) + assert.Assert(t, plan.IsEmpty(), "unmanaged volume must be reused untouched:\n%s", plan.String()) +} + +// TestReconcileVolumes_RenamedIsAdditive verifies that renaming a volume (the +// label-matched live volume carries a different name) creates the new volume and +// leaves the old one — and its data — untouched, without prompting. +func TestReconcileVolumes_RenamedIsAdditive(t *testing.T) { + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{"data": {Name: "myproject_data_v2", Driver: "local"}}, + } + observed := &ObservedState{ + ProjectName: "myproject", + Containers: map[string][]ObservedContainer{}, + Networks: map[string]ObservedNetwork{}, + // Same compose key "data", but the live volume still has the old name. + Volumes: map[string]ObservedVolume{ + "data": {Name: "myproject_data", ConfigHash: mustVolumeHash(t, types.VolumeConfig{Name: "myproject_data", Driver: "local"})}, + }, + } + + // noPrompt: a rename must not prompt for destructive recreation. + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt) + assert.NilError(t, err) + + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 volume:data, CreateVolume, renamed +`)+"\n") +} + // TestReconcileVolumes_DivergedUnmountedVolume verifies that a diverged volume // declared by the project but mounted by no running container is still recreated // (no container operations, just remove + create). @@ -1326,6 +1387,13 @@ func mustServiceHash(t *testing.T, svc types.ServiceConfig) string { return h } +func mustVolumeHash(t *testing.T, vol types.VolumeConfig) string { + t.Helper() + h, err := VolumeHash(vol) + assert.NilError(t, err) + return h +} + // mustResolvedServiceHash mirrors what the executor persists at create time: // the service references are resolved before hashing. Use it to seed // ObservedContainer.ConfigHash in tests involving network_mode/ipc/pid: From f16d0bc891732ff0e8e5902f8220b05e018a23b8 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 23 Jul 2026 15:50:52 +0200 Subject: [PATCH 4/4] fix(reconcile): migrate containers to the renamed volume within the same up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The additive rename path created the new volume but kept the old name in the observed state, so hasVolumeMismatch never fired: existing containers stayed on the old volume while fresh replicas mounted the new one (split-brain), and later runs picked a nondeterministic winner between the two equally labelled volumes. Rewrite the observed volume name to the desired one after planning the "renamed" create, so reconcileContainers migrates the existing containers onto the new volume in the same up — restoring parity with the old ensureVolume path — while still leaving the old volume and its data intact. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Nicolas De Loof --- pkg/compose/reconcile.go | 7 ++++++ pkg/compose/reconcile_test.go | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/pkg/compose/reconcile.go b/pkg/compose/reconcile.go index 5f45958c374..64aee5bc5ad 100644 --- a/pkg/compose/reconcile.go +++ b/pkg/compose/reconcile.go @@ -264,6 +264,13 @@ func (r *reconciler) reconcileVolumes() error { // old one (and its data) untouched — instead of prompting to delete // data under a name that does not exist yet. r.planCreateVolume(key, &desired, "renamed") + // Rewrite the observed name to the desired one so reconcileContainers + // detects the mount mismatch and migrates existing containers onto + // the new volume within the same up (as the pre-reconcile ensureVolume + // path did), and so later runs match deterministically on the new + // name rather than split-braining between the two. + observed.Name = desired.Name + r.observed.Volumes[key] = observed continue } confirmed, err := r.prompt( diff --git a/pkg/compose/reconcile_test.go b/pkg/compose/reconcile_test.go index bb805c60c89..cb83956b440 100644 --- a/pkg/compose/reconcile_test.go +++ b/pkg/compose/reconcile_test.go @@ -776,6 +776,53 @@ func TestReconcileVolumes_RenamedIsAdditive(t *testing.T) { `)+"\n") } +// TestReconcileVolumes_RenamedMigratesContainers verifies that a rename migrates +// the containers mounting the volume onto the freshly created one within the same +// up (matching the pre-reconcile ensureVolume behavior): the new volume is +// created additively (no RemoveVolume), and the container is recreated because +// its mount no longer matches the desired volume name. +func TestReconcileVolumes_RenamedMigratesContainers(t *testing.T) { + project := &types.Project{ + Name: "myproject", + Volumes: types.Volumes{"data": {Name: "myproject_data_v2", Driver: "local"}}, + Services: types.Services{ + "db": {Name: "db", Scale: intPtr(1), Volumes: []types.ServiceVolumeConfig{{Source: "data", Type: "volume"}}}, + }, + } + dbHash := mustServiceHash(t, project.Services["db"]) + observed := &ObservedState{ + ProjectName: "myproject", + Containers: map[string][]ObservedContainer{ + "db": {{ + ID: "c1aabbccddee", Number: 1, State: container.StateRunning, ConfigHash: dbHash, + Summary: container.Summary{ + ID: "c1aabbccddee", State: container.StateRunning, + Labels: map[string]string{api.ServiceLabel: "db", api.ContainerNumberLabel: "1", api.ConfigHashLabel: dbHash}, + // The existing container is still mounted on the old volume. + Mounts: []container.MountPoint{{Type: "volume", Name: "myproject_data"}}, + }, + }}, + }, + Networks: map[string]ObservedNetwork{}, + Volumes: map[string]ObservedVolume{ + "data": {Name: "myproject_data", ConfigHash: mustVolumeHash(t, types.VolumeConfig{Name: "myproject_data", Driver: "local"})}, + }, + } + + plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt) + assert.NilError(t, err) + + // New volume created additively (no RemoveVolume), and the container is + // recreated to migrate onto it. + assert.Equal(t, plan.String(), strings.TrimSpace(` +[] -> #1 volume:data, CreateVolume, renamed +[1] -> #2 service:db:1, CreateContainer, config changed (tmpName) [recreate:db:1] +[2] -> #3 service:db:1, StopContainer, replaced by #2 [recreate:db:1] +[3] -> #4 service:db:1, RemoveContainer, replaced by #2 [recreate:db:1] +[4] -> #5 service:db:1, RenameContainer, finalize recreate [recreate:db:1] +`)+"\n") +} + // TestReconcileVolumes_DivergedUnmountedVolume verifies that a diverged volume // declared by the project but mounted by no running container is still recreated // (no container operations, just remove + create).