feat(hooks): capture and surface lifecycle hook output - #14091
Conversation
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Two real bugs introduced by this PR — one memory-management issue and one output-correctness issue — plus a usability gap in --hooks --follow. See inline comments for details.
|
Some notes: the first two commits are the right fix, and they're @dennislapchenko's work from #14088: I'd rather we merge his PR as-is and rebase the rest on top, so authorship lands where it belongs. On the third commit, I'm going to push back on the NDJSON store. First, scoping the actual problem: in attached mode hook output already streams to the console ( But a client-side store is the wrong place to fill that gap. Compose's contract is that project state lives in the engine: any client, from any machine, can operate a project it didn't create. With logs under the client's config dir, I'd rather we go engine-native:
So: merge #14088, add failed-pre_start retention here, drop the store. WDYT? |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
ndeloof
left a comment
There was a problem hiding this comment.
Scope is exactly right: pure client-side, bounded tail in the error + retained container for post-mortem, with persistence deferred to the engine — and the test coverage is outstanding (31 new tests including every cleanup-failure path). Nice work on the Ctrl-C fix and the \r/UTF-8 handling too.
Inline comments below. I'd consider the first three worth fixing in this PR (anonymous-volume leak in the orphan sweep, retention on user cancellation, stale AutoRemove comments); the rest is fine as follow-ups:
down <svc>cleans only the named services' hook containers while the traversal removes the service and its dependents — the dependents' retained hooks survive a targeted down.- a
ContainerListfailure aborts the teardown mid-way, while the doc comment promises removal failures don't; warn-and-continue would match. - the two near-identical sweep helpers (
removeOrphanPreStartContainers/removePreStartHookContainers) could merge into one, which would structurally prevent the RemoveVolumes divergence. - ordering: retained hooks are removed after the service containers, yet they reference the service's volumes via
VolumesFrom— worth checkingdown -vstill reclaims the service's anonymous volumes in that order (sweeping hooks first would be safer). - "intentionally excluded" could also point at the concrete engine-native path in flight: moby/moby#53406 / moby/moby#53407 (opt-in exec output capture through the logging driver) — that's the API a future
logs --hookswould consume.
| return err | ||
| } | ||
| for _, ctr := range res.Items { | ||
| if _, removeErr := s.apiClient().ContainerRemove(ctx, ctr.ID, client.ContainerRemoveOptions{Force: true}); removeErr != nil { |
There was a problem hiding this comment.
This sweep is missing RemoveVolumes: true: the success path and the down sweep both pass it, so a hook whose image declares VOLUMEs leaks its anonymous volumes when this path does the cleanup. (Merging this helper with removePreStartHookContainers in down.go would prevent the divergence structurally.)
| cancelLogs() | ||
| <-logsDone | ||
| return waitErr | ||
| if waitErr != nil { |
There was a problem hiding this comment.
Two things on the retention branch:
waitPreStartreturnsctx.Err()on cancellation, so a plain Ctrl-C lands here too: the container is retained and the tail gets wrapped aroundcontext.Canceled. A user cancellation isn't a post-mortem — at minimum don't decoratecontext.Canceledwith the tail, and arguably don't retain in that case.- The docs say the operator can run
docker logs <id>, but neither the ID nor a name appears in the error (hook containers are created unnamed). Including the short ID here — or better, naming the container (<project>-<service>-pre_start-<i>) — would make the retention actually actionable.
| logsDone, getTail := s.streamPreStartLogs(logCtx, created.ID, service, index, listener) | ||
|
|
||
| if _, err := s.apiClient().ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil { | ||
| // AutoRemove only fires after a successful start, so the never-started |
There was a problem hiding this comment.
This comment (and its sibling in the connectPreStartExtraNetworks failure path) still justifies the explicit removal with "AutoRemove only fires after a successful start" — but AutoRemove is now false unconditionally. The behavior is right, the rationale is stale.
| f.Add("label", hookFilter(preStartHookType)) | ||
| filters = []client.Filters{f} | ||
| } else { | ||
| for _, service := range services { |
There was a problem hiding this comment.
Follow-up: scoping by options.Services diverges from what the teardown actually removes — WithRootNodesAndDown takes down the named services plus their dependents, so a targeted down web leaves the dependents' retained hook containers behind.
| Filters: f, | ||
| }) | ||
| if err != nil { | ||
| return err |
There was a problem hiding this comment.
Follow-up: this return err aborts the teardown (networks/volumes/images not yet processed) on a listing failure, while the doc comment above promises removal failures don't abort. Warn-and-continue would be consistent.
| } | ||
| } | ||
|
|
||
| if err := s.removePreStartHookContainers(ctx, projectName, options.Services); err != nil { |
There was a problem hiding this comment.
Follow-up / to verify: the retained hook containers reference the service containers' volumes via VolumesFrom, and they are removed after the service containers — so the service's rm -v may fail to reclaim anonymous volumes still referenced by a retained hook (they'd only go away with the hook's own RemoveVolumes afterwards). Sweeping hooks before the service containers would be the safer order.
| if partial := strings.TrimSpace(t.partial.String()); partial != "" { | ||
| lines = append(append([]string{}, lines...), partial) | ||
| if partial := strings.TrimSpace(strings.ToValidUTF8(t.partial.String(), "")); partial != "" { | ||
| lines = append(lines, partial) |
There was a problem hiding this comment.
Nit: this dropped the defensive copy (append(append([]string{}, lines...), partial)) — append(lines, partial) can write into t.lines' backing array. Harmless with the current single-goroutine write / read-after-done usage, but fragile for the one line it saves.
ndeloof
left a comment
There was a problem hiding this comment.
Thanks — the three must-fix items from my previous review are all properly addressed: RemoveVolumes: true on every sweep/cleanup path (with the mocks updated to pin it), the cancellation branch no longer retains nor decorates context.Canceled, the retained-container short ID makes the error actionable, and the stale AutoRemove comments now state the actual behavior. The new TestPreStart_CancellationRemovesContainer and the short-ID assertion are exactly the locks I hoped for, and the start_test.go adaptation is correct and well-explained.
Two unrelated regressions slipped into the last commit (rebase leftovers, I assume) that must be dropped before merge — see inline: the validate-mocks CI check removal and the codeql-action downgrade.
One real bug remains in the new cancellation path (inline): the cleanup ContainerRemove runs on the already-cancelled context, so in production it fails immediately and the container is left behind anyway (the orphan sweep saves the day on the next run, but the code's promise is dead on arrival). context.WithoutCancel(ctx) fixes it.
The agreed follow-ups (down scoping vs dependents, list-failure aborting teardown, merging the two sweep helpers, hook-before-service removal order, moby#53406/#53407 pointer) remain open — fine by me to land them separately.
| - lint | ||
| - validate-go-mod | ||
| - validate-headers | ||
| - validate-docs |
There was a problem hiding this comment.
This commit removes validate-mocks from the validate matrix — the check just introduced on main (#14102), and nothing in this PR justifies it (the commit message doesn't mention it either). Looks like a rebase leftover; please restore it. If the check is red on this branch, that's a mocks drift to fix with make mocks, not a check to drop.
| # Upload the results to GitHub's code scanning dashboard. | ||
| - name: "Upload to code-scanning" | ||
| uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 | ||
| uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 |
There was a problem hiding this comment.
Same commit downgrades codeql-action v4.37.7 → v4.37.6, reverting the dependabot bump merged on main. Rebase artifact — please drop this hunk.
| // Ctrl-C is a user cancellation, not a hook failure: remove the container | ||
| // and return the raw context error without decorating it with the tail or | ||
| // retaining the container for post-mortem inspection. | ||
| if ctx.Err() != nil { |
There was a problem hiding this comment.
This ContainerRemove runs on the already-cancelled ctx, so in production it fails immediately with context.Canceled and lands in the Warnf below — the container is retained despite this branch's intent (the mock in TestPreStart_CancellationRemovesContainer doesn't honor ctx, which is why the test passes). Use context.WithoutCancel(ctx) for the cleanup call — same pattern up uses for its teardown.
On top of the output-tail infrastructure from the previous commit: Stderr bias: split the combined output tail into separate stdout and stderr buffers. stdcopy.StdCopy(wOut, wErr, reader) now routes each stream to its own outputTail. The error message prefers stderr (where hooks write their actual error) and falls back to stdout when stderr is empty: db hook exited with status 1: Table 'service.sites' doesn't exist A new hookExitError helper encapsulates this preference. pre_start parity: streamPreStartLogs previously short-circuited and returned immediately when listener was nil (detached mode / compose up -d), leaving the tail buffers empty and failure errors without context. The log stream is now always opened. The demultiplexed streams fill separate tailOut / tailErr buffers. On non-zero exit the stderr-biased tail is appended to the error (same behaviour as exec hooks). Tests: - TestRunHook_StderrBias: stdout noise stays out of the error when stderr has content. - TestPreStart_DetachedModeAttachesLogs: ContainerLogs is called even with a nil listener. - TestPreStart_FailureIncludesTail: a failing pre_start hook carries its stderr output in the returned error. Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
ndeloof
left a comment
There was a problem hiding this comment.
The rebase regressions are gone (validate-mocks back in the matrix, codeql-action back on v4.37.7) and the outputTail.String() defensive copy is restored — thanks, and the squashed history reads well.
One item from my previous review survived the rewrite though (the inline thread got marked outdated by the force-push, so re-anchoring it): the cancellation-path cleanup still runs ContainerRemove on the already-cancelled context, so in production it can't succeed. One-line fix, and with it I have nothing else blocking — LGTM once addressed.
| // and return the raw context error without decorating it with the tail or | ||
| // retaining the container for post-mortem inspection. | ||
| if ctx.Err() != nil { | ||
| if _, removeErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil { |
There was a problem hiding this comment.
Still passing the already-cancelled ctx here: the moby client aborts the request immediately with context.Canceled, so this removal always lands in the Warnf and the container is left behind despite the branch's intent (the gomock in TestPreStart_CancellationRemovesContainer doesn't honor ctx, which is why the test stays green). context.WithoutCancel(ctx) on this call fixes it.
A failing pre_start hook container is now retained (AutoRemove: false) so operators can run 'docker logs <id>' and 'docker ps -a' to diagnose the failure. On success the container is removed explicitly, mirroring the old AutoRemove behaviour including anonymous volumes. Before each pre_start run, stale hook containers from a previous failed run are detected via project+service+HookLabel filters and force-removed so they do not accumulate. Changes: - pkg/api/labels.go: add HookLabel (com.docker.compose.hook) - pkg/compose/filters.go: add hookFilter helper - pkg/compose/pre_start.go: - AutoRemove: false in createPreStartContainer - HookLabel added to container labels - runPreStartHook: explicit ContainerRemove on success; retain on failure - removeOrphanPreStartContainers: new helper called in runPreStart - Tests: update all existing pre_start tests for the new flow; add feature tests (success removes, failure retains, orphan cleanup) and coverage-gap tests (lowestNumberedContainer, waitPreStart cancel, preStartResultErr, streamPreStartLogs error paths, old-API network paths, ExecCreate/Attach/Inspect errors, hookExitError branches) Coverage after: hook.go 100%, pre_start.go most functions 100% (was 89% and 64% respectively per Codecov delta). Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
A pre_start hook container retained after failure (introduced in the previous commit) is created without ConfigHashLabel so getContainers() and every downstream compose operation (down, ps, stop, rm) is blind to it. Without explicit cleanup it would survive compose down. This commit adds removePreStartHookContainers(), called from down() after regular container teardown and before network removal (so hook containers are gone before their networks are removed). The cleanup is project-wide on a full compose down; scoped to the requested services when compose down <svc...> is used. Individual ContainerRemove errors are logged at warn level and do not abort the teardown. Changes: - pkg/compose/down.go: removePreStartHookContainers helper + call in down() - pkg/compose/filters.go: add //nolint:unparam to hookFilter (param is architecturally correct; today only preStartHookType is in use) - pkg/compose/down_test.go: update all existing down tests to expect the new ContainerList call; add TestDownRemovesRetainedPreStartHookContainers and TestDownHookContainerRemovalFailureIsNonFatal Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
- compose_down.md: note that retained pre_start hook containers (those kept after a hook failure for post-mortem) are automatically removed by compose down, with the label filter to list them beforehand. - compose_up.md: note the retention behavior on pre_start hook failure, how to inspect the retained container, and that it is cleaned up on the next compose up or compose down. Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
ndeloof
left a comment
There was a problem hiding this comment.
The cancellation-path removal now survives the cancelled context — that was my last blocking point. LGTM.
One optional polish inline (fine as-is or in a follow-up): context.WithoutCancel(ctx) instead of context.Background() keeps the context values (tracing spans notably) while still dropping cancellation, and matches the idiom runInteractiveUp already uses for its teardown.
| // and return the raw context error without decorating it with the tail or | ||
| // retaining the container for post-mortem inspection. | ||
| if ctx.Err() != nil { | ||
| if _, removeErr := s.apiClient().ContainerRemove(context.Background(), created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil { |
There was a problem hiding this comment.
Non-blocking:
| if _, removeErr := s.apiClient().ContainerRemove(context.Background(), created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil { | |
| if _, removeErr := s.apiClient().ContainerRemove(context.WithoutCancel(ctx), created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil { |
WithoutCancel keeps the tracing/values of the parent context while dropping its cancellation — same effect as Background() for the API call, better trace correlation, and consistent with runInteractiveUp's teardown.
What this PR delivers
Builds on #14088 (merged), which added the hook output tail and error reporting foundation.
Context cancellation fix:
runHookpreviously blocked indefinitely on the exec-attach reader. The copy goroutine held the connection open, so the first Ctrl+C had no effect — only the OS kill on the second signal interrupted the process, skipping cleanup. Fix: run the copy in a background goroutine andselectonctx.Done()vs completion; on cancellation, close the connection to unblock the goroutine and returnctx.Err()directly (noExecInspecton the cancel path). Restores the behaviour of the oldrunWaitExec.outputTail robustness:
Write()rewritten to block-scan withbytes.IndexByte(O(n) vs O(n²) byte-by-byte loop); partial line byte-capped viaappendToPartial.strings.ToValidUTF8applied at both truncation sites so a cut multi-byte rune does not corrupt the error message.pushLinehandles mid-line\r(progress bars from curl/apt/pip): strip trailing\r, then keep the segment after the last bare\r.pre_start parity (from #14088 base): log stream always opened even without a listener so the tail is populated in detached mode; stderr-biased error reporting.
What was intentionally excluded
The client-side NDJSON hook-log store and
compose logs --hooksflag were prototyped in an earlier revision of this branch but have been dropped. Persisting hook output belongs in the engine (see moby/moby#32047, moby/moby#9527), not as a client-side workaround. That work is deferred until an engine-native solution exists.Testing
TestRunHook_ContextCancellation: net.Pipe that never writes; assertscontext.Canceledwithin 5 s ofcancel().TestRunHook_FailureIncludesOutput/with_listener: asserts listener receives all output lines on failure.TestRunHook_StderrBias: uses a capturing listener; asserts both stream lines are delivered.TestOutputTail: CRLF stripping, progress-bar\r, UTF-8-safe byte truncation.