Skip to content

Commit b52def4

Browse files
committed
feat: reconciler plans the purge of stale pre_start hook runners
pre_start hooks run in ephemeral containers. When a run fails, the runner is deliberately retained for post-mortem inspection, and the next run purges it — but that purge lived entirely inside the imperative primitive, invisible to the reconciliation plan. It is now a plan operation: when pre_start is going to run again (hooks declared, no replica running at observation — the imperative gating), the plan emits one best-effort RemoveContainer per stale runner, dropping its anonymous volumes, exactly the warn-only semantics of the imperative purge that remains in place as backstop. Observed state learns to tell hook containers apart: they carry no container-number label and previously classified as a service replica numbered 0. They now land in a dedicated HookContainers bucket the reconciler plans purges from. The imperative primitive is also split into its lifecycle-free execution piece (execPreStartHook: start, wait, log streaming, retain-on-failure) and the create/remove pieces around it, recomposed identically in runPreStart — locked by the existing characterization tests. This prepares moving hook-container creation and post-success removal into the plan once the start phase lands (#14200). Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent db9a43d commit b52def4

8 files changed

Lines changed: 302 additions & 33 deletions

File tree

pkg/compose/executor_ops.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,15 @@ func (exec *planExecutor) execStopContainer(ctx context.Context, op Operation) e
142142
}
143143

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

pkg/compose/executor_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,38 @@ func emptyObservedState(project string) *ObservedState {
174174
// Goes through newPlanExecutor + run (i.e. the same code path executePlan
175175
// uses in production) so the test exercises the errgroup, done-channel
176176
// wiring and group tracker — not a hand-rolled loop over executeNode.
177+
// A best-effort removal failure (stale pre_start hook runner purge) is
178+
// warn-only: the plan carries on, and the removal passes RemoveVolumes so the
179+
// runner's anonymous volumes go with it — the imperative purge semantics.
180+
func TestExecutePlanBestEffortRemoveContainerFailureTolerated(t *testing.T) {
181+
svc, apiClient := newTestService(t)
182+
183+
ctr := container.Summary{
184+
ID: "hook1",
185+
Names: []string{"/some-hook-runner"},
186+
Labels: map[string]string{api.ServiceLabel: "web"},
187+
}
188+
189+
apiClient.EXPECT().ContainerRemove(gomock.Any(), "hook1", gomock.Any()).
190+
DoAndReturn(func(_ context.Context, _ string, opts client.ContainerRemoveOptions) (client.ContainerRemoveResult, error) {
191+
assert.Assert(t, opts.RemoveVolumes, "hook-runner purge must drop anonymous volumes")
192+
return client.ContainerRemoveResult{}, errors.New("device or resource busy")
193+
})
194+
195+
plan := &Plan{}
196+
plan.addNode(Operation{
197+
Type: OpRemoveContainer,
198+
ResourceID: "hook:web:stale:hook1",
199+
Cause: "stale pre_start hook container",
200+
Container: &ctr,
201+
RemoveVolumes: true,
202+
BestEffort: true,
203+
}, "")
204+
205+
err := svc.executePlan(t.Context(), &types.Project{Name: "test"}, emptyObservedState("test"), plan)
206+
assert.NilError(t, err)
207+
}
208+
177209
func TestExecutePlanRemoveContainerDropsFromCache(t *testing.T) {
178210
svc, apiClient := newTestService(t)
179211

pkg/compose/observed_state.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ type ObservedState struct {
4646
// others as orphans (see selectNetwork/selectVolume).
4747
Networks map[string][]ObservedNetwork // compose network key → observed
4848
Volumes map[string][]ObservedVolume // compose volume key → observed
49+
// HookContainers are ephemeral lifecycle-hook runners (HookLabel set),
50+
// per service. Any observed at collection time is stale by definition —
51+
// a previous run failed before removing it — and the reconciler plans
52+
// its purge before re-running the hooks.
53+
HookContainers map[string][]ObservedContainer // service name → hook containers
4954
}
5055

5156
// selectNetwork picks, among the live networks recorded for a compose key, the
@@ -148,6 +153,8 @@ func (s *composeService) collectObservedState(ctx context.Context, project *type
148153
Containers: map[string][]ObservedContainer{},
149154
Networks: map[string][]ObservedNetwork{},
150155
Volumes: map[string][]ObservedVolume{},
156+
157+
HookContainers: map[string][]ObservedContainer{},
151158
}
152159

153160
// --- Containers ---
@@ -170,6 +177,18 @@ func (s *composeService) collectObservedState(ctx context.Context, project *type
170177

171178
for _, ctr := range raw {
172179
svcName := ctr.Labels[api.ServiceLabel]
180+
if ctr.Labels[api.HookLabel] != "" && knownServices[svcName] {
181+
// lifecycle-hook containers (ephemeral pre_start runners) are
182+
// neither service replicas nor one-offs: classified apart, so
183+
// they never masquerade as a replica (they carry no
184+
// container-number label and would otherwise read as number 0)
185+
// and the reconciler can plan purging stale ones. A hook
186+
// container whose service left the model falls through to the
187+
// orphan check below instead — nothing plans purges for an
188+
// unknown service, and --remove-orphans must keep cleaning it.
189+
state.HookContainers[svcName] = append(state.HookContainers[svcName], toObservedContainer(ctr))
190+
continue
191+
}
173192
if isNotOneOff(ctr) && knownServices[svcName] {
174193
state.Containers[svcName] = append(state.Containers[svcName], toObservedContainer(ctr))
175194
} else if isOrphaned(project)(ctr) {

pkg/compose/observed_state_test.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,33 @@ func TestCollectObservedState(t *testing.T) {
160160
api.OneoffLabel: "True",
161161
},
162162
},
163+
{
164+
// Stale lifecycle-hook runner (a previous run failed before
165+
// removing it): neither a replica (it has no container-number
166+
// label and must not read as number 0) nor a one-off —
167+
// classified apart so the reconciler can plan its purge.
168+
ID: "c5",
169+
Names: []string{"/hook-runner"},
170+
State: container.StateExited,
171+
Labels: map[string]string{
172+
api.ServiceLabel: "web",
173+
api.ProjectLabel: "myproject",
174+
api.HookLabel: "pre_start",
175+
},
176+
},
177+
{
178+
// Hook runner whose service left the model: nothing plans
179+
// purges for an unknown service, so it must keep flowing to
180+
// the orphan path --remove-orphans cleans.
181+
ID: "c6",
182+
Names: []string{"/old-hook-runner"},
183+
State: container.StateExited,
184+
Labels: map[string]string{
185+
api.ServiceLabel: "old",
186+
api.ProjectLabel: "myproject",
187+
api.HookLabel: "pre_start",
188+
},
189+
},
163190
},
164191
}, nil)
165192

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

205-
// Orphans: only the model-absent service "old". The running one-off c4 is
206-
// absent everywhere — not in the "web" bucket (asserted above: 1 replica),
207-
// not an orphan: up leaves live `compose run` sessions alone.
208-
assert.Equal(t, len(state.Orphans), 1)
232+
// The hook runner is classified apart — not a "web" replica (asserted
233+
// above: 1 replica), not an orphan
234+
assert.Equal(t, len(state.HookContainers["web"]), 1)
235+
assert.Equal(t, state.HookContainers["web"][0].ID, "c5")
236+
237+
// Orphans: the model-absent service "old" — its replica AND its hook
238+
// runner (c6), which must not hide in HookContainers where nothing would
239+
// ever purge it. The running one-off c4 is absent everywhere — not in the
240+
// "web" bucket (asserted above: 1 replica), not an orphan: up leaves live
241+
// `compose run` sessions alone.
242+
assert.Equal(t, len(state.Orphans), 2)
209243
assert.Equal(t, state.Orphans[0].ID, "c3")
244+
assert.Equal(t, state.Orphans[1].ID, "c6")
245+
assert.Equal(t, len(state.HookContainers["old"]), 0)
210246

211247
// Networks
212248
assert.Equal(t, len(state.Networks), 1)

pkg/compose/plan.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,15 @@ type Operation struct {
103103
Volume *types.VolumeConfig // for volume operations
104104
Timeout *time.Duration // for stop operations
105105
CreateNodeID int // for OpRenameContainer: ID of the CreateContainer node whose result to rename
106-
// BestEffort marks an operation whose failure must not abort the plan. It is
107-
// used for the optional removal of the old network on a rename: if the
108-
// network is still in use (by non-Compose containers) the removal is skipped
109-
// with a warning instead of failing — the new network already carries a
110-
// different name, so the migration does not depend on the old one going away.
106+
// RemoveVolumes asks OpRemoveContainer to also remove the container's
107+
// anonymous volumes — the imperative semantics for hook-runner containers.
108+
RemoveVolumes bool
109+
// BestEffort marks an operation whose failure must not abort the plan.
110+
// Used for the optional removal of the old network on a rename (if the
111+
// network is still in use by non-Compose containers the removal is skipped
112+
// with a warning — the new network already carries a different name), and
113+
// for purging stale pre_start hook runners (the imperative purge is
114+
// warn-only: a failed removal leaves the container visible, never blocks).
111115
BestEffort bool
112116
}
113117

pkg/compose/pre_start.go

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -79,26 +79,38 @@ func (s *composeService) runPreStart(ctx context.Context, project *types.Project
7979
logrus.Warnf("service %q: failed to remove stale pre_start hook containers: %v", service.Name, err)
8080
}
8181
for i, hook := range service.PreStart {
82-
if err := s.runPreStartHook(ctx, project, service, ctr, i, hook, listener); err != nil {
82+
created, err := s.createPreStartContainer(ctx, project, service, ctr, hook)
83+
if err != nil {
84+
return err
85+
}
86+
if err := s.execPreStartHook(ctx, service, i, created.ID, listener); err != nil {
8387
return err
8488
}
89+
// Success: remove the hook container, mirroring the old AutoRemove behaviour
90+
// (including its anonymous volumes). A removal failure is logged but does not
91+
// gate service start — the hook already succeeded.
92+
if _, removeErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{RemoveVolumes: true}); removeErr != nil {
93+
logrus.Warnf("service %q pre_start[%d]: failed to remove hook container %s: %v", service.Name, i, created.ID, removeErr)
94+
}
8595
}
8696
return nil
8797
}
8898

89-
func (s *composeService) runPreStartHook(
90-
ctx context.Context, project *types.Project, service types.ServiceConfig,
91-
ctr container.Summary, index int, hook types.ServiceHook, listener api.ContainerEventListener,
99+
// execPreStartHook starts an already-created hook container, streams its logs
100+
// and waits for its exit. It owns only execution-failure handling: a container
101+
// that never started or a run cancelled by the user is removed, a genuinely
102+
// failed hook is retained for post-mortem inspection. Removing the container
103+
// after a successful run is the caller's job — the container's lifecycle
104+
// belongs to whoever created it (the imperative runPreStart loop today, the
105+
// reconciliation plan once the executor runs hook nodes).
106+
func (s *composeService) execPreStartHook(
107+
ctx context.Context, service types.ServiceConfig,
108+
index int, containerID string, listener api.ContainerEventListener,
92109
) error {
93-
created, err := s.createPreStartContainer(ctx, project, service, ctr, hook)
94-
if err != nil {
95-
return err
96-
}
97-
98110
// Subscribe to wait before start to avoid missing the exit event for short-lived hooks.
99111
// WaitConditionNotRunning would match immediately because the container is still in
100112
// "created" state, so use WaitConditionNextExit to block until the run actually finishes.
101-
waitRes := s.apiClient().ContainerWait(ctx, created.ID, client.ContainerWaitOptions{
113+
waitRes := s.apiClient().ContainerWait(ctx, containerID, client.ContainerWaitOptions{
102114
Condition: container.WaitConditionNextExit,
103115
})
104116

@@ -108,13 +120,13 @@ func (s *composeService) runPreStartHook(
108120
// open cannot deadlock `<-logsDone`.
109121
logCtx, cancelLogs := context.WithCancel(ctx)
110122
defer cancelLogs()
111-
logsDone, getTail := s.streamPreStartLogs(logCtx, created.ID, service, index, listener)
123+
logsDone, getTail := s.streamPreStartLogs(logCtx, containerID, service, index, listener)
112124

113-
if _, err := s.apiClient().ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
125+
if _, err := s.apiClient().ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil {
114126
// AutoRemove is false, so we must remove the never-started container
115127
// explicitly. A failed removal is logged so the orphan is visible.
116-
if _, removeErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
117-
logrus.Warnf("service %q pre_start[%d]: failed to remove orphan hook container %s: %v", service.Name, index, created.ID, removeErr)
128+
if _, removeErr := s.apiClient().ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
129+
logrus.Warnf("service %q pre_start[%d]: failed to remove orphan hook container %s: %v", service.Name, index, containerID, removeErr)
118130
}
119131
// Drain waitRes so the client's wait goroutine exits without having to
120132
// wait for the parent context to be canceled.
@@ -136,15 +148,15 @@ func (s *composeService) runPreStartHook(
136148
// and return the raw context error without decorating it with the tail or
137149
// retaining the container for post-mortem inspection.
138150
if ctx.Err() != nil {
139-
if _, removeErr := s.apiClient().ContainerRemove(context.Background(), created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
140-
logrus.Warnf("service %q pre_start[%d]: failed to remove hook container %s after cancellation: %v", service.Name, index, created.ID, removeErr)
151+
if _, removeErr := s.apiClient().ContainerRemove(context.Background(), containerID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
152+
logrus.Warnf("service %q pre_start[%d]: failed to remove hook container %s after cancellation: %v", service.Name, index, containerID, removeErr)
141153
}
142154
return waitErr
143155
}
144156
// Genuine hook failure: retain the container so the operator can run
145157
// `docker logs <id>` and `docker inspect <id>` to diagnose the failure.
146158
// Include the short container ID in the error to make it actionable.
147-
shortID := created.ID
159+
shortID := containerID
148160
if len(shortID) > 12 {
149161
shortID = shortID[:12]
150162
}
@@ -153,12 +165,6 @@ func (s *composeService) runPreStartHook(
153165
}
154166
return fmt.Errorf("%w (hook container %s retained for inspection)", waitErr, shortID)
155167
}
156-
// Success: remove the hook container, mirroring the old AutoRemove behaviour
157-
// (including its anonymous volumes). A removal failure is logged but does not
158-
// gate service start — the hook already succeeded.
159-
if _, removeErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{RemoveVolumes: true}); removeErr != nil {
160-
logrus.Warnf("service %q pre_start[%d]: failed to remove hook container %s: %v", service.Name, index, created.ID, removeErr)
161-
}
162168
return nil
163169
}
164170

pkg/compose/reconcile.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,8 @@ func (r *reconciler) reconcileService(service types.ServiceConfig) error {
662662
return err
663663
}
664664

665+
r.planPurgeStaleHookRunners(service, expected)
666+
665667
containers := r.observed.Containers[service.Name]
666668
actual := len(containers)
667669

@@ -760,6 +762,41 @@ func (r *reconciler) reconcileService(service types.ServiceConfig) error {
760762

761763
// mustRecreate decides whether oc must be recreated to match expected. The
762764
// expectedHash and parentRecreated inputs are precomputed once per service by
765+
// planPurgeStaleHookRunners plans the removal of hook-runner containers left
766+
// behind by a previous run that failed before removing them. It mirrors the
767+
// imperative purge living inside the gated runPreStart call: emitted only when
768+
// pre_start is going to run again — hooks declared, a replica to start
769+
// (scale > 0: the imperative start path returns before the hooks for a
770+
// scale-0 service) and no replica running at observation — so a genuinely
771+
// failed hook container stays retained for inspection as long as its service
772+
// is otherwise up. Removals are best-effort (the imperative purge is
773+
// warn-only) and independent of every other node.
774+
func (r *reconciler) planPurgeStaleHookRunners(service types.ServiceConfig, expectedScale int) {
775+
stale := r.observed.HookContainers[service.Name]
776+
if len(stale) == 0 || len(service.PreStart) == 0 || expectedScale == 0 {
777+
return
778+
}
779+
for _, oc := range r.observed.Containers[service.Name] {
780+
if oc.State == container.StateRunning {
781+
return
782+
}
783+
}
784+
serviceCopy := service
785+
stale = slices.Clone(stale)
786+
slices.SortFunc(stale, func(a, b ObservedContainer) int { return strings.Compare(a.ID, b.ID) })
787+
for i := range stale {
788+
r.plan.addNode(Operation{
789+
Type: OpRemoveContainer,
790+
ResourceID: fmt.Sprintf("hook:%s:stale:%s", service.Name, stale[i].ID[:min(12, len(stale[i].ID))]),
791+
Cause: "stale pre_start hook container",
792+
Service: &serviceCopy,
793+
Container: &stale[i].Summary,
794+
RemoveVolumes: true,
795+
BestEffort: true,
796+
}, "")
797+
}
798+
}
799+
763800
// reconcileService — see expectedConfigHash and parentNamespaceRecreated for
764801
// the rationale (issue #13878).
765802
func (r *reconciler) mustRecreate(expected types.ServiceConfig, expectedHash string, parentRecreated bool, oc ObservedContainer, policy string) bool {

0 commit comments

Comments
 (0)