Skip to content

feat(provider): publish-endpoint deploys a network relay for provider services - #14193

Open
ndeloof wants to merge 7 commits into
docker:mainfrom
ndeloof:provider-relay
Open

feat(provider): publish-endpoint deploys a network relay for provider services#14193
ndeloof wants to merge 7 commits into
docker:mainfrom
ndeloof:provider-relay

Conversation

@ndeloof

@ndeloof ndeloof commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

A provider's resource lives outside the compose network: consumers could only reach it through injected variables carrying a host-published address. This adds the transparent path:

  • publish-endpoint provider message: {"type": "publish-endpoint", "message": "80=localhost:49152"} — container port consumers know on the left, real location on the right. Providers express that location naturally from the host's perspective; compose owns the translation to the relay's vantage point, rewriting host-relative addresses (localhost, any loopback IP, unspecified or empty host) to host.docker.internal — provisioned through ExtraHosts (host-gateway) so it also works on a plain Linux engine. LAN IPs and DNS names pass verbatim.
  • Relay container: when endpoints are published, compose deploys docker/compose-relay (new relay/ directory: static Go TCP forwarder, FROM scratch, bake target relay-image, COMPOSE_RELAY_IMAGE override for internal registries) in place of the service — canonical <project>-<service>-1 name, service alias on the networks of the depending services. Consumers use http://<service>:<port>, no injected variables involved.
  • First-class project container, full lifecycle: standard compose labels incl. config-hash, so label-driven commands run without the compose file (ps, logs, stop, down) keep seeing the service — plus com.docker.compose.relay (value: identity hash of image+routes) declaring its role:
    • up idempotent: relay kept when routes unchanged, recreated otherwise (reconciler already leaves provider services' containers alone);
    • down treats the relay as part of the provider service's deprovisioning: its containers are removed before the plugin removes the provider's resource — mirroring up, which provisions the resource before deploying the relay — so down -v releases the project network instead of failing on "resource is still in use";
    • exec/cp refuse a relay (no service process to act on);
    • the attached-up monitor excludes relays from the containers whose termination ends the command — they are long-lived infrastructure.

This benefits every provider: the example provider demonstrates the flow (detached HTTP helper + publish-endpoint), covered by an e2e scenario asserting http://db works from a consumer, exec db is refused, and down -v leaves no relay behind, plus unit tests (message parsing, upstream rewriting, route identity, network selection, relay guard, image override). A provider message compose cannot decode is now reported as an "invalid message" rather than an "invalid response" — providers push messages, they don't answer requests.

Standalone — no dependency on #14175 (the two protocol additions are orthogonal).

Publishing note for maintainers: docker/compose-relay is a new image to publish on Docker Hub under the docker/ organization (bake target included; release wiring to be added to CI).

🤖 Generated with Claude Code

@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.

Assessment: 🟢 APPROVE

This PR introduces a well-structured relay mechanism for provider services — the core logic in relay.go, plugins.go, and relay/main.go is sound. One low-severity issue was found in the example/demo code only.

Comment thread docs/examples/provider.go Outdated
@ndeloof
ndeloof force-pushed the provider-relay branch 2 times, most recently from c0c356c to fee8789 Compare September 6, 2026 16:48
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 61.43498% with 86 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/relay.go 55.74% 67 Missing and 10 partials ⚠️
pkg/compose/plugins.go 82.14% 4 Missing and 1 partial ⚠️
pkg/compose/cp.go 20.00% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@ndeloof
ndeloof force-pushed the provider-relay branch 3 times, most recently from fea2480 to 893cb95 Compare September 7, 2026 06:53
@ndeloof

ndeloof commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

👋 Heads up: The /review command is deprecated. Please re-request a review from docker-agent in the PR sidebar instead.

@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.

Assessment: 🟡 NEEDS ATTENTION

Two findings in the new relay code introduced by this PR.

Comment thread pkg/compose/relay.go Outdated
Comment thread relay/main.go
@ndeloof

ndeloof commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

👋 Heads up: The /review command is deprecated. Please re-request a review from docker-agent in the PR sidebar instead.

@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.

Assessment: 🟡 NEEDS ATTENTION

Two medium-severity correctness/reliability issues and two low-severity issues were found in the new relay code.

Findings summary:

  • relay/main.go: wg.Wait() does not drain active forward() connections on shutdown (medium, 🟢 strong 100/100)
  • pkg/compose/relay.go: paused relay with matching identity is force-removed instead of unpaused (medium, 🟢 strong 97/100)
  • pkg/compose/relay.go: missing existence check before project.Networks[key].Name (low, 🟡 moderate 57/100)
  • docs/examples/provider.go: bufio.ReadString has no timeout when reading serve-demo address (low, 🟢 strong 100/100)

Comment thread relay/main.go
Comment thread pkg/compose/relay.go Outdated
Comment thread pkg/compose/relay.go
Comment thread docs/examples/provider.go
@ndeloof

ndeloof commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

👋 Heads up: The /review command is deprecated. Please re-request a review from docker-agent in the PR sidebar instead.

@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.

Assessment: 🔴 CRITICAL

1 critical finding (high-severity CONFIRMED bug) and 1 minor finding (low-severity CONFIRMED).


Summary: The PR introduces a solid relay mechanism for provider services. The main correctness issue is an orphan container left behind when network attachment partially fails; the Dockerfile has a dead ARG that should be wired up.

Comment thread pkg/compose/relay.go
Comment thread relay/Dockerfile Outdated
@ndeloof

ndeloof commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

👋 Heads up: The /review command is deprecated. Please re-request a review from docker-agent in the PR sidebar instead.

@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.

Assessment: 🟡 NEEDS ATTENTION

Two medium-confidence findings in the new relay code introduced by this PR.

Comment thread relay/main.go Outdated
Comment thread pkg/compose/relay.go Outdated
@ndeloof

ndeloof commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

👋 Heads up: The /review command is deprecated. Please re-request a review from docker-agent in the PR sidebar instead.

@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.

Assessment: 🟡 NEEDS ATTENTION

One medium-severity finding: the relay guard introduced by this PR is incomplete in pkg/compose/cp.go — the default branch (common cp usage without --index) skips the checkRelayTarget check that was correctly added to the options.Index > 0 branch.

Comment thread pkg/compose/cp.go
@ndeloof

ndeloof commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

👋 Heads up: The /review command is deprecated. Please re-request a review from docker-agent in the PR sidebar instead.

@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.

🟢 No issues found — LGTM! View logs.

@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.

Assessment: 🟡 NEEDS ATTENTION

One confirmed issue found in the new relay deployment code.

Comment thread pkg/compose/plugins.go Outdated

@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.

Assessment: 🟢 APPROVE

This PR adds a well-structured network relay feature for Docker Compose provider services. The WaitGroup lifecycle in relay/main.go is correct — serve() completes all wg.Add calls before defer wg.Done() fires, so wg.Wait() in main only returns when all connection goroutines are finished. The relay container lifecycle (idempotency, state handling, label computation, network selection) and ordering in down look correct.

One minor issue in the example provider's subprocess management is noted below.

Comment thread docs/examples/provider.go

@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.

Assessment: 🟢 APPROVE

Incremental review — covers only commits since 55984e38f42b3e5edc1d218415366b595be37152.

The three changed files are a clean, focused refactoring:

  • pkg/compose/plugins.go — replaces defer mux.Unlock() with an explicit mux.Unlock() so the mutex is released before the Docker API work in ensureServiceRelay. The lock acquisition and release are on a single, straight-line path (no early returns between them), so the refactoring is safe. networkKeys is correctly computed under the lock via relayNetworks, which reads project.Services.

  • pkg/compose/relay.gorelayNetworks call removed from ensureServiceRelay; the result is now received as a parameter instead. All remaining reads of project inside this function touch only project.Name, project.Networks, project.ComposeFiles, and project.WorkingDir — none of which concurrent providers mutate — so the post-lock reads are race-free. The len(networkKeys) == 0 guard at the top of the create path correctly prevents an index-out-of-bounds on networkKeys[0].

  • docs/examples/provider.go — comment-only addition explaining the subprocess lifetime design.

No bugs found in the incremental changes.

@ndeloof
ndeloof marked this pull request as ready for review September 10, 2026 09:15
@ndeloof
ndeloof requested review from a team as code owners September 10, 2026 09:15
@ndeloof
ndeloof requested a review from glours September 10, 2026 09:15

@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.

Assessment: 🟡 NEEDS ATTENTION

One medium-severity and one low-severity finding in the new relay code.

Comment thread relay/main.go
Comment thread docs/examples/provider.go
ndeloof and others added 6 commits September 11, 2026 14:39
… services

A provider's resource lives outside the compose network: consumers could
only reach it through injected variables carrying a host-published
address — nothing like the compose-native experience of addressing a
service by name at its well-known port.

A provider may now publish where each endpoint of its resource actually
listens:

    {"type": "publish-endpoint", "message": "80=localhost:49152"}

The endpoint is announced as seen from the provider's host: the relay —
the component that knows it runs inside a container — rewrites loopback
or unspecified upstream hosts to host.docker.internal (resolved through
its injected host-gateway extra_host); routable addresses pass through.

When at least one endpoint is published, compose deploys a relay
container in place of the service: a minimal TCP forwarder (new relay/
directory, published as docker/compose-relay, overridable with
COMPOSE_RELAY_IMAGE for internal registries) joining the networks of the
services that depend on the provider service, aliased with the service
name. Consumers then use http://<service>:<port> as if the service were
a regular container.

The relay is a first-class project container — canonical name, standard
compose labels including config-hash (label-driven commands run without
the compose file keep seeing the service: ps, logs, stop, down) — plus
the com.docker.compose.relay label declaring its role:

- the reconciler already leaves provider services' containers alone, and
  the relay's identity hash (image + routes) makes up idempotent: kept
  when routes are unchanged, recreated otherwise;
- process-level commands (exec, cp) refuse a relay — there is no service
  process in it to act on;
- the up monitor excludes relays from the containers whose termination
  ends an attached up: they are long-lived infrastructure and would
  otherwise keep 'up' waiting forever.

The example provider demonstrates the flow behind PROVIDER_DEMO_ENDPOINT
(a detached helper serving a fixed HTTP response), backed by an e2e
scenario asserting the compose-native address works and exec is refused.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…internal

A provider legitimately expresses its endpoints from the host's
perspective — "localhost:5734" is where its resource listens, on the
machine compose runs on. But the relay dials from its own network
namespace, where loopback names the relay container itself: routes were
passed verbatim, so every connection died on the relay's own empty
loopback while the host.docker.internal ExtraHosts mapping provisioned
for exactly this purpose sat unused.

relayRoutesSpec now rewrites host-relative upstreams (localhost, any
loopback IP, unspecified or empty host) to host.docker.internal before
rendering; LAN IPs and DNS names still pass verbatim. The rewrite
happens before the identity hash, so existing relays carrying the old
routes are recreated on the next up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…onse

Providers push messages on their own initiative — setenv, info,
publish-endpoint… — they do not respond to anything. "invalid response
from plugin" sent users looking for a request that never existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
down handled a provider service by running the plugin alone — but the
service may own a project container, the relay deployed when it
published endpoints. Left running, it kept the project network in use
and `down -v` failed with "Resource is still in use".

The relay is now part of the service's deprovisioning: its containers
are removed first, then the plugin removes the provider's resource —
mirroring up, which provisions the resource before deploying the relay.

The example provider's down used to answer with a hardcoded error (a
leftover no test relied on): it now succeeds, with the failure
simulation kept behind PROVIDER_DOWN_FAILURE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
runPlugin held the global mutex — there to guard concurrent writes to
project.Services — across ensureServiceRelay, whose Docker API work
(list, create, image pull, start, a 30s removal wait) forced every
concurrent provider to wait on the slowest one.

The mutex now covers only the shared-state work: env-var injection plus
the relay's network selection, which reads project.Services and must
not race with another provider's writes. The relay is deployed after
the lock is released, taking the pre-computed network keys — simply
unlocking around the call, as first suggested, would have traded the
serialization for a data race on the services map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…time

The serve-demo subprocess is the demo's provisioned resource: it must
outlive the provider invocation — consumers reach it through the relay
after up returns — and it reaps itself after three minutes. Spell that
out where a reader (or reviewer) would otherwise expect a Kill/Wait.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
A peer that never closes after receiving the relayed FIN used to pin
the forward's goroutine pair and both TCP connections until SIGKILL —
and with them the drain in main. Force-closing both ends as soon as one
direction finishes would have thrown out TCP half-close (a client that
FINs its request and then reads a long response), so the surviving
direction now runs under an idle grace instead: a read deadline
re-armed before every Read once the other direction is done. Active
streams are never cut — only pairs sitting idle past the grace are
reaped. Locked by two tests: the silent-peer pair is reaped at the
grace, and a response still streaming after the client's half-close
survives well past it.

The demo provider's comment also spells out why serve-demo is neither
Wait()ed nor a zombie: the provider exits within seconds, so init has
long adopted — and reaps — the subprocess when its three minutes are up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>

@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.

Assessment: 🟢 APPROVE

This PR adds transparent relay infrastructure for provider services that publish endpoints — a clean design that correctly handles the relay lifecycle (up idempotency, down ordering, exec/cp guards, monitor exclusion). The implementation is solid overall.

Two minor correctness issues found in the new relay binary:

Comment thread relay/main.go
// persist until a descriptor is freed, and a tight loop would
// spin the CPU and flood the log.
log.Printf("accept on %s: %v", listener.Addr(), err)
time.Sleep(backoff)

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.

[low] Backoff cap overshoots intended 1s maximum — actual max sleep is 1280ms

The condition if backoff < time.Second { backoff *= 2 } fires the cap check after computing the new value, not before. Walking the doubling sequence from 5ms: 5→10→20→40→80→160→320→640→1280ms (because 640 < 1000, so it doubles to 1280; then 1280 >= 1000, so it stops). The maximum sleep is 1280ms, 28% above the apparent 1s intent.

Suggested change
time.Sleep(backoff)
if backoff < 500*time.Millisecond {
backoff *= 2
}

This caps at 1s exactly (640ms doubles to 1280ms → blocked; 320ms doubles to 640ms → allowed). Alternatively, use backoff = min(backoff*2, time.Second) (Go 1.21+, available since this module declares go 1.23).

Confidence Score
🟢 strong 100/100

Comment thread relay/main.go
// hostIsContainerLocal reports whether a host announced by the provider
// designates the provider's own host machine (loopback or unspecified) —
// unreachable under that name from inside a container.
func hostIsContainerLocal(host string) bool {

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.

[low] hostIsContainerLocal uses case-sensitive "localhost" comparison while the compose-side relayUpstream uses strings.EqualFold

relay/main.go normalizes loopback addresses in parseRoutes using hostIsContainerLocal, which checks host == "localhost" (case-sensitive). The compose side in pkg/compose/relay.go:relayUpstream uses strings.EqualFold(host, "localhost"), so it correctly handles "LOCALHOST" or "Localhost".

In the normal flow this gap is unexposed — compose always pre-rewrites upstreams before setting RELAY_ROUTES, so the relay binary never receives an unnormalized "LOCALHOST". However, a user (or a future provider) who sets RELAY_ROUTES manually, or tests the relay image directly, may encounter unexpected behaviour: RELAY_ROUTES=80=LOCALHOST:5432 would silently pass through without rewriting to host.docker.internal, causing dial failures inside the container.

Suggested change
func hostIsContainerLocal(host string) bool {
func hostIsContainerLocal(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && (ip.IsLoopback() || ip.IsUnspecified())
}
Confidence Score
🟡 moderate 57/100

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.

2 participants