Skip to content

pre_start hook runners become reconciliation-plan resources - #14221

Open
ndeloof wants to merge 2 commits into
docker:mainfrom
ndeloof:prestart-lifecycle-in-plan
Open

ndeloof wants to merge 2 commits into
docker:mainfrom
ndeloof:prestart-lifecycle-in-plan

Conversation

@ndeloof

@ndeloof ndeloof commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What this PR does, in one sentence

pre_start hook containers become first-class resources of the reconciliation plan: the plan creates them, the start phase only executes them.

Context

pre_start hook containers have their own identity — a name, labels, and possibly an image different from the service's — yet they live entirely outside the reconciliation plan: today runPreStart creates, runs and removes them imperatively inside the start flow. The engine cannot see them, so it cannot order them, purge them deterministically, or account for their failures the way it does for every other resource. Leftover runners from a failed run are swept by ad-hoc label scans, and nothing ties a runner to the hook it was created for.

What the PR brings

The reconciler now plans one CreateHookContainer operation per declared hook, whenever the start phase is going to run the hooks — a replica to start and no running replica surviving the plan (a recreated replica is not running at start time, so its runners are planned too). Runner creation is properly ordered in the DAG: after every replica operation of its service (so the executor resolves the VolumesFrom target against a final live view), after the infrastructure, and after the purge of previously observed runners. Runners get a deterministic name (<project>-<service>-pre_start-<i>) and a new com.docker.compose.hook-index label, so repeated plans converge on the same container instead of accumulating anonymous ones, and the start phase can match each declared hook with the runner prepared for it.

runPreStart becomes pure execution: it looks up the created-state runner for each hook and never creates one. A declared hook without a prepared runner is an actionable error naming the reconciliation command to run (docker compose up <service>). The accepted consequence — locked by an e2e scenario — is that stop then start of a hooked service errors, since runners are consumed on success; up covers the overwhelming majority of usage and always prepares fresh runners.

Guardrails: success removal, failure retention (container kept for post-mortem) and cancellation cleanup are byte-for-byte the previous behavior; runners stay invisible to start/ps listings (they carry no config-hash label, which the default filters require); down keeps removing them by hook label; runners from previous compose versions (no index label) are recognized and purged.

Why this is the right next brick

The plan engine currently covers the create phase only. Moving runner creation into that phase needs no start-phase operations, so this lands independently of the start-phase plan (#14200) while setting up the follow-up: with runners as plan resources, hook execution and removal can become start-phase operations, making failure retention a DAG property instead of imperative code.

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 (docker#14200).

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
pre_start hook containers become first-class resources of the
reconciliation plan. The reconciler plans one OpCreateHookContainer per
declared hook — gated on the same predicate the start phase uses to run
the hooks: a replica to start and no running replica SURVIVING the plan
(a recreated replica is not running at start time, so its runners are
planned too). Runner creation depends on every replica node (the
executor resolves the VolumesFrom target against a final live view),
on the infrastructure, and on the purge of previously observed runners:
names are deterministic (<project>-<service>-pre_start-<i>) so repeated
plans converge on the same container instead of accumulating anonymous
ones, and a new HookIndexLabel ties each runner to its hook.

runPreStart becomes pure execution: it looks up the created-state
runner for each hook by label and never creates one. A declared hook
without a prepared runner is an actionable error naming the
reconciliation command (docker compose up <service>) — the accepted
consequence is that stop-then-start of a hooked service errors, since
runners are consumed on success (locked by e2e). Success removal,
failure retention and cancellation cleanup are unchanged.

Runners stay invisible to the start/ps container listings — they carry
no ConfigHashLabel, which getDefaultFilters requires — and down keeps
removing them by hook label.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof requested review from a team as code owners September 15, 2026 10:08

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Review did not complete — The agent ran but did not post a review. View logs for details. Re-request a review from docker-agent to retry.

@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.82759% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/executor_ops.go 90.47% 1 Missing and 1 partial ⚠️
pkg/compose/pre_start.go 95.83% 1 Missing and 1 partial ⚠️
pkg/compose/reconcile.go 94.73% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@glours glours left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice one, turning hook runners into first-class plan resources removes a whole class of ad-hoc bookkeeping. Two non-blocking should-fixes found while going through the diff (AI-assisted review, verified against the code before posting), nothing here blocks merge.

Comment thread pkg/compose/reconcile.go
Comment on lines +817 to +826
for i := range service.PreStart {
node := r.plan.addNode(Operation{
Type: OpCreateHookContainer,
ResourceID: fmt.Sprintf("hook:%s:%s:%d", service.Name, preStartHookType, i),
Cause: "pre_start hook",
Service: &serviceCopy,
HookIndex: i,
Name: getHookContainerName(r.project.Name, service.Name, i),
}, "", deps...)
deps = []*PlanNode{node}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The loop reassigns deps to just the previous hook's node, chaining every OpCreateHookContainer for a service into a serial dependency line — even though each targets the same already-live replica independently and has no reason to wait on a sibling hook's create. Keeping deps fixed at the shared set (containerNodes+infraDeps+purges) lets the executor create them in parallel instead.

Suggested change
for i := range service.PreStart {
node := r.plan.addNode(Operation{
Type: OpCreateHookContainer,
ResourceID: fmt.Sprintf("hook:%s:%s:%d", service.Name, preStartHookType, i),
Cause: "pre_start hook",
Service: &serviceCopy,
HookIndex: i,
Name: getHookContainerName(r.project.Name, service.Name, i),
}, "", deps...)
deps = []*PlanNode{node}
for i := range service.PreStart {
r.plan.addNode(Operation{
Type: OpCreateHookContainer,
ResourceID: fmt.Sprintf("hook:%s:%s:%d", service.Name, preStartHookType, i),
Cause: "pre_start hook",
Service: &serviceCopy,
HookIndex: i,
Name: getHookContainerName(r.project.Name, service.Name, i),
}, "", deps...)
}

assert.Equal(t, plan.String(), strings.TrimSpace(`
[] -> #1 service:app:1, CreateContainer, no existing container
[1] -> #2 hook:app:pre_start:0, CreateHookContainer, pre_start hook
[2] -> #3 hook:app:pre_start:1, CreateHookContainer, pre_start hook

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Side effect of the fix suggested in reconcile.go: with deps no longer reassigned per iteration, hook[1]'s create would depend on [1] (the replica create) instead of [2] (hook[0]'s own node).

Suggested change
[2] -> #3 hook:app:pre_start:1, CreateHookContainer, pre_start hook
[1] -> #3 hook:app:pre_start:1, CreateHookContainer, pre_start hook

Comment thread pkg/compose/pre_start.go
Comment on lines +94 to +95
return fmt.Errorf("service %q pre_start[%d]: no hook runner container found — runners are prepared when the service is created and consumed when its hooks run; run %q to prepare them again",
service.Name, i, "docker compose up "+service.Name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Narrow but real race: keptRunning is decided from the pre-plan ObservedState snapshot in reconcile.go, while startService re-checks container state independently via a fresh ContainerList after the whole create-phase plan has executed. If the sole surviving replica is stopped externally in that window, plan-time skips runner creation (replica looked like it would survive) but start-time now sees nothing running and hits this hard error — previously self-healing via the inline create, now a dead end pointing at up for a situation up didn't cause.

Not asking for the full fix here (that'd mean re-validating state at start time instead of trusting two independent daemon reads), but the message could at least hint this isn't necessarily a planning bug:

Suggested change
return fmt.Errorf("service %q pre_start[%d]: no hook runner container found — runners are prepared when the service is created and consumed when its hooks run; run %q to prepare them again",
service.Name, i, "docker compose up "+service.Name)
return fmt.Errorf("service %q pre_start[%d]: no hook runner container found — either its runner was already consumed by a previous start, or the service's container state changed after reconciliation planned this run; run %q to prepare fresh runners",
service.Name, i, "docker compose up "+service.Name)

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.

3 participants