mirror of
https://github.com/docker/compose.git
synced 2026-09-23 14:04:35 +00:00
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:
parent
ef5076f0ad
commit
44ac2c94e9
6 changed files with 587 additions and 203 deletions
2
AI_AGENT_DISCLOSURE.md
Normal file
2
AI_AGENT_DISCLOSURE.md
Normal 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.
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue