From b016de4b9ff709c36abe70889ed7e354ec77804a Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Sun, 19 Apr 2026 16:59:49 +0200 Subject: [PATCH] introduce (reconcilation) Plan String() is designed to make it easy to compare coomputed plan vs expectations Signed-off-by: Nicolas De Loof --- pkg/compose/plan.go | 164 +++++++++++++++++++++++++++++++++++++++ pkg/compose/plan_test.go | 139 +++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 pkg/compose/plan.go create mode 100644 pkg/compose/plan_test.go diff --git a/pkg/compose/plan.go b/pkg/compose/plan.go new file mode 100644 index 000000000..3fea00ed8 --- /dev/null +++ b/pkg/compose/plan.go @@ -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 +} diff --git a/pkg/compose/plan_test.go b/pkg/compose/plan_test.go new file mode 100644 index 000000000..d0e9999c9 --- /dev/null +++ b/pkg/compose/plan_test.go @@ -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) +}