Conversation
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Lower-confidence findings (not posted inline)
- [medium→low] pkg/compose/plugins.go:395 —
get-imagerequest handled for any plugin command without command-context guard (confidence: weak 52/100)
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
docker-agent
left a comment
There was a problem hiding this comment.
This review covers only the commits pushed since d7fc6a1 (the previous completed review).
Assessment: 🟢 APPROVE
The incremental diff (92 lines across 3 files) makes two targeted fixes:
-
plugins.go —
handlePluginMessagegains acommandparameter soget-imagerequests are rejected with a protocol error when sent outside thepullcommand, preventing image-export stalls inup/down/stop. Thecommandstring is passed by value and is thread-safe. -
pull.go —
ensureProviderImagesnow runs unconditionally before theerr != nilcheck (fixing the previously reported finding): a regular-service pull failure no longer silently skips provider image distribution. When both fail,errors.Join(err, providerErr)surfaces both. UnderIgnoreFailures=true,providerErris suppressed symmetrically with regular service pull errors — consistent with the semantics documented in the prior review thread resolution. -
provider_images_test.go — New unit test
TestExecutePlugin_GetImageRefusedOutsidePullvalidates the protocol restriction. Theassert.ErrorContainssubstring ("get-image is only supported during the pull command") correctly matches the actual error message from plugins.go ("invalid message from plugin: get-image is only supported during the pull command").
No new bugs found in the + lines.
7b812e0 to
96d14a1
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
96d14a1 to
5a7bc6f
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Provider image-phase integration is well-structured overall and the chunked-streaming protocol is sound. Two correctness issues were found in the new code.
5a7bc6f to
a4da6e1
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
a4da6e1 to
f482d73
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
f482d73 to
cc4504f
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The provider image distribution implementation is solid. The chunked-transfer protocol, mutex discipline, and error propagation are all well-handled. The two drafter hypotheses about a trailing CRLF mismatch between ChunkedWriter.Close() and the documented protocol were investigated and found to be false positives: Go's httputil.ChunkedWriter.Close() writes only "0\r\n" (not "0\r\n\r\n") — confirmed from the Go 1.26.3 source — and the code comment and docs/extension.md are correct. ChunkedReader returns EOF when it reads the zero-length chunk without consuming any additional bytes, so the wire format is internally consistent and the test assertion ("no byte may follow the terminating zero chunk") is valid.
Lower-confidence findings (not posted inline)
-
[low]
pkg/compose/pull.go:~86–93 —ensureProviderImagesunreachable whenpullServiceImages/pullHookImagesreturns a fatal scheduling error (unverified low severity)
The comment at line 102 states providers run "even when a regular pull failed", which is accurate for per-image pull failures accumulated inp.pullErrors. However, ifpullServiceImagesorpullHookImagesreturns a non-nil scheduling error (e.g. ashouldPullImageparse failure on a badpull_policyvalue), the early-exit at line 93 skipsensureProviderImagesentirely. In practice this edge case only triggers on configuration errors that would abort the command anyway, so real-world impact is minimal. -
[low]
pkg/compose/provider_images.go:97–107 —providerImageSourcealways returns"local"for build-only services duringcompose pull --pull=always(unverified low severity)
For a service withbuild:declared and noimage:name,providerImageSourcealways returnsproviderImageSourceLocalregardless of whether the image was actually rebuilt in this run. Oncompose pull,builtisnil, sojustBuiltis alwaysfalse— and sinceservice.Image == "", line 101 returns"local". This is arguably correct design (build-only services have no registry authority), but a provider receivingsource=local, policy=alwaysmay re-ingest the same unchanged local image on everycompose pull, giving false freshness signals. A brief doc comment clarifying this intent would help provider authors.
9bbe3a8 to
20adf11
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The image-distribution feature is well-structured. The chunked HTTP/1.1 streaming protocol is correct, the stdinMu contract is intentionally documented, and the errgroup non-canceling pattern is appropriate. The prior review findings (mutex duration, mid-transfer abort via stdin.Close, error propagation in pull.go) have all been addressed in the current code.
Lower-confidence findings (not posted inline)
These low-severity observations were drafted but not formally verified (low findings skip the verification step). They are surfaced here for author awareness only.
-
[low]
docs/examples/provider.go:155— error message omitsannounce.Errorwhen compose sends an error JSON
Whenjson.Unmarshalsucceeds butannounce.Error != ""(compose sent an error response), the emitted message isimage stream unavailable: <line> (err <nil>)— the server-reported reason inannounce.Erroris not included. As example/documentation code this has no production impact, but would mislead readers implementing their own provider. A clearer message would be:image stream unavailable: <announce.Error>. -
[low]
pkg/compose/plugins.go:361,368— announce write failures silently discarded on error paths instreamImageTo
On theinvalid platformandImageSaveerror paths,_ = announce(...)discards any write failure. If the provider pipe is already broken at that point, the provider receives EOF without an error JSON line. The impact is minimal: the provider already sees EOF and discards, and the original error is still returned to the caller and logged. No hang or data corruption is possible.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
Lower-confidence findings (not posted inline)
-
[low] pkg/compose/pull.go:110 —
ensureProviderImagesnil return appended to pre-allocatedpullErrorsslice with leading nil slots (confidence: not scored — low severity, not verified)pullErrorsis initialized asmake([]error, len(project.Services))— a slice of N nil entries whererunServicePullwrites by index. The provider error is appended after those N entries.errors.Joinskips nils so there's no user-visible impact today, but a future caller assumingpullErrors[i]corresponds toproject.Services[i]would find the provider error at indexlen(project.Services)outside that mapping. -
[low] pkg/compose/plugins.go:476 —
stdin.Close()may be called twice concurrently on the non-processExited error path (confidence: not scored — low severity, not verified)When
streamImageTofails after the success announce is sent, theGetImageTypegoroutine callsstdin.Close()while holdingstdinMu. On the non-processExitedpath theexecutePlugindefer also callsstdin.Close(). Go'sos.File.Close()is thread-safe so there's no crash, but a future refactor making theWriteCloserless robust could surface the double-close. Async.Oncewould make the intent clearer. -
[low] pkg/compose/provider_images.go:74 —
providerImageSourcereturnslocalfor a build-only service ondocker compose pullwhen no local image may exist (confidence: not scored — low severity, not verified)When
docker compose pullis invoked (built=nil), a provider service with abuild:directive but noimage:field gets--source=localwith no--digest/--createdcache facts (because the image was never built). A provider implementing the documented contract might then issueget-imagefor a ref that doesn't exist in the local daemon, receiving an error. Consider returningproviderImageSourceRegistryfor build-only services whenbuiltis nil, or explicitly documenting that--source=localwith no cache facts means "no local copy exists."
glours
left a comment
There was a problem hiding this comment.
Reviewed the provider image-distribution mechanism (the new pull lifecycle command + chunked get-image transfer). Requesting changes on 3 points below, each with a concrete minimal fix. Everything else checked out correctly under direct tracing: the providerImageSource truth table against the 5 reachable combinations, goroutine/stdin lifecycle (no leak, no mutual deadlock between the stdout read loop and the image-streaming goroutine), event-ID disjointness, SkipProviders scoping (only used by watch.go's rebuild path), and the pull.go error-joining logic. Build (go build ./...) and the existing unit tests pass against d10d7ea1b, but none of them exercise the 3 gaps below, which is why they weren't caught.
…ution Providers opt into image distribution by declaring a pull block in their metadata — the presence of the command block is the declaration of support, generalizing the stop precedent. During its pull command a provider can request the service image from the local daemon with a get-image message (optionally platform-narrowed); compose answers on stdin with one image-stream JSON line then the tar as HTTP/1.1 chunked data (RFC 9112): length-prefixed blocks need no in-band delimiter in binary data, the zero-length chunk marks a COMPLETE transfer, and any stock chunked reader consumes it. There is deliberately no trailer nor final CRLF after the zero chunk — a stock reader stops there without consuming further bytes, so the next stdin answer starts clean for a provider requesting several images. An export failure is announced in-band through the error field so the provider is never left waiting; a failure after the announce closes the answer channel so the truncation is observable as EOF mid-chunk instead of a stream nobody will finish. The stream is exclusive on stdin for its whole duration — the framing invariant behind the lock. get-image is scoped to the pull command: emitted during up, down or stop it is a protocol error rather than served, so an image export can never stall another lifecycle command. Message dispatch moves out of executePlugin into handlePluginMessage, which also keeps the function under the complexity threshold. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
ensureImagesExists and docker compose pull now give provider-backed services their turn: compose invokes the provider's pull command with the image identity and the state of the local daemon cache (--image/--digest/--created), plus two verdicts compose alone can compute so providers never re-implement that arbitration: - --source: the authority for this invocation — local (the daemon's image is the desired state: build-only service, pull_policy build, or an image the current run just built) or registry (resolve the reference upstream — including the CI workflow where build is only the recipe used to publish the image consumers pull); - --policy: missing on the up path (a usable version suffices) or always on compose pull (ensure freshness of the authority). digest/created describe the cache, they are not instructions: the provider persists them as the bookkeeping keys of what it ingested — digest as identity test, created as the ordering fallback for backends that cannot preserve digests. Providers without the metadata block are skipped: behavior unchanged. Providers are independent, so their pulls run without fail-fast cancellation — the first failure no longer kills its siblings mid-run and mangles their reports into context-canceled noise; every provider runs to completion and every failure is reported, prefixed by service. On the pull path the provider phase runs unconditionally — skipping it because an unrelated service failed to pull would silently leave provider runtimes stale — and its error is a per-service pull error, following their exact regime: reported with them, suppressed by IgnoreFailures. A fatal errgroup error is reported joined with the accumulated per-service context instead of dropping it. The provider runs under the root context: the errgroup context is canceled once Wait returns and would kill the provider process on the spot. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
startService errored with 'no container to start' whenever the project-wide container listing came back empty, before the per-service filtering that lets provider services pass through. A project whose services are all provider-backed legitimately reaches the start phase with zero containers. The empty-list error is now softened for provider services only — a relay container deployed for published endpoints still goes through the regular start path. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
extension.md gains the Image distribution section: invocation contract (--image/--digest/--created/--source/--policy), the get-image request and its chunked image-stream answer (zero-chunk terminated, stdin exclusivity and drain-before-next-answer contract), the two-branch decision guidance (digest as identity, created as ordering fallback, reproducible builds caveat), and the support-by-presence metadata convention is now stated as the general rule. The example provider implements pull end to end — request, stock chunked reader, hash recorded at PROVIDER_PULL_MARKER — and the e2e scenario locks the whole path: up builds the service image and streams it to the provider under the local verdict, compose pull re-invokes it under the freshness contract. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
A service declaring only build: (no image:) answered get-service-config with an empty "image" field: service.Image is the YAML-declared value, and compose-go never fills it in for this case (every other caller of GetImageNameOrDefault in this package exists precisely because of that). The provider only ever sees this response, never the model compose builds internally, so an sbx-style provider building its own runtime image rejected the service outright with "defines no image". The response now carries the resolved name (api.GetImageNameOrDefault) instead - the same one the image phase already built and tagged by the time a provider asks. The build directive itself is cleared before marshaling: a provider has no builder to run it against, only an image identity to run. Pinned by a regression test using the existing fake-provider harness, verified by ablation to fail on either half of the fix reverted. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Three fixes from glours' review: - ensureProviderImages now skips a provider service declaring neither image: nor build: (legal for a pure resource provider, e.g. one backing a remote Amazon RDS instance). Without the guard, such a service reached pull with a fabricated "<project>-<service>" reference (GetImageNameOrDefault) under source=registry (providerImageSource treats Build == nil as registry-authoritative) -- a name that was never real. Already reachable through existing e2e fixtures (TestDependsOnMultipleProviders' provider1/provider2), only a no-op today because the demo pull() short-circuits on an unset env var, not because of any guard here. - localImageFacts now resolves --digest through the manifest-aware localContentDigest instead of the raw ImageInspect ID. Under BuildKit provenance attestations, the raw ID covers the attestation manifest too and changes on every build even when the runnable image content didn't (docker#13636), which would make the provider re-sync on every up for no reason -- defeating docs/extension.md's own "identity test" contract for --digest. Threads service.Platform through from runProviderPull so the resolved manifest matches what the provider will actually run. - streamImageTo now distinguishes a failure before the success announce (invalid platform, ImageSave error -- a clean, well-formed error line, channel intact) from one after it (the chunked body truncated, channel unresynchronizable) via a new streamAbortedError type. Only the latter closes stdin; the provider protocol supports several sequential get-image requests per pull invocation, and closing stdin on a clean pre-stream error was killing any other independent request in the same invocation for no reason. Each fix is pinned by a regression test, verified by reverting the fix locally and confirming the new test catches it. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
d10d7ea to
220e693
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
docker-agent found that streamImageTo returned a plain error (channel intact) when the announce write itself failed -- for the success announce, and for the error announce on a platform-parse/ImageSave failure. In both cases the provider never received anything to read and was left blocked on ReadString, while the caller, seeing a plain error, never closed stdin either -- a deadlock. Both paths now return streamAbortedError when the announce write fails, matching the existing mid-stream/close failure handling so the caller's errors.As check closes stdin and unblocks the provider. Pinned by two new regression tests using a writer that always errors, verified by reverting the fix locally and confirming both fail with a plain error instead of streamAbortedError. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
docker-agent
left a comment
There was a problem hiding this comment.
This review covers only the commits pushed since 220e69341281d71b5cf5bd8828a4422e2d6ba99a.
Assessment: 🟢 APPROVE
The incremental diff correctly addresses the two previously flagged issues:
-
Success-announce write failure now returns
&streamAbortedError{werr}— the success-announce path that previously returned a plainerr(leaving the provider blocked waiting for a JSON line that never arrived) now correctly wraps the write error so the caller closes stdin. -
Error-announce write failure on both paths also returns
&streamAbortedError{werr}— the two_ = announce(...)calls that silently discarded announce write failures on the platform-parse andImageSaveerror paths are now properly checked; a write failure returns&streamAbortedError{werr}rather than leaving the channel broken with no signal to the provider. -
Two new tests pin both fixes:
TestStreamImageTo_SuccessAnnounceWriteFailureAbortsandTestStreamImageTo_ErrorAnnounceWriteFailureAbortsexercise the exact failure paths using a minimalfailingWritertype. Mock expectations match the actual call signatures, deferredtar.Close()executes correctly on early returns, and theerrors.Asassertion correctly distinguishes*streamAbortedErrorfrom other error types.
No new issues found in the changed lines.
What this PR does, in one sentence
Provider-backed services take part in the image phase: compose hands the provider the identity of the service image and — on request — streams it the image bytes, so a
build:orimage:declared on a provider service actually reaches the provider's runtime.Context
A provider service can declare
build:today: compose builds the image… into the local daemon, where the provider never sees it. Providers whose runtime lives elsewhere (a sandbox VM with its own daemon, a cloud backend) have no way to receive a locally-built image, and no say in the image phase at all —docker compose pullskips them entirely. The only workarounds are out-of-band: push to a registry the provider can reach, or manualdocker save | loadplumbing.What the PR brings
Providers opt in by declaring a
pullblock in theirmetadataoutput — the presence of the command block is the declaration of support, the same conventionstopalready uses (now documented as the general rule). For opted-in providers, compose invokes<provider> compose pullduring the image phase ofup(after any build) and ondocker compose pull, passing:--image) and the state of the local daemon cache (--digest/--created, when present) — facts to persist as bookkeeping keys, not instructions: digest as identity test,createdas the ordering fallback for backends that cannot preserve digests (a rebuilt local image must be detectable as newer even by a backend that transforms what it ingests);--source(local: the daemon's image is the desired state — build-only service,pull_policy: build, or just built by this run; registry: resolve upstream, which covers the build-as-CI-recipe workflow where consumers never build) and--policy(missing onup: a usable version suffices; always onpull: ensure freshness).When the provider needs the local bytes, it sends a
get-imagerequest and compose answers on its stdin with one JSON line then the image tar as an HTTP/1.1 chunked body (RFC 9112): length-prefixed blocks need no in-band delimiter in binary data, the zero-length chunk marks a complete transfer (a truncated stream must be discarded), and any language's stock chunked reader consumes it. Export failures are announced in-band so the provider is never left waiting.Guardrails: providers without the metadata block see zero behavior change; the wire format is locked by unit tests (byte-identical round-trip on delimiter-hostile payloads, error announce, end-to-end against a fake provider process); an e2e scenario drives the full path through the example provider (up builds and streams under the local verdict,
compose pullre-invokes under the freshness contract). The PR also fixes a pre-existing gap the scenario exposed: a project made only of provider services failed the start phase with "no container to start" — softened for provider services only, the endpoint-relay still going through the regular start path.Why this is the right next brick
This is deliberately the provider-initiated design: the image phase is where compose already makes images available to where services run, and the digest/created negotiation gives idempotence for free (no re-streaming on every
up). A side-channel transport for very large images and the pull-fails→build fallback chain can layer on later without breaking the announced-answer protocol (encodingfield).🤖 Generated with Claude Code