introduce (reconcilation) Plan

String() is designed to make it easy to compare coomputed plan vs expectations

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2026-04-19 16:59:49 +02:00 committed by Guillaume Lours
parent 34693bd14d
commit b016de4b9f
2 changed files with 303 additions and 0 deletions

164
pkg/compose/plan.go Normal file
View file

@ -0,0 +1,164 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package compose
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/compose-spec/compose-go/v2/types"
"github.com/moby/moby/api/types/container"
)
// OperationType identifies the kind of atomic operation in a reconciliation plan.
// Each operation maps to exactly one Docker API call.
type OperationType int
const (
// Network operations
OpCreateNetwork OperationType = iota
OpRemoveNetwork
OpDisconnectNetwork
OpConnectNetwork
// Volume operations
OpCreateVolume
OpRemoveVolume
// Container operations
OpCreateContainer
OpStartContainer
OpStopContainer
OpRemoveContainer
OpRenameContainer
)
// String returns the human-readable name of an OperationType.
func (o OperationType) String() string {
switch o {
case OpCreateNetwork:
return "CreateNetwork"
case OpRemoveNetwork:
return "RemoveNetwork"
case OpDisconnectNetwork:
return "DisconnectNetwork"
case OpConnectNetwork:
return "ConnectNetwork"
case OpCreateVolume:
return "CreateVolume"
case OpRemoveVolume:
return "RemoveVolume"
case OpCreateContainer:
return "CreateContainer"
case OpStartContainer:
return "StartContainer"
case OpStopContainer:
return "StopContainer"
case OpRemoveContainer:
return "RemoveContainer"
case OpRenameContainer:
return "RenameContainer"
default:
return fmt.Sprintf("Unknown(%d)", int(o))
}
}
// Operation describes a single atomic action to be performed by the executor.
// It carries all the data needed to execute the operation without further
// decision-making.
type Operation struct {
Type OperationType
ResourceID string // e.g. "service:web:1", "network:backend", "volume:data"
Cause string // why this operation is needed
// Resource-specific data (only the relevant fields are set per operation type)
Service *types.ServiceConfig // for container operations
Container *container.Summary // existing container (for stop/remove)
Inherited *container.Summary // container to inherit anonymous volumes from (for create-as-replacement)
Number int // container replica number (for create)
Name string // target container/resource name
Network *types.NetworkConfig // for network operations
Volume *types.VolumeConfig // for volume operations
Timeout *time.Duration // for stop operations
}
// PlanNode is a single node in the reconciliation DAG. It represents one
// atomic operation and its dependencies on other nodes.
type PlanNode struct {
ID int // numeric identifier (#1, #2, ...)
Operation Operation
DependsOn []*PlanNode // prerequisite operations
Group string // event grouping key (e.g. "recreate:web:1"); empty for ungrouped nodes
}
// Plan is a directed acyclic graph of operations produced by the reconciler.
// Nodes are stored in topological order (dependencies before dependents).
type Plan struct {
Nodes []*PlanNode
nextID int
}
// addNode appends a new node to the plan and returns it.
func (p *Plan) addNode(op Operation, group string, deps ...*PlanNode) *PlanNode {
p.nextID++
node := &PlanNode{
ID: p.nextID,
Operation: op,
DependsOn: deps,
Group: group,
}
p.Nodes = append(p.Nodes, node)
return node
}
// String renders the plan as a human-readable graph for testing and debugging.
//
// Format per line: [dep1,dep2] -> #id resource, operation, cause [group]
//
// Examples:
//
// [] -> #1 network:default, CreateNetwork, not found
// [1] -> #2 service:web:1, CreateContainer, no existing container
// [2] -> #3 service:web:1, StopContainer, replaced by #2 [recreate:web:1]
func (p *Plan) String() string {
var sb strings.Builder
for _, node := range p.Nodes {
deps := make([]string, len(node.DependsOn))
for i, d := range node.DependsOn {
deps[i] = strconv.Itoa(d.ID)
}
fmt.Fprintf(&sb, "[%s] -> #%d %s, %s, %s",
strings.Join(deps, ","),
node.ID,
node.Operation.ResourceID,
node.Operation.Type,
node.Operation.Cause,
)
if node.Group != "" {
fmt.Fprintf(&sb, " [%s]", node.Group)
}
sb.WriteByte('\n')
}
return sb.String()
}
// IsEmpty returns true if the plan contains no operations.
func (p *Plan) IsEmpty() bool {
return len(p.Nodes) == 0
}

139
pkg/compose/plan_test.go Normal file
View file

@ -0,0 +1,139 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package compose
import (
"testing"
"gotest.tools/v3/assert"
)
func TestOperationTypeString(t *testing.T) {
tests := []struct {
op OperationType
want string
}{
{OpCreateNetwork, "CreateNetwork"},
{OpRemoveNetwork, "RemoveNetwork"},
{OpDisconnectNetwork, "DisconnectNetwork"},
{OpConnectNetwork, "ConnectNetwork"},
{OpCreateVolume, "CreateVolume"},
{OpRemoveVolume, "RemoveVolume"},
{OpCreateContainer, "CreateContainer"},
{OpStartContainer, "StartContainer"},
{OpStopContainer, "StopContainer"},
{OpRemoveContainer, "RemoveContainer"},
{OpRenameContainer, "RenameContainer"},
{OperationType(999), "Unknown(999)"},
}
for _, tt := range tests {
assert.Equal(t, tt.op.String(), tt.want)
}
}
func TestPlanStringEmpty(t *testing.T) {
p := &Plan{}
assert.Equal(t, p.String(), "")
assert.Assert(t, p.IsEmpty())
}
func TestPlanStringNoDeps(t *testing.T) {
p := &Plan{}
p.addNode(Operation{
Type: OpCreateNetwork,
ResourceID: "network:default",
Cause: "not found",
}, "")
p.addNode(Operation{
Type: OpCreateVolume,
ResourceID: "volume:data",
Cause: "not found",
}, "")
expected := "[] -> #1 network:default, CreateNetwork, not found\n" +
"[] -> #2 volume:data, CreateVolume, not found\n"
assert.Equal(t, p.String(), expected)
assert.Assert(t, !p.IsEmpty())
}
func TestPlanStringWithDeps(t *testing.T) {
p := &Plan{}
nw := p.addNode(Operation{
Type: OpCreateNetwork,
ResourceID: "network:default",
Cause: "not found",
}, "")
vol := p.addNode(Operation{
Type: OpCreateVolume,
ResourceID: "volume:data",
Cause: "not found",
}, "")
p.addNode(Operation{
Type: OpCreateContainer,
ResourceID: "service:db:1",
Cause: "no existing container",
}, "", nw, vol)
expected := "[] -> #1 network:default, CreateNetwork, not found\n" +
"[] -> #2 volume:data, CreateVolume, not found\n" +
"[1,2] -> #3 service:db:1, CreateContainer, no existing container\n"
assert.Equal(t, p.String(), expected)
}
func TestPlanStringWithGroup(t *testing.T) {
p := &Plan{}
create := p.addNode(Operation{
Type: OpCreateContainer,
ResourceID: "service:web:1",
Cause: "config hash changed (tmpName)",
}, "recreate:web:1")
stop := p.addNode(Operation{
Type: OpStopContainer,
ResourceID: "service:web:1",
Cause: "replaced by #1",
}, "recreate:web:1", create)
remove := p.addNode(Operation{
Type: OpRemoveContainer,
ResourceID: "service:web:1",
Cause: "replaced by #1",
}, "recreate:web:1", stop)
p.addNode(Operation{
Type: OpRenameContainer,
ResourceID: "service:web:1",
Cause: "finalize recreate",
}, "recreate:web:1", remove)
expected := "[] -> #1 service:web:1, CreateContainer, config hash changed (tmpName) [recreate:web:1]\n" +
"[1] -> #2 service:web:1, StopContainer, replaced by #1 [recreate:web:1]\n" +
"[2] -> #3 service:web:1, RemoveContainer, replaced by #1 [recreate:web:1]\n" +
"[3] -> #4 service:web:1, RenameContainer, finalize recreate [recreate:web:1]\n"
assert.Equal(t, p.String(), expected)
}
func TestPlanAddNodeAutoIncrements(t *testing.T) {
p := &Plan{}
n1 := p.addNode(Operation{Type: OpCreateNetwork, ResourceID: "a", Cause: "x"}, "")
n2 := p.addNode(Operation{Type: OpCreateVolume, ResourceID: "b", Cause: "y"}, "")
n3 := p.addNode(Operation{Type: OpCreateContainer, ResourceID: "c", Cause: "z"}, "", n1, n2)
assert.Equal(t, n1.ID, 1)
assert.Equal(t, n2.ID, 2)
assert.Equal(t, n3.ID, 3)
assert.Equal(t, len(n3.DependsOn), 2)
assert.Equal(t, n3.DependsOn[0].ID, 1)
assert.Equal(t, n3.DependsOn[1].ID, 2)
}