From 44ac2c94e92f5a8abfa95d4d551774e79f1846ac Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 22 Jul 2026 16:08:05 +0200 Subject: [PATCH] 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 000000000..37a1b5a1b --- /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 6d15f28f2..5cfaea503 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 5f4ab8804..2228361c4 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 9fd7b42bb..4ec472365 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 2169d9a5c..83a07d6a1 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 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) +// 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 } - // Remove all affected containers (each depends on its own stop) + // 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) + + // 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...) + // 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 + } - // 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 + // 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 991b7a4f8..0afdc63b6 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 ---