Skip to content

Commit 2121450

Browse files
committed
Reconciliation : networks and volumes
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent b575fdb commit 2121450

File tree

2 files changed

+664
-0
lines changed

2 files changed

+664
-0
lines changed

pkg/compose/reconcile.go

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
"github.com/compose-spec/compose-go/v2/types"
24+
)
25+
26+
// ReconcileOptions controls how the reconciler compares desired and observed state.
27+
type ReconcileOptions struct {
28+
Services []string // targeted services (empty = all)
29+
Recreate string // "diverged", "force", "never" for targeted services
30+
RecreateDependencies string // same for non-targeted services
31+
Inherit bool // inherit anonymous volumes on recreate
32+
RemoveOrphans bool
33+
SkipProviders bool
34+
}
35+
36+
// reconciler compares a types.Project (desired state) with an ObservedState
37+
// (actual state) and produces a Plan — a DAG of atomic operations.
38+
type reconciler struct {
39+
project *types.Project
40+
observed *ObservedState
41+
options ReconcileOptions
42+
prompt Prompt
43+
plan *Plan
44+
}
45+
46+
// reconcile is the main entry point: it builds a Plan from desired vs observed state.
47+
// The prompt function is called for interactive decisions (e.g. volume divergence).
48+
func reconcile(_ context.Context, project *types.Project, observed *ObservedState, options ReconcileOptions, prompt Prompt) (*Plan, error) {
49+
r := &reconciler{
50+
project: project,
51+
observed: observed,
52+
options: options,
53+
prompt: prompt,
54+
plan: &Plan{},
55+
}
56+
57+
if err := r.reconcileNetworks(); err != nil {
58+
return nil, err
59+
}
60+
61+
if err := r.reconcileVolumes(); err != nil {
62+
return nil, err
63+
}
64+
65+
return r.plan, nil
66+
}
67+
68+
// reconcileNetworks adds plan nodes for network creation or recreation.
69+
func (r *reconciler) reconcileNetworks() error {
70+
for key, desired := range r.project.Networks {
71+
if desired.External {
72+
continue
73+
}
74+
observed, exists := r.observed.Networks[key]
75+
if !exists {
76+
r.planCreateNetwork(key, &desired)
77+
continue
78+
}
79+
80+
expectedHash, err := NetworkHash(&desired)
81+
if err != nil {
82+
return err
83+
}
84+
if observed.ConfigHash != "" && observed.ConfigHash != expectedHash {
85+
if err := r.planRecreateNetwork(key, &desired); err != nil {
86+
return err
87+
}
88+
}
89+
// else: network exists and config matches, nothing to do
90+
}
91+
return nil
92+
}
93+
94+
// planCreateNetwork adds a single CreateNetwork node.
95+
func (r *reconciler) planCreateNetwork(key string, nw *types.NetworkConfig) *PlanNode {
96+
return r.plan.addNode(Operation{
97+
Type: OpCreateNetwork,
98+
ResourceID: fmt.Sprintf("network:%s", key),
99+
Cause: "not found",
100+
Name: nw.Name,
101+
Network: nw,
102+
}, "")
103+
}
104+
105+
// planRecreateNetwork adds the full sequence for a diverged network:
106+
// stop affected containers → disconnect → remove network → create network.
107+
func (r *reconciler) planRecreateNetwork(key string, nw *types.NetworkConfig) error {
108+
affectedServices := r.servicesUsingNetwork(key)
109+
affectedContainers := r.containersForServices(affectedServices)
110+
111+
// Stop all affected containers
112+
var stopNodes []*PlanNode
113+
for i := range affectedContainers {
114+
oc := &affectedContainers[i]
115+
node := r.plan.addNode(Operation{
116+
Type: OpStopContainer,
117+
ResourceID: fmt.Sprintf("service:%s:%d", oc.Summary.Labels[serviceLabel], oc.Number),
118+
Cause: fmt.Sprintf("network %s config changed", key),
119+
Container: &oc.Summary,
120+
}, "")
121+
stopNodes = append(stopNodes, node)
122+
}
123+
124+
// Disconnect all affected containers (each depends on its own stop)
125+
var disconnectNodes []*PlanNode
126+
for i, oc := range affectedContainers {
127+
node := r.plan.addNode(Operation{
128+
Type: OpDisconnectNetwork,
129+
ResourceID: fmt.Sprintf("service:%s:%d", oc.Summary.Labels[serviceLabel], oc.Number),
130+
Cause: fmt.Sprintf("network %s recreate", key),
131+
Container: &affectedContainers[i].Summary,
132+
Name: nw.Name,
133+
}, "", stopNodes[i])
134+
disconnectNodes = append(disconnectNodes, node)
135+
}
136+
137+
// Remove network (depends on all disconnects)
138+
removeNode := r.plan.addNode(Operation{
139+
Type: OpRemoveNetwork,
140+
ResourceID: fmt.Sprintf("network:%s", key),
141+
Cause: "config hash diverged",
142+
Name: nw.Name,
143+
Network: nw,
144+
}, "", disconnectNodes...)
145+
146+
// Create network (depends on remove)
147+
r.plan.addNode(Operation{
148+
Type: OpCreateNetwork,
149+
ResourceID: fmt.Sprintf("network:%s", key),
150+
Cause: "recreate after config change",
151+
Name: nw.Name,
152+
Network: nw,
153+
}, "", removeNode)
154+
155+
return nil
156+
}
157+
158+
// reconcileVolumes adds plan nodes for volume creation or recreation.
159+
func (r *reconciler) reconcileVolumes() error {
160+
for key, desired := range r.project.Volumes {
161+
if desired.External {
162+
continue
163+
}
164+
observed, exists := r.observed.Volumes[key]
165+
if !exists {
166+
r.planCreateVolume(key, &desired)
167+
continue
168+
}
169+
170+
expectedHash, err := VolumeHash(desired)
171+
if err != nil {
172+
return err
173+
}
174+
if observed.ConfigHash != "" && observed.ConfigHash != expectedHash {
175+
confirmed, err := r.prompt(
176+
fmt.Sprintf("Volume %q exists but doesn't match configuration in compose file. Recreate (data will be lost)?", desired.Name),
177+
false,
178+
)
179+
if err != nil {
180+
return err
181+
}
182+
if confirmed {
183+
r.planRecreateVolume(key, &desired)
184+
}
185+
}
186+
// else: volume exists and config matches, nothing to do
187+
}
188+
return nil
189+
}
190+
191+
// planCreateVolume adds a single CreateVolume node.
192+
func (r *reconciler) planCreateVolume(key string, vol *types.VolumeConfig) *PlanNode {
193+
return r.plan.addNode(Operation{
194+
Type: OpCreateVolume,
195+
ResourceID: fmt.Sprintf("volume:%s", key),
196+
Cause: "not found",
197+
Name: vol.Name,
198+
Volume: vol,
199+
}, "")
200+
}
201+
202+
// planRecreateVolume adds the full sequence for a diverged volume:
203+
// stop affected containers → remove containers → remove volume → create volume.
204+
// Containers must be removed (not just stopped) because Docker does not allow
205+
// removing a volume that is referenced by any container, even a stopped one.
206+
func (r *reconciler) planRecreateVolume(key string, vol *types.VolumeConfig) {
207+
affectedServices := r.servicesUsingVolume(key)
208+
affectedContainers := r.containersForServices(affectedServices)
209+
210+
// Stop all affected containers
211+
var stopNodes []*PlanNode
212+
for i := range affectedContainers {
213+
oc := &affectedContainers[i]
214+
node := r.plan.addNode(Operation{
215+
Type: OpStopContainer,
216+
ResourceID: fmt.Sprintf("service:%s:%d", oc.Summary.Labels[serviceLabel], oc.Number),
217+
Cause: fmt.Sprintf("volume %s config changed", key),
218+
Container: &oc.Summary,
219+
}, "")
220+
stopNodes = append(stopNodes, node)
221+
}
222+
223+
// Remove all affected containers (each depends on its own stop)
224+
var removeNodes []*PlanNode
225+
for i, oc := range affectedContainers {
226+
node := r.plan.addNode(Operation{
227+
Type: OpRemoveContainer,
228+
ResourceID: fmt.Sprintf("service:%s:%d", oc.Summary.Labels[serviceLabel], oc.Number),
229+
Cause: fmt.Sprintf("volume %s config changed", key),
230+
Container: &affectedContainers[i].Summary,
231+
}, "", stopNodes[i])
232+
removeNodes = append(removeNodes, node)
233+
}
234+
235+
// Remove volume (depends on all container removals)
236+
removeVolNode := r.plan.addNode(Operation{
237+
Type: OpRemoveVolume,
238+
ResourceID: fmt.Sprintf("volume:%s", key),
239+
Cause: "config hash diverged",
240+
Name: vol.Name,
241+
Volume: vol,
242+
}, "", removeNodes...)
243+
244+
// Create volume (depends on remove)
245+
r.plan.addNode(Operation{
246+
Type: OpCreateVolume,
247+
ResourceID: fmt.Sprintf("volume:%s", key),
248+
Cause: "recreate after config change",
249+
Name: vol.Name,
250+
Volume: vol,
251+
}, "", removeVolNode)
252+
}
253+
254+
// servicesUsingNetwork returns the names of services that reference the given
255+
// compose network key.
256+
func (r *reconciler) servicesUsingNetwork(networkKey string) []string {
257+
var names []string
258+
for _, svc := range r.project.Services {
259+
if _, ok := svc.Networks[networkKey]; ok {
260+
names = append(names, svc.Name)
261+
}
262+
}
263+
return names
264+
}
265+
266+
// servicesUsingVolume returns the names of services that mount the given
267+
// compose volume key.
268+
func (r *reconciler) servicesUsingVolume(volumeKey string) []string {
269+
var names []string
270+
for _, svc := range r.project.Services {
271+
for _, v := range svc.Volumes {
272+
if v.Source == volumeKey {
273+
names = append(names, svc.Name)
274+
break
275+
}
276+
}
277+
}
278+
return names
279+
}
280+
281+
// containersForServices returns all observed containers belonging to the given
282+
// service names.
283+
func (r *reconciler) containersForServices(services []string) []ObservedContainer {
284+
var result []ObservedContainer
285+
for _, svc := range services {
286+
result = append(result, r.observed.Containers[svc]...)
287+
}
288+
return result
289+
}
290+
291+
// serviceLabel is a package-level shorthand for the service label key.
292+
const serviceLabel = "com.docker.compose.service"

0 commit comments

Comments
 (0)