Skip to content

re-implement compose logic#13638

Closed
ndeloof wants to merge 1 commit into
docker:mainfrom
ndeloof:reconciliation
Closed

re-implement compose logic#13638
ndeloof wants to merge 1 commit into
docker:mainfrom
ndeloof:reconciliation

Conversation

@ndeloof
Copy link
Copy Markdown
Contributor

@ndeloof ndeloof commented Mar 15, 2026

re-implement compose logic as a reconciliation between observed and desired state, producing a plan with pending operations.

  • build observed state
  • compute reconciliation plan by comparing observed vs desired state
  • execute plan

This allow to decorelate reconciliation (aka "convergence") logic from docker API, and write simpler and efficient tests to cover various scenarios

This PR was created with AI assistance

What I did

told Claude about the architecture I had in mind, and expectations regarding tests structure

Related issue

(not mandatory) A picture of a cute animal, if possible in relation to what you did
image

@ndeloof ndeloof requested a review from a team as a code owner March 15, 2026 19:08
@ndeloof ndeloof requested a review from glours March 15, 2026 19:08
@ndeloof ndeloof force-pushed the reconciliation branch 8 times, most recently from f0460b4 to 8017d4e Compare March 17, 2026 07:25
Copilot AI review requested due to automatic review settings March 17, 2026 07:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR re-implements the Compose “up/create” convergence logic as a pure reconciliation step (observed vs desired state) that outputs a dependency-ordered plan, plus an executor that applies that plan via Docker API calls. This aims to decouple decision-making from Docker API interactions and enable more targeted unit testing.

Changes:

  • Introduces ObservedState, a pure Reconcile() planner, and a DAG-based ExecutePlan() executor to replace most of the old convergence logic.
  • Refactors create to: inspect current state → reconcile → execute plan, and updates run to use the new execution state for service reference resolution.
  • Adds extensive unit tests for reconcile behavior and annotates some e2e tests with TODOs pointing to the new unit coverage.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
pkg/compose/create.go Switches create flow to InspectState → Reconcile → ExecutePlan and adds external network validation + “untouched” event emission.
pkg/compose/observed_state.go Adds observed state builder (containers/networks/volumes/orphans) used as Reconcile input.
pkg/compose/reconcile.go Adds the pure reconciliation/planning logic, operation model, and dependency wiring.
pkg/compose/plan_executor.go Adds plan execution engine (concurrent DAG traversal) and display helpers; includes new executionState.
pkg/compose/reconcile_test.go Adds a large suite of unit tests validating plan generation across scenarios.
pkg/compose/observed_state_test.go Adds basic tests for observed state and plan helper behaviors.
pkg/compose/run.go Replaces convergence-based service reference resolution with executionState.
pkg/compose/convergence.go Removes most of the old convergence implementation (now replaced by reconcile+executor).
pkg/e2e/*.go Adds TODO comments noting equivalent coverage by new Reconcile unit tests.
pkg/compose/publish.go Minor refactor to use fmt.Fprintf with builders.
pkg/compose/progress.go Updates comment wording for runningEvent.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread pkg/compose/reconcile.go
Comment on lines +1098 to +1104
ctrName := getCanonicalContainerName(ctr)
// Skip if we already have operations for this container
if _, exists := plan.Operations["recreate-container:"+ctrName]; exists {
continue
}
if _, exists := plan.Operations["stop-container:"+ctrName]; exists {
continue
Comment thread pkg/compose/create.go Outdated
case op.ContainerOp != nil:
serviceOps[op.ServiceName] = append(serviceOps[op.ServiceName], op)
case op.PluginOp != nil:
serviceOps[op.ServiceName] = append(serviceOps[op.ServiceName], op)
Comment thread pkg/compose/reconcile.go
Comment on lines +720 to +733
removeID := fmt.Sprintf("remove-container:%s", ctrName)
plan.Operations[removeID] = &Operation{
ID: removeID,
Type: OpRemoveContainer,
ServiceName: service.Name,
Resource: ctrName,
ContainerOp: &ContainerOperation{
Service: service,
ContainerName: ctrName,
Existing: &ctr,
},
DependsOn: []string{stopID},
Reason: "scale down",
}
Comment thread pkg/compose/plan_executor.go Outdated
Comment on lines +71 to +115
func (es *executionState) getContainers(serviceName string) Containers {
es.mu.Lock()
defer es.mu.Unlock()
return es.containers[serviceName]
}

func (es *executionState) setNetworkID(key, id string) {
es.mu.Lock()
defer es.mu.Unlock()
es.networks[key] = id
}

func (es *executionState) setVolumeID(key, id string) {
es.mu.Lock()
defer es.mu.Unlock()
es.volumes[key] = id
}

// resolveServiceReferences replaces service references in a ServiceConfig with
// actual container IDs from the execution state. This mirrors the logic in
// convergence.resolveServiceReferences but uses executionState instead.
func (es *executionState) resolveServiceReferences(service *types.ServiceConfig) error {
if err := es.resolveVolumeFrom(service); err != nil {
return err
}
return es.resolveSharedNamespaces(service)
}

func (es *executionState) resolveVolumeFrom(service *types.ServiceConfig) error {
for i, vol := range service.VolumesFrom {
spec := strings.Split(vol, ":")
if len(spec) == 0 {
continue
}
if spec[0] == "container" {
service.VolumesFrom[i] = spec[1]
continue
}
name := spec[0]
dependencies := es.getContainers(name)
if len(dependencies) == 0 {
return fmt.Errorf("cannot share volume with service %s: container missing", name)
}
service.VolumesFrom[i] = dependencies.sorted()[0].ID
}
Comment thread pkg/compose/plan_executor.go Outdated
Comment thread pkg/compose/observed_state.go Outdated
Comment on lines +101 to +109
// Partition containers by service
containersByService := map[string]Containers{}
for _, c := range allContainers {
service := c.Labels[api.ServiceLabel]
containersByService[service] = append(containersByService[service], c)
}

// Identify orphan containers
orphans := allContainers.filter(isOrphaned(project))
Comment thread pkg/compose/reconcile.go
Comment on lines +271 to +279
// Build network ID map and volume name map for needsRecreate checks
networkIDs := map[string]string{}
for key, n := range observed.Networks {
networkIDs[key] = n.ID
}
volumeNames := map[string]string{}
for key, v := range observed.Volumes {
volumeNames[key] = v.Name
}
Comment thread pkg/compose/reconcile.go
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 17, 2026

Codecov Report

❌ Patch coverage is 85.08137% with 165 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/reconcile.go 88.08% 61 Missing and 18 partials ⚠️
pkg/compose/plan_executor.go 76.87% 63 Missing and 11 partials ⚠️
pkg/compose/create.go 81.81% 5 Missing and 5 partials ⚠️
pkg/compose/observed_state.go 96.82% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

- build observed state
- compute reconciliation plan by comparing observed vs desired state
- execute plan

this allow to decorelate reconciliation (aka "convergence") logic from docker API,
and write simpler and efficient tests to cover various scenarios

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants