Skip to content

get-relay-info tells providers where to bind for the relay - #14258

Merged
glours merged 3 commits into
docker:mainfrom
ndeloof:provider-relay-info
Sep 24, 2026
Merged

glours merged 3 commits into
docker:mainfrom
ndeloof:provider-relay-info

Conversation

@ndeloof

@ndeloof ndeloof commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

What this PR does, in one sentence

A provider running its service locally can now ask Compose where to bind its published endpoint (get-relay-info), so the relay reaches it on every platform without exposing the port on the LAN.

Closes #14257

Context

When a provider publishes an endpoint, Compose deploys a relay so dependents reach the service at its compose-native address. But the provider has to pick the address its endpoint binds, and on a standalone Linux engine no address is both relay-reachable and off the LAN: host.docker.internal resolves there to the bridge gateway, which a loopback-only listener cannot accept, while the wildcard exposes the port on every host interface. The bundled example provider works around it by binding 0.0.0.0 — trading LAN exposure for reachability — and a provider choosing loopback instead is silently limited to Docker Desktop.

What the PR brings

The approach retained: Compose owns the platform knowledge, the provider just binds what is announced. A new get-relay-info message answers with the networks the relay would join — the dependents' networks, exactly as selected for the relay deployment — each carrying the address a locally-run endpoint should bind to be reachable from the relay:

  • on a standalone engine, the network's engine-assigned gateway — an address the provider's host owns on that bridge, reachable from the relay (and local containers) but not from the LAN;
  • under Docker Desktop (detected through the engine label), 127.0.0.1 — the networks live inside the VM there, and the host's own loopback is, factually, where a host process is reached through the Desktop proxy.

Guardrails that bound the change:

  • opt-in by construction: the message is provider-initiated, and only meaningful for the subset of providers running their service locally — a provider backing the service with a remote resource (e.g. a managed database) never sends it, and pays nothing (Desktop detection and network inspects run only on request);
  • best-effort contract: a network whose address cannot be resolved is listed with the gateway field omitted — the provider falls back to a bind of its choice;
  • unknown messages stay fatal by design — a provider requiring an unsupported message must fail loudly — so Compose now announces the message types it accepts in the COMPOSE_PROVIDER_MESSAGES environment variable of the provider process, letting providers adapt instead;
  • inert for everything shipped: no existing message or relay behavior changes; the relay already passes routable upstreams verbatim and translates loopback ones, so announced addresses flow through the existing paths.

The example provider demonstrates the pattern — bind the first announced gateway, wildcard only when none is announced — which also closes the coverage gap noted in #14257: TestProviderPublishEndpoint now exercises the gateway path on Linux CI and the loopback path on Docker Desktop, instead of only ever testing a wildcard bind.

@ndeloof
ndeloof requested review from a team as code owners September 23, 2026 14:27

@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

One high-severity data race confirmed in the new GetRelayInfoType handler.

Comment thread pkg/compose/plugins.go
Comment thread docs/examples/provider.go
Comment thread docs/examples/provider.go Outdated
@codecov

codecov Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.71429% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/compose/plugins.go 66.66% 11 Missing and 6 partials ⚠️

📢 Thoughts on this report? Let us know!

A provider running its service LOCALLY has to pick the address its
published endpoint binds, and on a standalone Linux engine no address
is both relay-reachable and off the LAN (docker#14257):
host.docker.internal resolves there to the bridge gateway, which a
loopback-only listener cannot accept, while the wildcard exposes the
port on every host interface. The bundled example provider worked
around it by binding 0.0.0.0.

The new get-relay-info message closes that gap, with compose owning
the platform knowledge so the provider just binds what is announced.
Compose answers with one JSON line listing the networks the relay
would join — the dependents' networks, exactly as selected for the
relay deployment — each with the address a locally-run endpoint should
bind to be reachable from the relay: the network's engine-assigned
gateway on a standalone engine (an address the host owns on that
bridge, reachable from the relay but not from the LAN), and 127.0.0.1
under Docker Desktop — the networks live inside the VM there, and the
host's own loopback is, factually, where a host process is reached
through the Desktop proxy. Resolution is lazy (Desktop detection and
network inspects run only when a provider asks — a remote-resource
provider never does) and best-effort: a missing gateway means "bind
elsewhere".

Compose also announces the message types it accepts in the
COMPOSE_PROVIDER_MESSAGES environment variable of the provider
process: an unknown message stays a fatal protocol error by design
(a provider REQUIRING an unsupported message must fail loudly), and
the announcement is how a provider adapts instead of failing. The
example provider binds the first announced gateway, falling back to
the wildcard only when none is announced; through it,
TestProviderPublishEndpoint exercises the gateway path on Linux CI
and the loopback path on Docker Desktop.

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

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

TestExecutePlugin_GetRelayInfo and TestExecutePlugin_GetRelayInfoDesktop cover the happy path and the Desktop path, but nothing exercises relayInfo's "gateway unresolved" branch (NetworkInspect error, or an IPAM config with no valid IPv4 gateway) — the exact case the omitempty/best-effort contract in relay.go is built around. Can you add a case mocking NetworkInspect to fail (see notFoundError in executor_test.go, already used this way in observed_state_test.go:387) and asserting the network is still listed with Gateway empty? Without it, a later change to the err == nil/cfg.Gateway.IsValid() guards could start sending a wrong non-empty gateway with nothing catching it.

Address glours review on docker#14258: TestExecutePlugin_GetRelayInfo and
TestExecutePlugin_GetRelayInfoDesktop only exercised the happy path.
Add TestExecutePlugin_GetRelayInfoUnresolvedGateway to cover the
best-effort contract relay.go omitempty is built around: a
NetworkInspect failure, an IPv6-only gateway, and no gateway at all
must each still list the network with Gateway left empty.

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

ndeloof commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

@glours added in 162ebb8: TestExecutePlugin_GetRelayInfoUnresolvedGateway covers relayInfo's "gateway unresolved" branch with three subtests — NetworkInspect failing, an IPv6-only gateway, and no gateway entry at all — each asserting the network is still listed with Gateway left empty.

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

This review covers only the commits pushed since 9badbe3.

Assessment: 🟢 APPROVE

The single change in this incremental diff — the new TestExecutePlugin_GetRelayInfoUnresolvedGateway table-driven test — is well-constructed. It correctly covers all three best-effort contract branches for unresolved gateways: a failing NetworkInspect, an IPv6-only IPAM gateway, and an absent IPAM gateway. Mock expectations, variable-name assertions (RELAY_NETWORK / RELAY_GATEWAY), the len(variables.prefixed) == 2 invariant, and the subprocess helper pattern all match the production code. No bugs found in the diff.

@ndeloof
ndeloof requested a review from glours September 24, 2026 09:29

@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

Comment thread docs/examples/provider.go
Addresses docker-agent review on docker#14258 (discussion_r4092089046):
when ReadString fails after Start() succeeds, the pipe read-end was
leaked -- nothing else calls Wait() on this deliberately long-lived
subprocess. Mirrors the existing close on the Start() failure path.

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.

This review covers only the commits pushed since 162ebb8.

Assessment: 🟢 APPROVE

The single change in this incremental diff — closing the stdout pipe's read-end when ReadString fails in serveDemo — is correct and introduces no new issues.

stdout is an io.ReadCloser returned by server.StdoutPipe(). Because Wait() is intentionally never called (the subprocess outlives the function), nothing else would release the pipe's read-end file descriptor on the error path. The explicit _ = stdout.Close() cleanly plugs that leak, mirroring the identical pattern already present on the server.Start() failure path. Discarding the Close() error is appropriate since the function is already returning a more meaningful error from ReadString, and there is no double-close risk.

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

LGTM

@glours
glours merged commit bad7616 into docker:main Sep 24, 2026
65 checks passed
glours pushed a commit that referenced this pull request Sep 24, 2026
Address glours review on #14258: TestExecutePlugin_GetRelayInfo and
TestExecutePlugin_GetRelayInfoDesktop only exercised the happy path.
Add TestExecutePlugin_GetRelayInfoUnresolvedGateway to cover the
best-effort contract relay.go omitempty is built around: a
NetworkInspect failure, an IPv6-only gateway, and no gateway at all
must each still list the network with Gateway left empty.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@ndeloof
ndeloof deleted the provider-relay-info branch September 24, 2026 13:29
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.

Provider relay cannot reach loopback-bound published endpoints on a standalone Linux engine

3 participants