Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/api/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ const (
// runPreStartHook so orphan hook containers from a previous failed run can
// be found and removed by project+service+hook label filters.
HookLabel = "com.docker.compose.hook"
// HookIndexLabel stores the position of the hook in its service's hook
// list (e.g. pre_start[2] → "2"), so the start phase can match each
// declared hook with the runner container the reconciliation plan
// prepared for it.
HookIndexLabel = "com.docker.compose.hook-index"
)

// ComposeVersion is the compose tool version as declared by label VersionLabel
Expand Down
2 changes: 2 additions & 0 deletions pkg/compose/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ func (exec *planExecutor) executeNode(ctx context.Context, node *PlanNode) error
return exec.execRemoveContainer(ctx, op)
case OpRenameContainer:
return exec.execRenameContainer(ctx, node)
case OpCreateHookContainer:
return exec.execCreateHookContainer(ctx, node)
case OpRunProvider:
return exec.compose.runPlugin(ctx, exec.project, *op.Service, "up")
default:
Expand Down
35 changes: 34 additions & 1 deletion pkg/compose/executor_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,15 @@ func (exec *planExecutor) execStopContainer(ctx context.Context, op Operation) e
}

func (exec *planExecutor) execRemoveContainer(ctx context.Context, op Operation) error {
_, err := exec.compose.apiClient().ContainerRemove(ctx, op.Container.ID, client.ContainerRemoveOptions{Force: true})
_, err := exec.compose.apiClient().ContainerRemove(ctx, op.Container.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: op.RemoveVolumes})
if err != nil {
if op.BestEffort {
// warn-only removal (stale pre_start hook runner): the container
// stays visible to the operator, the plan carries on — and the
// live view below keeps it, since it was not removed
logrus.Warnf("failed to remove %s: %v", op.ResourceID, err)
return nil
}
return err
}
// Why: a dependent service's create may resolve `network_mode: service:X`
Expand All @@ -161,6 +168,32 @@ func (exec *planExecutor) execRemoveContainer(ctx context.Context, op Operation)
return nil
}

// execCreateHookContainer creates the runner container for one pre_start hook.
// The target replica — whose volumes the hook shares via VolumesFrom — is
// resolved from the live view at execution time: the node depends on every
// container operation of its service, so the view is final here, and the
// lowest-numbered replica matches the one the start phase hands the hooks.
func (exec *planExecutor) execCreateHookContainer(ctx context.Context, node *PlanNode) error {
op := node.Operation
service := *op.Service
exec.containersMu.Lock()
replicas := slices.Clone(exec.containersByService[service.Name])
exec.containersMu.Unlock()
if len(replicas) == 0 {
return fmt.Errorf("internal: no %q container to attach pre_start hook %d to", service.Name, op.HookIndex)
}
target := lowestNumberedContainer(replicas)
created, err := exec.compose.createPreStartContainer(ctx, exec.project, service, target, op.HookIndex, op.Name)
if err != nil {
return err
}
exec.pctx.set(node.ID, operationResult{
ContainerID: created.ID,
ContainerName: op.Name,
})
return nil
}

func (exec *planExecutor) execRenameContainer(ctx context.Context, node *PlanNode) error {
op := node.Operation
if op.CreateNodeID == 0 {
Expand Down
112 changes: 112 additions & 0 deletions pkg/compose/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,38 @@ func emptyObservedState(project string) *ObservedState {
// Goes through newPlanExecutor + run (i.e. the same code path executePlan
// uses in production) so the test exercises the errgroup, done-channel
// wiring and group tracker — not a hand-rolled loop over executeNode.
// A best-effort removal failure (stale pre_start hook runner purge) is
// warn-only: the plan carries on, and the removal passes RemoveVolumes so the
// runner's anonymous volumes go with it — the imperative purge semantics.
func TestExecutePlanBestEffortRemoveContainerFailureTolerated(t *testing.T) {
svc, apiClient := newTestService(t)

ctr := container.Summary{
ID: "hook1",
Names: []string{"/some-hook-runner"},
Labels: map[string]string{api.ServiceLabel: "web"},
}

apiClient.EXPECT().ContainerRemove(gomock.Any(), "hook1", gomock.Any()).
DoAndReturn(func(_ context.Context, _ string, opts client.ContainerRemoveOptions) (client.ContainerRemoveResult, error) {
assert.Assert(t, opts.RemoveVolumes, "hook-runner purge must drop anonymous volumes")
return client.ContainerRemoveResult{}, errors.New("device or resource busy")
})

plan := &Plan{}
plan.addNode(Operation{
Type: OpRemoveContainer,
ResourceID: "hook:web:stale:hook1",
Cause: "stale pre_start hook container",
Container: &ctr,
RemoveVolumes: true,
BestEffort: true,
}, "")

err := svc.executePlan(t.Context(), &types.Project{Name: "test"}, emptyObservedState("test"), plan)
assert.NilError(t, err)
}

func TestExecutePlanRemoveContainerDropsFromCache(t *testing.T) {
svc, apiClient := newTestService(t)

Expand Down Expand Up @@ -486,3 +518,83 @@ type conflictError struct{}

func (conflictError) Error() string { return "conflict" }
func (conflictError) Conflict() {}

// TestExecutePlanCreateHookContainer verifies the hook-runner create op: the
// target replica is resolved from the executor's live view (lowest
// container-number, mirroring what the start phase hands the hooks) and the
// runner is created under the deterministic name carried by the operation.
func TestExecutePlanCreateHookContainer(t *testing.T) {
svc, apiClient := newTestService(t)
apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}).
Return(client.PingResult{APIVersion: "1.44"}, nil).AnyTimes()
apiClient.EXPECT().ClientVersion().Return("1.44").AnyTimes()

service := types.ServiceConfig{
Name: "web",
Image: "alpine",
PreStart: []types.ServiceHook{
{Command: types.ShellCommand{"init"}},
},
}
observed := &ObservedState{
ProjectName: "test",
Containers: map[string][]ObservedContainer{
"web": {
// Deliberately listed out of order: replica 2 first.
{ID: "c2", Summary: container.Summary{ID: "c2", Labels: map[string]string{api.ContainerNumberLabel: "2"}}},
{ID: "c1", Summary: container.Summary{ID: "c1", Labels: map[string]string{api.ContainerNumberLabel: "1"}}},
},
},
Networks: map[string][]ObservedNetwork{},
Volumes: map[string][]ObservedVolume{},
}

var gotOpts client.ContainerCreateOptions
apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) {
gotOpts = opts
return client.ContainerCreateResult{ID: "hook-1"}, nil
})

plan := &Plan{}
plan.addNode(Operation{
Type: OpCreateHookContainer,
ResourceID: "hook:web:pre_start:0",
Cause: "pre_start hook",
Service: &service,
HookIndex: 0,
Name: getHookContainerName("test", "web", 0),
}, "")

err := svc.executePlan(t.Context(), &types.Project{Name: "test"}, observed, plan)
assert.NilError(t, err)
assert.Equal(t, gotOpts.Name, "test-web-pre_start-0")
assert.DeepEqual(t, gotOpts.HostConfig.VolumesFrom, []string{"c1"})
assert.Equal(t, gotOpts.Config.Labels[api.HookIndexLabel], "0")
}

// TestExecutePlanCreateHookContainerNoReplica: a hook-create node scheduled
// with no replica in the live view is an internal planning error — the
// reconciler guarantees the node depends on the replica's create.
func TestExecutePlanCreateHookContainerNoReplica(t *testing.T) {
svc, _ := newTestService(t)

service := types.ServiceConfig{
Name: "web",
Image: "alpine",
PreStart: []types.ServiceHook{{Command: types.ShellCommand{"init"}}},
}

plan := &Plan{}
plan.addNode(Operation{
Type: OpCreateHookContainer,
ResourceID: "hook:web:pre_start:0",
Cause: "pre_start hook",
Service: &service,
HookIndex: 0,
Name: getHookContainerName("test", "web", 0),
}, "")

err := svc.executePlan(t.Context(), &types.Project{Name: "test"}, emptyObservedState("test"), plan)
assert.ErrorContains(t, err, `no "web" container`)
}
21 changes: 21 additions & 0 deletions pkg/compose/observed_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ type ObservedState struct {
// others as orphans (see selectNetwork/selectVolume).
Networks map[string][]ObservedNetwork // compose network key → observed
Volumes map[string][]ObservedVolume // compose volume key → observed
// HookContainers are ephemeral lifecycle-hook runners (HookLabel set),
// per service: fresh runners prepared by a previous plan and not yet
// consumed, or leftovers of a run that failed before removing them.
// Whenever the hooks are going to run again, the reconciler purges every
// observed runner and plans fresh ones (see planPreStartHookRunners), so
// the set always converges to the current service definition.
HookContainers map[string][]ObservedContainer // service name → hook containers
}

// selectNetwork picks, among the live networks recorded for a compose key, the
Expand Down Expand Up @@ -148,6 +155,8 @@ func (s *composeService) collectObservedState(ctx context.Context, project *type
Containers: map[string][]ObservedContainer{},
Networks: map[string][]ObservedNetwork{},
Volumes: map[string][]ObservedVolume{},

HookContainers: map[string][]ObservedContainer{},
}

// --- Containers ---
Expand All @@ -170,6 +179,18 @@ func (s *composeService) collectObservedState(ctx context.Context, project *type

for _, ctr := range raw {
svcName := ctr.Labels[api.ServiceLabel]
if ctr.Labels[api.HookLabel] != "" && knownServices[svcName] {
// lifecycle-hook containers (ephemeral pre_start runners) are
// neither service replicas nor one-offs: classified apart, so
// they never masquerade as a replica (they carry no
// container-number label and would otherwise read as number 0)
// and the reconciler can plan purging stale ones. A hook
// container whose service left the model falls through to the
// orphan check below instead — nothing plans purges for an
// unknown service, and --remove-orphans must keep cleaning it.
state.HookContainers[svcName] = append(state.HookContainers[svcName], toObservedContainer(ctr))
continue
}
if isNotOneOff(ctr) && knownServices[svcName] {
state.Containers[svcName] = append(state.Containers[svcName], toObservedContainer(ctr))
} else if isOrphaned(project)(ctr) {
Expand Down
44 changes: 40 additions & 4 deletions pkg/compose/observed_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,33 @@ func TestCollectObservedState(t *testing.T) {
api.OneoffLabel: "True",
},
},
{
// Stale lifecycle-hook runner (a previous run failed before
// removing it): neither a replica (it has no container-number
// label and must not read as number 0) nor a one-off —
// classified apart so the reconciler can plan its purge.
ID: "c5",
Names: []string{"/hook-runner"},
State: container.StateExited,
Labels: map[string]string{
api.ServiceLabel: "web",
api.ProjectLabel: "myproject",
api.HookLabel: "pre_start",
},
},
{
// Hook runner whose service left the model: nothing plans
// purges for an unknown service, so it must keep flowing to
// the orphan path --remove-orphans cleans.
ID: "c6",
Names: []string{"/old-hook-runner"},
State: container.StateExited,
Labels: map[string]string{
api.ServiceLabel: "old",
api.ProjectLabel: "myproject",
api.HookLabel: "pre_start",
},
},
},
}, nil)

Expand Down Expand Up @@ -202,11 +229,20 @@ func TestCollectObservedState(t *testing.T) {
assert.Equal(t, len(state.Containers["db"]), 1)
assert.Equal(t, state.Containers["db"][0].ID, "c2")

// Orphans: only the model-absent service "old". The running one-off c4 is
// absent everywhere — not in the "web" bucket (asserted above: 1 replica),
// not an orphan: up leaves live `compose run` sessions alone.
assert.Equal(t, len(state.Orphans), 1)
// The hook runner is classified apart — not a "web" replica (asserted
// above: 1 replica), not an orphan
assert.Equal(t, len(state.HookContainers["web"]), 1)
assert.Equal(t, state.HookContainers["web"][0].ID, "c5")

// Orphans: the model-absent service "old" — its replica AND its hook
// runner (c6), which must not hide in HookContainers where nothing would
// ever purge it. The running one-off c4 is absent everywhere — not in the
// "web" bucket (asserted above: 1 replica), not an orphan: up leaves live
// `compose run` sessions alone.
assert.Equal(t, len(state.Orphans), 2)
assert.Equal(t, state.Orphans[0].ID, "c3")
assert.Equal(t, state.Orphans[1].ID, "c6")
assert.Equal(t, len(state.HookContainers["old"]), 0)

// Networks
assert.Equal(t, len(state.Networks), 1)
Expand Down
21 changes: 16 additions & 5 deletions pkg/compose/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ const (

// Provider operations
OpRunProvider OperationType = 30

// Hook operations. 40-42 are reserved for the start-phase operations
// (wait condition, pre_start run, post_start run).
OpCreateHookContainer OperationType = 43
)

// String returns the human-readable name of an OperationType.
Expand Down Expand Up @@ -82,6 +86,8 @@ func (o OperationType) String() string {
return "RenameContainer"
case OpRunProvider:
return "RunProvider"
case OpCreateHookContainer:
return "CreateHookContainer"
default:
return fmt.Sprintf("Unknown(%d)", int(o))
}
Expand All @@ -103,11 +109,16 @@ type Operation struct {
Volume *types.VolumeConfig // for volume operations
Timeout *time.Duration // for stop operations
CreateNodeID int // for OpRenameContainer: ID of the CreateContainer node whose result to rename
// BestEffort marks an operation whose failure must not abort the plan. It is
// used for the optional removal of the old network on a rename: if the
// network is still in use (by non-Compose containers) the removal is skipped
// with a warning instead of failing — the new network already carries a
// different name, so the migration does not depend on the old one going away.
HookIndex int // for OpCreateHookContainer: position of the hook in the service's hook list
// RemoveVolumes asks OpRemoveContainer to also remove the container's
// anonymous volumes — the imperative semantics for hook-runner containers.
RemoveVolumes bool
// BestEffort marks an operation whose failure must not abort the plan.
// Used for the optional removal of the old network on a rename (if the
// network is still in use by non-Compose containers the removal is skipped
// with a warning — the new network already carries a different name), and
// for purging stale pre_start hook runners (the imperative purge is
// warn-only: a failed removal leaves the container visible, never blocks).
BestEffort bool
}

Expand Down
Loading
Loading