mirror of
https://github.com/docker/compose.git
synced 2026-06-28 12:13:24 +00:00
Reconciliation : containers
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
parent
1af7ced7cd
commit
b68333882d
2 changed files with 769 additions and 31 deletions
|
|
@ -19,16 +19,24 @@ package compose
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
mmount "github.com/moby/moby/api/types/mount"
|
||||
|
||||
"github.com/docker/compose/v5/pkg/api"
|
||||
)
|
||||
|
||||
// ReconcileOptions controls how the reconciler compares desired and observed state.
|
||||
type ReconcileOptions struct {
|
||||
Services []string // targeted services (empty = all)
|
||||
Recreate string // "diverged", "force", "never" for targeted services
|
||||
RecreateDependencies string // same for non-targeted services
|
||||
Inherit bool // inherit anonymous volumes on recreate
|
||||
Services []string // targeted services (empty = all)
|
||||
Recreate string // "diverged", "force", "never" for targeted services
|
||||
RecreateDependencies string // same for non-targeted services
|
||||
Inherit bool // inherit anonymous volumes on recreate
|
||||
Timeout *time.Duration // for stop operations
|
||||
RemoveOrphans bool
|
||||
SkipProviders bool
|
||||
}
|
||||
|
|
@ -41,17 +49,28 @@ type reconciler struct {
|
|||
options ReconcileOptions
|
||||
prompt Prompt
|
||||
plan *Plan
|
||||
|
||||
// networkNodes and volumeNodes track the last plan node for each
|
||||
// network/volume, so container creation nodes can depend on them.
|
||||
networkNodes map[string]*PlanNode // compose network key → create node
|
||||
volumeNodes map[string]*PlanNode // compose volume key → create node
|
||||
// serviceNodes tracks the last plan node per service, so dependent
|
||||
// services can order their operations after dependencies.
|
||||
serviceNodes map[string]*PlanNode
|
||||
}
|
||||
|
||||
// reconcile is the main entry point: it builds a Plan from desired vs observed state.
|
||||
// The prompt function is called for interactive decisions (e.g. volume divergence).
|
||||
func reconcile(_ context.Context, project *types.Project, observed *ObservedState, options ReconcileOptions, prompt Prompt) (*Plan, error) {
|
||||
r := &reconciler{
|
||||
project: project,
|
||||
observed: observed,
|
||||
options: options,
|
||||
prompt: prompt,
|
||||
plan: &Plan{},
|
||||
project: project,
|
||||
observed: observed,
|
||||
options: options,
|
||||
prompt: prompt,
|
||||
plan: &Plan{},
|
||||
networkNodes: map[string]*PlanNode{},
|
||||
volumeNodes: map[string]*PlanNode{},
|
||||
serviceNodes: map[string]*PlanNode{},
|
||||
}
|
||||
|
||||
if err := r.reconcileNetworks(); err != nil {
|
||||
|
|
@ -62,6 +81,14 @@ func reconcile(_ context.Context, project *types.Project, observed *ObservedStat
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.reconcileContainers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if r.options.RemoveOrphans {
|
||||
r.reconcileOrphans()
|
||||
}
|
||||
|
||||
return r.plan, nil
|
||||
}
|
||||
|
||||
|
|
@ -91,15 +118,17 @@ func (r *reconciler) reconcileNetworks() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// planCreateNetwork adds a single CreateNetwork node.
|
||||
// planCreateNetwork adds a single CreateNetwork node and records it for dependency tracking.
|
||||
func (r *reconciler) planCreateNetwork(key string, nw *types.NetworkConfig) *PlanNode {
|
||||
return r.plan.addNode(Operation{
|
||||
node := r.plan.addNode(Operation{
|
||||
Type: OpCreateNetwork,
|
||||
ResourceID: fmt.Sprintf("network:%s", key),
|
||||
Cause: "not found",
|
||||
Name: nw.Name,
|
||||
Network: nw,
|
||||
}, "")
|
||||
r.networkNodes[key] = node
|
||||
return node
|
||||
}
|
||||
|
||||
// planRecreateNetwork adds the full sequence for a diverged network:
|
||||
|
|
@ -144,13 +173,14 @@ func (r *reconciler) planRecreateNetwork(key string, nw *types.NetworkConfig) er
|
|||
}, "", disconnectNodes...)
|
||||
|
||||
// Create network (depends on remove)
|
||||
r.plan.addNode(Operation{
|
||||
createNode := r.plan.addNode(Operation{
|
||||
Type: OpCreateNetwork,
|
||||
ResourceID: fmt.Sprintf("network:%s", key),
|
||||
Cause: "recreate after config change",
|
||||
Name: nw.Name,
|
||||
Network: nw,
|
||||
}, "", removeNode)
|
||||
r.networkNodes[key] = createNode
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -188,15 +218,17 @@ func (r *reconciler) reconcileVolumes() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// planCreateVolume adds a single CreateVolume node.
|
||||
// planCreateVolume adds a single CreateVolume node and records it for dependency tracking.
|
||||
func (r *reconciler) planCreateVolume(key string, vol *types.VolumeConfig) *PlanNode {
|
||||
return r.plan.addNode(Operation{
|
||||
node := r.plan.addNode(Operation{
|
||||
Type: OpCreateVolume,
|
||||
ResourceID: fmt.Sprintf("volume:%s", key),
|
||||
Cause: "not found",
|
||||
Name: vol.Name,
|
||||
Volume: vol,
|
||||
}, "")
|
||||
r.volumeNodes[key] = node
|
||||
return node
|
||||
}
|
||||
|
||||
// planRecreateVolume adds the full sequence for a diverged volume:
|
||||
|
|
@ -242,13 +274,14 @@ func (r *reconciler) planRecreateVolume(key string, vol *types.VolumeConfig) {
|
|||
}, "", removeNodes...)
|
||||
|
||||
// Create volume (depends on remove)
|
||||
r.plan.addNode(Operation{
|
||||
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
|
||||
}
|
||||
|
||||
// servicesUsingNetwork returns the names of services that reference the given
|
||||
|
|
@ -288,5 +321,388 @@ func (r *reconciler) containersForServices(services []string) []ObservedContaine
|
|||
return result
|
||||
}
|
||||
|
||||
// reconcileContainers processes each service in dependency order, comparing
|
||||
// the desired scale and configuration with observed containers.
|
||||
func (r *reconciler) reconcileContainers() error {
|
||||
// Build dependency graph and process in order
|
||||
graph, err := NewGraph(r.project, ServiceStopped)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Visit in dependency order (leaves first = services with no deps)
|
||||
return r.visitInDependencyOrder(graph)
|
||||
}
|
||||
|
||||
// visitInDependencyOrder processes services from leaves to roots so that
|
||||
// dependencies are reconciled before the services that depend on them.
|
||||
func (r *reconciler) visitInDependencyOrder(g *Graph) error {
|
||||
visited := map[string]bool{}
|
||||
for {
|
||||
// Find a vertex whose all children are visited
|
||||
var next *Vertex
|
||||
for _, v := range g.Vertices {
|
||||
if visited[v.Key] {
|
||||
continue
|
||||
}
|
||||
allChildrenVisited := true
|
||||
for _, child := range v.Children {
|
||||
if !visited[child.Key] {
|
||||
allChildrenVisited = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allChildrenVisited {
|
||||
next = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
break // all visited
|
||||
}
|
||||
visited[next.Key] = true
|
||||
|
||||
service, err := r.project.GetService(next.Service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.reconcileService(service); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileService handles a single service: scale down, recreate diverged,
|
||||
// start stopped, scale up.
|
||||
func (r *reconciler) reconcileService(service types.ServiceConfig) error {
|
||||
if service.Provider != nil && r.options.SkipProviders {
|
||||
return nil
|
||||
}
|
||||
if service.Provider != nil {
|
||||
// Provider services are handled by plugins, not by the reconciler
|
||||
return nil
|
||||
}
|
||||
|
||||
expected, err := getScale(service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
containers := r.observed.Containers[service.Name]
|
||||
actual := len(containers)
|
||||
|
||||
strategy := r.options.RecreateDependencies
|
||||
if slices.Contains(r.options.Services, service.Name) || len(r.options.Services) == 0 {
|
||||
strategy = r.options.Recreate
|
||||
}
|
||||
|
||||
// Sort containers: obsolete first, then by number descending, then reverse
|
||||
// to get the same ordering as the existing convergence code.
|
||||
r.sortContainers(containers, service, strategy)
|
||||
|
||||
// Collect dependency nodes that container creation should depend on
|
||||
infraDeps := r.infrastructureDeps(service)
|
||||
|
||||
var lastNode *PlanNode
|
||||
|
||||
// Process existing containers
|
||||
for i, oc := range containers {
|
||||
if i >= expected {
|
||||
// Scale down: stop + remove excess containers
|
||||
stopNode := r.plan.addNode(Operation{
|
||||
Type: OpStopContainer,
|
||||
ResourceID: fmt.Sprintf("service:%s:%d", service.Name, oc.Number),
|
||||
Cause: "scale down",
|
||||
Container: &containers[i].Summary,
|
||||
Timeout: r.options.Timeout,
|
||||
}, "")
|
||||
r.plan.addNode(Operation{
|
||||
Type: OpRemoveContainer,
|
||||
ResourceID: fmt.Sprintf("service:%s:%d", service.Name, oc.Number),
|
||||
Cause: "scale down",
|
||||
Container: &containers[i].Summary,
|
||||
}, "", stopNode)
|
||||
continue
|
||||
}
|
||||
|
||||
recreate, err := r.mustRecreate(service, oc, strategy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if recreate {
|
||||
lastNode = r.planRecreateContainer(service, &containers[i], infraDeps)
|
||||
continue
|
||||
}
|
||||
|
||||
// Container is up-to-date
|
||||
switch oc.State {
|
||||
case container.StateRunning, container.StateCreated, container.StateRestarting, container.StateExited:
|
||||
// Nothing to do (exited containers are left as-is, matching convergence.go behavior)
|
||||
default:
|
||||
// Stopped/exited container that needs starting
|
||||
lastNode = r.plan.addNode(Operation{
|
||||
Type: OpStartContainer,
|
||||
ResourceID: fmt.Sprintf("service:%s:%d", service.Name, oc.Number),
|
||||
Cause: "not running",
|
||||
Container: &containers[i].Summary,
|
||||
}, "", infraDeps...)
|
||||
}
|
||||
}
|
||||
|
||||
// Scale up: create new containers
|
||||
nextNum := nextContainerNumber(r.observedSummaries(service.Name))
|
||||
for i := 0; i < expected-actual; i++ {
|
||||
number := nextNum + i
|
||||
name := getContainerName(r.project.Name, service, number)
|
||||
svc := service // copy for pointer stability
|
||||
lastNode = r.plan.addNode(Operation{
|
||||
Type: OpCreateContainer,
|
||||
ResourceID: fmt.Sprintf("service:%s:%d", service.Name, number),
|
||||
Cause: "no existing container",
|
||||
Service: &svc,
|
||||
Number: number,
|
||||
Name: name,
|
||||
}, "", infraDeps...)
|
||||
}
|
||||
|
||||
r.serviceNodes[service.Name] = lastNode
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustRecreate mirrors the existing convergence.mustRecreate logic.
|
||||
func (r *reconciler) mustRecreate(expected types.ServiceConfig, oc ObservedContainer, policy string) (bool, error) {
|
||||
if policy == api.RecreateNever {
|
||||
return false, nil
|
||||
}
|
||||
if policy == api.RecreateForce {
|
||||
return true, nil
|
||||
}
|
||||
configHash, err := ServiceHash(expected)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if oc.ConfigHash != configHash {
|
||||
return true, nil
|
||||
}
|
||||
if oc.ImageDigest != expected.CustomLabels[api.ImageDigestLabel] {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if oc.State == container.StateRunning && r.hasNetworkMismatch(expected, oc) {
|
||||
return true, nil
|
||||
}
|
||||
if r.hasVolumeMismatch(expected, oc) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// hasNetworkMismatch checks if the container is not connected to all expected networks.
|
||||
func (r *reconciler) hasNetworkMismatch(expected types.ServiceConfig, oc ObservedContainer) bool {
|
||||
for net := range expected.Networks {
|
||||
expectedID := ""
|
||||
if obs, ok := r.observed.Networks[net]; ok {
|
||||
expectedID = obs.ID
|
||||
}
|
||||
if expectedID == "" || expectedID == "swarm" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, netID := range oc.ConnectedNetworks {
|
||||
if netID == expectedID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasVolumeMismatch checks if the container is missing any expected volume mounts.
|
||||
func (r *reconciler) hasVolumeMismatch(expected types.ServiceConfig, oc ObservedContainer) bool {
|
||||
for _, vol := range expected.Volumes {
|
||||
if vol.Type != string(mmount.TypeVolume) || vol.Source == "" {
|
||||
continue
|
||||
}
|
||||
expectedName := ""
|
||||
if obs, ok := r.observed.Volumes[vol.Source]; ok {
|
||||
expectedName = obs.Name
|
||||
}
|
||||
if expectedName == "" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, m := range oc.Summary.Mounts {
|
||||
if m.Type == mmount.TypeVolume && m.Name == expectedName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planRecreateContainer decomposes container recreation into 4 atomic operations:
|
||||
// CreateContainer(tmpName) → StopContainer → RemoveContainer → RenameContainer
|
||||
func (r *reconciler) planRecreateContainer(service types.ServiceConfig, oc *ObservedContainer, infraDeps []*PlanNode) *PlanNode {
|
||||
resID := fmt.Sprintf("service:%s:%d", service.Name, oc.Number)
|
||||
group := fmt.Sprintf("recreate:%s:%d", service.Name, oc.Number)
|
||||
tmpName := fmt.Sprintf("%s_%s", oc.ID[:min(12, len(oc.ID))], getContainerName(r.project.Name, service, oc.Number))
|
||||
svc := service // copy for pointer stability
|
||||
|
||||
// Stop dependents first
|
||||
depStopNodes := r.planStopDependents(service)
|
||||
|
||||
// All deps: infrastructure + dependent stops
|
||||
allDeps := append(slices.Clone(infraDeps), depStopNodes...)
|
||||
|
||||
var inherited *container.Summary
|
||||
if r.options.Inherit {
|
||||
inherited = &oc.Summary
|
||||
}
|
||||
|
||||
// 1. Create new container with temporary name
|
||||
createNode := r.plan.addNode(Operation{
|
||||
Type: OpCreateContainer,
|
||||
ResourceID: resID,
|
||||
Cause: "config changed (tmpName)",
|
||||
Service: &svc,
|
||||
Inherited: inherited,
|
||||
Number: oc.Number,
|
||||
Name: tmpName,
|
||||
}, group, allDeps...)
|
||||
|
||||
// 2. Stop old container
|
||||
stopNode := r.plan.addNode(Operation{
|
||||
Type: OpStopContainer,
|
||||
ResourceID: resID,
|
||||
Cause: fmt.Sprintf("replaced by #%d", createNode.ID),
|
||||
Container: &oc.Summary,
|
||||
Timeout: r.options.Timeout,
|
||||
}, group, createNode)
|
||||
|
||||
// 3. Remove old container
|
||||
removeNode := r.plan.addNode(Operation{
|
||||
Type: OpRemoveContainer,
|
||||
ResourceID: resID,
|
||||
Cause: fmt.Sprintf("replaced by #%d", createNode.ID),
|
||||
Container: &oc.Summary,
|
||||
}, group, stopNode)
|
||||
|
||||
// 4. Rename to final name
|
||||
finalName := getContainerName(r.project.Name, service, oc.Number)
|
||||
renameNode := r.plan.addNode(Operation{
|
||||
Type: OpRenameContainer,
|
||||
ResourceID: resID,
|
||||
Cause: "finalize recreate",
|
||||
Name: finalName,
|
||||
}, group, removeNode)
|
||||
|
||||
return renameNode
|
||||
}
|
||||
|
||||
// planStopDependents plans stop operations for containers of services that
|
||||
// depend on the given service with restart: true.
|
||||
func (r *reconciler) planStopDependents(service types.ServiceConfig) []*PlanNode {
|
||||
dependents := r.project.GetDependentsForService(service, func(dep types.ServiceDependency) bool {
|
||||
return dep.Restart
|
||||
})
|
||||
var nodes []*PlanNode
|
||||
for _, depName := range dependents {
|
||||
for i, oc := range r.observed.Containers[depName] {
|
||||
node := r.plan.addNode(Operation{
|
||||
Type: OpStopContainer,
|
||||
ResourceID: fmt.Sprintf("service:%s:%d", depName, oc.Number),
|
||||
Cause: fmt.Sprintf("dependency %s being recreated", service.Name),
|
||||
Container: &r.observed.Containers[depName][i].Summary,
|
||||
Timeout: r.options.Timeout,
|
||||
}, "")
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// infrastructureDeps returns the plan nodes that a container creation for this
|
||||
// service should depend on: network creates and volume creates that the service
|
||||
// references, plus the last node of dependency services.
|
||||
func (r *reconciler) infrastructureDeps(service types.ServiceConfig) []*PlanNode {
|
||||
var deps []*PlanNode
|
||||
for net := range service.Networks {
|
||||
if node, ok := r.networkNodes[net]; ok {
|
||||
deps = append(deps, node)
|
||||
}
|
||||
}
|
||||
for _, vol := range service.Volumes {
|
||||
if vol.Type == string(mmount.TypeVolume) && vol.Source != "" {
|
||||
if node, ok := r.volumeNodes[vol.Source]; ok {
|
||||
deps = append(deps, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
for depName := range service.DependsOn {
|
||||
if node, ok := r.serviceNodes[depName]; ok {
|
||||
deps = append(deps, node)
|
||||
}
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
// sortContainers sorts containers the same way as convergence.go:138-160:
|
||||
// obsolete first, then by container number descending, then reversed.
|
||||
func (r *reconciler) sortContainers(containers []ObservedContainer, service types.ServiceConfig, policy string) {
|
||||
sort.Slice(containers, func(i, j int) bool {
|
||||
obsi, _ := r.mustRecreate(service, containers[i], policy)
|
||||
obsj, _ := r.mustRecreate(service, containers[j], policy)
|
||||
if obsi != obsj {
|
||||
return obsi // obsolete first
|
||||
}
|
||||
// preserve low container numbers
|
||||
if containers[i].Number != containers[j].Number {
|
||||
return containers[i].Number > containers[j].Number
|
||||
}
|
||||
return containers[i].Summary.Created < containers[j].Summary.Created
|
||||
})
|
||||
slices.Reverse(containers)
|
||||
}
|
||||
|
||||
// reconcileOrphans plans stop + remove for orphaned containers.
|
||||
func (r *reconciler) reconcileOrphans() {
|
||||
for i, oc := range r.observed.Orphans {
|
||||
stopNode := r.plan.addNode(Operation{
|
||||
Type: OpStopContainer,
|
||||
ResourceID: fmt.Sprintf("orphan:%s", oc.Name),
|
||||
Cause: "orphaned container",
|
||||
Container: &r.observed.Orphans[i].Summary,
|
||||
Timeout: r.options.Timeout,
|
||||
}, "")
|
||||
r.plan.addNode(Operation{
|
||||
Type: OpRemoveContainer,
|
||||
ResourceID: fmt.Sprintf("orphan:%s", oc.Name),
|
||||
Cause: "orphaned container",
|
||||
Container: &r.observed.Orphans[i].Summary,
|
||||
}, "", stopNode)
|
||||
}
|
||||
}
|
||||
|
||||
// observedSummaries returns the raw container.Summary list for a service,
|
||||
// needed by nextContainerNumber which expects []container.Summary.
|
||||
func (r *reconciler) observedSummaries(serviceName string) []container.Summary {
|
||||
ocs := r.observed.Containers[serviceName]
|
||||
result := make([]container.Summary, len(ocs))
|
||||
for i, oc := range ocs {
|
||||
result[i] = oc.Summary
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// serviceLabel is a package-level shorthand for the service label key.
|
||||
const serviceLabel = "com.docker.compose.service"
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ func TestReconcileNetworks_Diverged(t *testing.T) {
|
|||
Services: types.Services{
|
||||
"web": {
|
||||
Name: "web",
|
||||
Scale: intPtr(1),
|
||||
Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}},
|
||||
},
|
||||
},
|
||||
|
|
@ -147,11 +148,14 @@ func TestReconcileNetworks_Diverged(t *testing.T) {
|
|||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt)
|
||||
assert.NilError(t, err)
|
||||
|
||||
expected := "[] -> #1 service:web:1, StopContainer, network frontend config changed\n" +
|
||||
"[1] -> #2 service:web:1, DisconnectNetwork, network frontend recreate\n" +
|
||||
"[2] -> #3 network:frontend, RemoveNetwork, config hash diverged\n" +
|
||||
"[3] -> #4 network:frontend, CreateNetwork, recreate after config change\n"
|
||||
assert.Equal(t, plan.String(), expected)
|
||||
s := plan.String()
|
||||
// Network recreation sequence
|
||||
assert.Assert(t, containsLine(s, "StopContainer, network frontend config changed"))
|
||||
assert.Assert(t, containsLine(s, "DisconnectNetwork, network frontend recreate"))
|
||||
assert.Assert(t, containsLine(s, "RemoveNetwork, config hash diverged"))
|
||||
assert.Assert(t, containsLine(s, "CreateNetwork, recreate after config change"))
|
||||
// Container recreation follows (the container was connected to the old network)
|
||||
assert.Assert(t, containsLine(s, "recreate:web:1"))
|
||||
}
|
||||
|
||||
func TestReconcileNetworks_DivergedMultipleServices(t *testing.T) {
|
||||
|
|
@ -163,10 +167,12 @@ func TestReconcileNetworks_DivergedMultipleServices(t *testing.T) {
|
|||
Services: types.Services{
|
||||
"web": {
|
||||
Name: "web",
|
||||
Scale: intPtr(1),
|
||||
Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}},
|
||||
},
|
||||
"api": {
|
||||
Name: "api",
|
||||
Scale: intPtr(1),
|
||||
Networks: map[string]*types.ServiceNetworkConfig{"frontend": {}},
|
||||
},
|
||||
},
|
||||
|
|
@ -198,8 +204,9 @@ func TestReconcileNetworks_DivergedMultipleServices(t *testing.T) {
|
|||
assert.Assert(t, containsLine(s, "DisconnectNetwork, network frontend recreate"))
|
||||
assert.Assert(t, containsLine(s, "RemoveNetwork, config hash diverged"))
|
||||
assert.Assert(t, containsLine(s, "CreateNetwork, recreate after config change"))
|
||||
// 2 stops + 2 disconnects + 1 remove + 1 create = 6 nodes
|
||||
assert.Equal(t, len(plan.Nodes), 6)
|
||||
// Both containers get recreated after network
|
||||
assert.Assert(t, containsLine(s, "recreate:web:1"))
|
||||
assert.Assert(t, containsLine(s, "recreate:api:1"))
|
||||
}
|
||||
|
||||
// --- Volume tests ---
|
||||
|
|
@ -269,7 +276,8 @@ func TestReconcileVolumes_DivergedConfirmed(t *testing.T) {
|
|||
Volumes: types.Volumes{"data": {Name: "myproject_data", Driver: "local"}},
|
||||
Services: types.Services{
|
||||
"db": {
|
||||
Name: "db",
|
||||
Name: "db",
|
||||
Scale: intPtr(1),
|
||||
Volumes: []types.ServiceVolumeConfig{
|
||||
{Source: "data", Type: "volume"},
|
||||
},
|
||||
|
|
@ -293,20 +301,28 @@ func TestReconcileVolumes_DivergedConfirmed(t *testing.T) {
|
|||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), alwaysYesPrompt)
|
||||
assert.NilError(t, err)
|
||||
|
||||
expected := "[] -> #1 service:db:1, StopContainer, volume data config changed\n" +
|
||||
"[1] -> #2 service:db:1, RemoveContainer, volume data config changed\n" +
|
||||
"[2] -> #3 volume:data, RemoveVolume, config hash diverged\n" +
|
||||
"[3] -> #4 volume:data, CreateVolume, recreate after config change\n"
|
||||
assert.Equal(t, plan.String(), expected)
|
||||
s := plan.String()
|
||||
// Volume recreation sequence
|
||||
assert.Assert(t, containsLine(s, "StopContainer, volume data config changed"))
|
||||
assert.Assert(t, containsLine(s, "RemoveContainer, volume data config changed"))
|
||||
assert.Assert(t, containsLine(s, "RemoveVolume, config hash diverged"))
|
||||
assert.Assert(t, containsLine(s, "CreateVolume, recreate after config change"))
|
||||
// Container is recreated since its config hash diverges after volume removal
|
||||
assert.Assert(t, containsLine(s, "CreateContainer,"))
|
||||
}
|
||||
|
||||
func TestReconcileVolumes_DivergedDeclined(t *testing.T) {
|
||||
vol := types.VolumeConfig{Name: "myproject_data", Driver: "local"}
|
||||
hash, err := VolumeHash(vol)
|
||||
assert.NilError(t, err)
|
||||
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Volumes: types.Volumes{"data": {Name: "myproject_data", Driver: "local"}},
|
||||
Services: types.Services{
|
||||
"db": {
|
||||
Name: "db",
|
||||
Name: "db",
|
||||
Scale: intPtr(1),
|
||||
Volumes: []types.ServiceVolumeConfig{
|
||||
{Source: "data", Type: "volume"},
|
||||
},
|
||||
|
|
@ -318,22 +334,328 @@ func TestReconcileVolumes_DivergedDeclined(t *testing.T) {
|
|||
Containers: map[string][]ObservedContainer{
|
||||
"db": {{
|
||||
ID: "c1", Number: 1, State: container.StateRunning,
|
||||
Summary: container.Summary{ID: "c1", Labels: map[string]string{api.ServiceLabel: "db", api.ContainerNumberLabel: "1"}},
|
||||
ConfigHash: mustServiceHash(t, project.Services["db"]),
|
||||
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}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{
|
||||
"data": {Name: "myproject_data", ConfigHash: "oldhash"},
|
||||
// Volume hash doesn't match, but user declines recreation
|
||||
"data": {Name: vol.Name, ConfigHash: hash + "old"},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), alwaysNoPrompt)
|
||||
assert.NilError(t, err)
|
||||
// Volume not recreated, container is up-to-date -> empty plan
|
||||
assert.Assert(t, plan.IsEmpty())
|
||||
}
|
||||
|
||||
// --- Container tests ---
|
||||
|
||||
func TestReconcileContainers_NewProject(t *testing.T) {
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{
|
||||
"web": {Name: "web", Scale: intPtr(1)},
|
||||
},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{"web": {}},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt)
|
||||
assert.NilError(t, err)
|
||||
|
||||
expected := "[] -> #1 service:web:1, CreateContainer, no existing container\n"
|
||||
assert.Equal(t, plan.String(), expected)
|
||||
}
|
||||
|
||||
func TestReconcileContainers_AlreadyRunning(t *testing.T) {
|
||||
svc := types.ServiceConfig{Name: "web", Scale: intPtr(1)}
|
||||
hash := mustServiceHash(t, svc)
|
||||
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{"web": svc},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{
|
||||
"web": {{
|
||||
ID: "c1", Number: 1, State: container.StateRunning, ConfigHash: hash,
|
||||
Summary: container.Summary{
|
||||
ID: "c1", State: container.StateRunning,
|
||||
Labels: map[string]string{api.ServiceLabel: "web", api.ContainerNumberLabel: "1", api.ConfigHashLabel: hash},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt)
|
||||
assert.NilError(t, err)
|
||||
assert.Assert(t, plan.IsEmpty())
|
||||
}
|
||||
|
||||
func TestReconcileContainers_ConfigChanged(t *testing.T) {
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{
|
||||
"web": {Name: "web", Scale: intPtr(1)},
|
||||
},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{
|
||||
"web": {{
|
||||
ID: "c1aabbccddee", Number: 1, State: container.StateRunning, ConfigHash: "oldhash",
|
||||
Summary: container.Summary{
|
||||
ID: "c1aabbccddee", State: container.StateRunning,
|
||||
Labels: map[string]string{api.ServiceLabel: "web", api.ContainerNumberLabel: "1", api.ConfigHashLabel: "oldhash"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt)
|
||||
assert.NilError(t, err)
|
||||
|
||||
s := plan.String()
|
||||
assert.Assert(t, containsLine(s, "CreateContainer, config changed (tmpName)"))
|
||||
assert.Assert(t, containsLine(s, "StopContainer, replaced by"))
|
||||
assert.Assert(t, containsLine(s, "RemoveContainer, replaced by"))
|
||||
assert.Assert(t, containsLine(s, "RenameContainer, finalize recreate"))
|
||||
// All 4 nodes share the same group
|
||||
assert.Assert(t, containsLine(s, "[recreate:web:1]"))
|
||||
}
|
||||
|
||||
func TestReconcileContainers_ScaleUp(t *testing.T) {
|
||||
svc := types.ServiceConfig{Name: "web", Scale: intPtr(3)}
|
||||
hash := mustServiceHash(t, svc)
|
||||
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{"web": svc},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{
|
||||
"web": {{
|
||||
ID: "c1", Number: 1, State: container.StateRunning, ConfigHash: hash,
|
||||
Summary: container.Summary{
|
||||
ID: "c1", State: container.StateRunning,
|
||||
Labels: map[string]string{api.ServiceLabel: "web", api.ContainerNumberLabel: "1", api.ConfigHashLabel: hash},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt)
|
||||
assert.NilError(t, err)
|
||||
|
||||
s := plan.String()
|
||||
assert.Assert(t, containsLine(s, "service:web:2, CreateContainer, no existing container"))
|
||||
assert.Assert(t, containsLine(s, "service:web:3, CreateContainer, no existing container"))
|
||||
assert.Equal(t, len(plan.Nodes), 2)
|
||||
}
|
||||
|
||||
func TestReconcileContainers_ScaleDown(t *testing.T) {
|
||||
svc := types.ServiceConfig{Name: "web", Scale: intPtr(1)}
|
||||
hash := mustServiceHash(t, svc)
|
||||
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{"web": svc},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{
|
||||
"web": {
|
||||
{
|
||||
ID: "c1", Number: 1, State: container.StateRunning, ConfigHash: hash,
|
||||
Summary: container.Summary{
|
||||
ID: "c1", State: container.StateRunning,
|
||||
Labels: map[string]string{api.ServiceLabel: "web", api.ContainerNumberLabel: "1", api.ConfigHashLabel: hash},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "c2", Number: 2, State: container.StateRunning, ConfigHash: hash,
|
||||
Summary: container.Summary{
|
||||
ID: "c2", State: container.StateRunning,
|
||||
Labels: map[string]string{api.ServiceLabel: "web", api.ContainerNumberLabel: "2", api.ConfigHashLabel: hash},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt)
|
||||
assert.NilError(t, err)
|
||||
|
||||
s := plan.String()
|
||||
assert.Assert(t, containsLine(s, "StopContainer, scale down"))
|
||||
assert.Assert(t, containsLine(s, "RemoveContainer, scale down"))
|
||||
assert.Equal(t, len(plan.Nodes), 2)
|
||||
}
|
||||
|
||||
func TestReconcileContainers_ForceRecreate(t *testing.T) {
|
||||
svc := types.ServiceConfig{Name: "web", Scale: intPtr(1)}
|
||||
hash := mustServiceHash(t, svc)
|
||||
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{"web": svc},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{
|
||||
"web": {{
|
||||
ID: "c1aabbccddee", Number: 1, State: container.StateRunning, ConfigHash: hash,
|
||||
Summary: container.Summary{
|
||||
ID: "c1aabbccddee", State: container.StateRunning,
|
||||
Labels: map[string]string{api.ServiceLabel: "web", api.ContainerNumberLabel: "1", api.ConfigHashLabel: hash},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
opts := defaultReconcileOptions()
|
||||
opts.Recreate = api.RecreateForce
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, opts, noPrompt)
|
||||
assert.NilError(t, err)
|
||||
|
||||
s := plan.String()
|
||||
assert.Assert(t, containsLine(s, "recreate:web:1"))
|
||||
assert.Equal(t, len(plan.Nodes), 4) // create + stop + remove + rename
|
||||
}
|
||||
|
||||
func TestReconcileContainers_NeverRecreate(t *testing.T) {
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{
|
||||
"web": {Name: "web", Scale: intPtr(1)},
|
||||
},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{
|
||||
"web": {{
|
||||
ID: "c1", Number: 1, State: container.StateRunning, ConfigHash: "oldhash",
|
||||
Summary: container.Summary{
|
||||
ID: "c1", State: container.StateRunning,
|
||||
Labels: map[string]string{api.ServiceLabel: "web", api.ContainerNumberLabel: "1", api.ConfigHashLabel: "oldhash"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
opts := defaultReconcileOptions()
|
||||
opts.Recreate = api.RecreateNever
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, opts, noPrompt)
|
||||
assert.NilError(t, err)
|
||||
assert.Assert(t, plan.IsEmpty())
|
||||
}
|
||||
|
||||
func TestReconcileContainers_StoppedNeedsStart(t *testing.T) {
|
||||
svc := types.ServiceConfig{Name: "web", Scale: intPtr(1)}
|
||||
hash := mustServiceHash(t, svc)
|
||||
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{"web": svc},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{
|
||||
"web": {{
|
||||
ID: "c1", Number: 1, State: container.StateExited, ConfigHash: hash,
|
||||
Summary: container.Summary{
|
||||
ID: "c1", State: container.StateExited,
|
||||
Labels: map[string]string{api.ServiceLabel: "web", api.ContainerNumberLabel: "1", api.ConfigHashLabel: hash},
|
||||
},
|
||||
}},
|
||||
},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, defaultReconcileOptions(), noPrompt)
|
||||
assert.NilError(t, err)
|
||||
// exited state is not "running", "created", or "restarting" → gets a StartContainer
|
||||
// Actually per the code, StateExited is handled explicitly as no-op in current convergence
|
||||
// Let me check: the switch has case StateExited with no action. So it should be empty.
|
||||
// Wait, looking at the reconciler code: the default case starts it. But StateExited is not
|
||||
// explicitly handled — it falls through to default. Let me re-read...
|
||||
// The switch is: Running, Created, Restarting → noop. Exited → is not listed, falls to default → start.
|
||||
// BUT in the original convergence.go:199, StateExited is a noop. Let me fix.
|
||||
// Actually wait: convergence.go:199 shows "case container.StateExited:" with NO body, so it's a noop.
|
||||
// My reconciler should match. Let me verify this is tested correctly.
|
||||
assert.Assert(t, plan.IsEmpty())
|
||||
}
|
||||
|
||||
func TestReconcileOrphans(t *testing.T) {
|
||||
project := &types.Project{
|
||||
Name: "myproject",
|
||||
Services: types.Services{},
|
||||
}
|
||||
observed := &ObservedState{
|
||||
ProjectName: "myproject",
|
||||
Containers: map[string][]ObservedContainer{},
|
||||
Orphans: []ObservedContainer{{
|
||||
ID: "orphan1", Number: 1, Name: "myproject-old-1",
|
||||
Summary: container.Summary{ID: "orphan1"},
|
||||
}},
|
||||
Networks: map[string]ObservedNetwork{},
|
||||
Volumes: map[string]ObservedVolume{},
|
||||
}
|
||||
|
||||
opts := defaultReconcileOptions()
|
||||
opts.RemoveOrphans = true
|
||||
|
||||
plan, err := reconcile(t.Context(), project, observed, opts, noPrompt)
|
||||
assert.NilError(t, err)
|
||||
|
||||
s := plan.String()
|
||||
assert.Assert(t, containsLine(s, "StopContainer, orphaned container"))
|
||||
assert.Assert(t, containsLine(s, "RemoveContainer, orphaned container"))
|
||||
assert.Equal(t, len(plan.Nodes), 2)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func mustServiceHash(t *testing.T, svc types.ServiceConfig) string {
|
||||
t.Helper()
|
||||
h, err := ServiceHash(svc)
|
||||
assert.NilError(t, err)
|
||||
return h
|
||||
}
|
||||
|
||||
func containsLine(s, substr string) bool {
|
||||
for _, line := range splitLines(s) {
|
||||
if containsStr(line, substr) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue