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) <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2026-07-22 16:08:05 +02:00 committed by Guillaume Lours
parent ef5076f0ad
commit 44ac2c94e9
6 changed files with 587 additions and 203 deletions

2
AI_AGENT_DISCLOSURE.md Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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