diff --git a/docs/extension.md b/docs/extension.md
index f4a002f0e3..175a666d3d 100644
--- a/docs/extension.md
+++ b/docs/extension.md
@@ -211,26 +211,83 @@ standalone Linux engine no address is both relay-reachable and off the LAN by de
resolves there to the bridge gateway, which a loopback-only listener cannot accept, while the wildcard exposes
the port on every host interface. (Docker Desktop has no such dilemma — its proxy reaches the host's loopback.)
-`get-relay-info` resolves this: the provider asks, and Compose answers with one JSON line listing the networks
-the relay would join — the dependents' networks, as selected for the relay deployment — each with the address a
-locally-run endpoint should bind so the relay can reach it:
+`get-relay-info` resolves this: sending it is itself the provider's declaration that it binds locally, and
+Compose reacts by creating a **dedicated relay-link network** — an `internal:true` bridge, scoped to this one
+provider service, joined by nothing but the relay container — then answers with one JSON line naming it
+alongside the address a locally-run endpoint should bind so the relay can reach it:
```json
{ "type": "get-relay-info" }
```
```json
-{"networks":[{"name":"myproject_default","gateway":"172.18.0.1"}]}
+{"networks":[{"name":"myproject_database_relay","gateway":"172.20.0.1"}]}
```
-Compose owns the platform knowledge behind that address: on a standalone engine it is the network's gateway —
-an address the provider's host owns on that network's bridge, reachable from the relay (and from local
-containers) but not from the LAN; under Docker Desktop it is `127.0.0.1` — the network lives inside the VM,
-and the host's own loopback is, factually, where a host process is reached through the Desktop proxy. The
-provider simply binds the announced gateway and publishes the endpoint exactly as bound: a routable address
-passes to the relay untouched, a loopback one is announced as `localhost` (translated to
+A provider backing a remote resource (an Amazon RDS instance, say) has no local endpoint to bind and simply
+never sends `get-relay-info`: no relay-link network is created for it, and its `publish-endpoint` reports the
+remote resource's own address unchanged.
+
+```mermaid
+sequenceDiagram
+ participant Compose
+ participant Provider
+ participant net as relay-link network
(internal, per-service)
+ participant relay as relay container
+
+ rect rgb(235, 245, 255)
+ note over Compose,net: Provider running its service locally
+ Compose->>Provider: compose up --project-name=xx "database"
+ Provider->>Compose: json { "type": "get-relay-info" }
+ Compose->>net: create (or reuse) the dedicated
relay-link network for "database"
+ Compose--)Provider: json {"networks":[{"name":"myproject_database_relay",
"gateway":"172.20.0.1"}]}
+ Provider->>Provider: bind local endpoint to 172.20.0.1
+ Provider--)Compose: json { "type": "publish-endpoint",
"message": "80=172.20.0.1:49152" }
+ Compose->>relay: deploy, join dependents' networks
AND the relay-link network
+ end
+```
+
+```mermaid
+sequenceDiagram
+ participant Compose
+ participant Provider
+ participant resource as remote resource
(e.g. Amazon RDS)
+ participant relay as relay container
+
+ rect rgb(255, 245, 235)
+ note over Compose,resource: Provider backing a remote resource
+ Compose->>Provider: compose up --project-name=xx "database"
+ Provider->>resource: provision
+ note over Provider: no local endpoint to bind:
get-relay-info is never sent
+ Provider--)Compose: json { "type": "publish-endpoint",
"message": "80=resource.example.com:5432" }
+ Compose->>relay: deploy, join dependents' networks only
(no relay-link network created)
+ end
+```
+
+Compose owns the platform knowledge behind the announced address: on a standalone engine it is the relay-link
+network's IPv4 gateway — an address the provider's host owns on that dedicated bridge, reachable from the relay
+(same-bridge local delivery) and joined by nothing else: no other container is a member of that network, and none
+of the project's service networks includes it. This is isolation by network membership, not something the
+address alone enforces: the gateway is an address of the host itself, and a plain socket bound to it restricts
+by destination address, not by the interface traffic arrives on (Linux's weak host model) — so a container on
+another bridge, or a LAN peer routing through the host, can still reach such a listener by IP. To keep the
+endpoint reachable from the relay only, a provider should bind **only** the announced gateway — never a
+wildcard or any other address — **and** pin the socket to the relay-link bridge's own network interface
+(`SO_BINDTODEVICE` on Linux), so traffic arriving on any other interface is not accepted. Without that pin,
+treat the endpoint as reachable by any local container. Under Docker
+Desktop the announced address is `127.0.0.1` — the network lives inside the VM, and the host's own
+loopback is, factually, where a host process is reached through the Desktop proxy, so no dedicated network is
+created there. The provider simply binds the announced gateway and publishes the endpoint exactly as bound: a
+routable address passes to the relay untouched, a loopback one is announced as `localhost` (translated to
`host.docker.internal`). The `gateway` field may be absent when it cannot be resolved (exotic network drivers,
-IPv6-only IPAM): fall back to a bind of your choice. Best-effort by design.
+IPv6-only IPAM): fall back to a bind of your choice. Best-effort by design. The relay-link network is removed
+along with the relay container when the service stops publishing endpoints, and by `down` like any other
+project resource.
+
+The `name` field is an opaque identifier, not a promise that a Docker network by that name exists: under Docker
+Desktop it is the literal string `"desktop"`, which no `docker network inspect` or `NetworkConnect` call will
+ever resolve. A provider must use it only for logging, never as an engine-level network reference — `gateway` is
+the only field it needs to bind and publish correctly.
## Down lifecycle
diff --git a/pkg/api/labels.go b/pkg/api/labels.go
index dc819e13c7..a45267348c 100644
--- a/pkg/api/labels.go
+++ b/pkg/api/labels.go
@@ -49,6 +49,15 @@ const (
// whether an existing relay can be kept on the next up. Commands that
// act on a service's process (exec, ...) refuse relay containers.
RelayLabel = "com.docker.compose.relay"
+ // RelayNetworkLabel marks the dedicated bridge network created for one
+ // provider-managed service's relay link — the sole channel between the
+ // relay container and the provider's own runtime (see get-relay-info).
+ // Never a project's user-declared network, and never joined by any
+ // dependent or sibling container: only the relay connects to it. That is
+ // isolation by network membership: the gateway is a host address, so a
+ // provider must also pin its bind to the bridge's interface to keep other
+ // local containers from reaching it by IP.
+ RelayNetworkLabel = "com.docker.compose.relay-network"
// SlugLabel stores unique slug used for one-off container identity
SlugLabel = "com.docker.compose.slug"
// ImageDigestLabel stores digest of the container image used to run service
diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go
index 54929dcaba..999ea11395 100644
--- a/pkg/compose/compose.go
+++ b/pkg/compose/compose.go
@@ -543,6 +543,12 @@ func (s *composeService) actualNetworks(ctx context.Context, projectName string)
actual := types.Networks{}
for _, net := range networks.Items {
+ if _, ok := net.Labels[api.RelayNetworkLabel]; ok {
+ // a provider service's dedicated relay link, never a project's
+ // own declared network: it carries no NetworkLabel key at all,
+ // which would otherwise fold it into a bogus Networks[""] entry
+ continue
+ }
actual[net.Labels[api.NetworkLabel]] = types.NetworkConfig{
Name: net.Name,
Driver: net.Driver,
diff --git a/pkg/compose/down.go b/pkg/compose/down.go
index d10ee50ca9..30a7ff08cd 100644
--- a/pkg/compose/down.go
+++ b/pkg/compose/down.go
@@ -122,6 +122,7 @@ func (s *composeService) down(ctx context.Context, projectName string, options a
}
ops := s.ensureNetworksDown(ctx, project, limiter)
+ ops = append(ops, s.ensureRelayLinkNetworksDown(ctx, project, limiter)...)
if options.Images != "" {
ops = append(ops, s.ensureImagesDown(ctx, project, options, limiter)...)
diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go
index 6c9d228e3f..af9164a371 100644
--- a/pkg/compose/down_test.go
+++ b/pkg/compose/down_test.go
@@ -95,6 +95,11 @@ func TestDown(t *testing.T) {
api.EXPECT().NetworkRemove(gomock.Any(), "abc123", gomock.Any()).Return(client.NetworkRemoveResult{}, nil)
api.EXPECT().NetworkRemove(gomock.Any(), "def456", gomock.Any()).Return(client.NetworkRemoveResult{}, nil)
+ // no relay-link network for this project (no provider service involved)
+ api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter(strings.ToLower(testProject)).Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
api.EXPECT().ContainerList(gomock.Any(), hookFilterListOpt()).Return(client.ContainerListResult{}, nil)
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{})
@@ -117,6 +122,11 @@ func TestDown_ConcurrencyIsBoundedAcrossServices(t *testing.T) {
apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).
Return(client.ContainerListResult{}, nil) // removePreStartHookContainers lookup
+ // no relay-link network for this project (no provider service involved)
+ apiClient.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter("prj").Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
apiClient.EXPECT().ContainerStop(gomock.Any(), gomock.Any(), gomock.Any()).
Return(client.ContainerStopResult{}, nil).
Times(numServices)
@@ -166,6 +176,11 @@ func TestDown_ImagePruningSharesConcurrencyBudgetAcrossOps(t *testing.T) {
{ID: "sha256:dangling2"},
}}, nil)
+ // no relay-link network for this project (no provider service involved)
+ apiClient.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter("prj").Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
tracker := &peakConcurrencyTracker{}
apiClient.EXPECT().ImageRemove(gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(context.Context, string, client.ImageRemoveOptions) (client.ImageRemoveResult, error) {
@@ -238,6 +253,11 @@ func TestDown_NetworkAndImageRemovalShareConcurrencyBudget(t *testing.T) {
apiClient.EXPECT().NetworkRemove(gomock.Any(), "net1", gomock.Any()).
Return(client.NetworkRemoveResult{}, nil)
+ // no relay-link network for this project (no provider service involved)
+ apiClient.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter("prj").Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
err := svc.down(t.Context(), "prj", compose.DownOptions{Project: project, Images: "local", RemoveOrphans: true})
assert.NilError(t, err)
assert.Assert(t, tracker.Peak() <= 2, "network- and image-removal ops must share the same concurrency budget, got peak %d", tracker.Peak())
@@ -286,6 +306,11 @@ func TestDownWithGivenServices(t *testing.T) {
api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(client.NetworkInspectResult{Network: network.Inspect{Network: network.Network{ID: "abc123"}}}, nil)
api.EXPECT().NetworkRemove(gomock.Any(), "abc123", gomock.Any()).Return(client.NetworkRemoveResult{}, nil)
+ // no relay-link network for this project (no provider service involved)
+ api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter(strings.ToLower(testProject)).Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
api.EXPECT().ContainerList(gomock.Any(), hookFilterListOpt("service1")).Return(client.ContainerListResult{}, nil)
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{
@@ -389,6 +414,11 @@ func TestDownRemoveOrphans(t *testing.T) {
}, nil)
api.EXPECT().NetworkRemove(gomock.Any(), "abc123", gomock.Any()).Return(client.NetworkRemoveResult{}, nil)
+ // no relay-link network for this project (no provider service involved)
+ api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter(strings.ToLower(testProject)).Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
api.EXPECT().ContainerList(gomock.Any(), hookFilterListOpt()).Return(client.ContainerListResult{}, nil)
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{RemoveOrphans: true})
@@ -425,6 +455,11 @@ func TestDownRemoveVolumes(t *testing.T) {
api.EXPECT().VolumeRemove(gomock.Any(), "myProject_volume", client.VolumeRemoveOptions{Force: true}).Return(client.VolumeRemoveResult{}, nil)
+ // no relay-link network for this project (no provider service involved)
+ api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter(strings.ToLower(testProject)).Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
api.EXPECT().ContainerList(gomock.Any(), hookFilterListOpt()).Return(client.ContainerListResult{}, nil)
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{Volumes: true})
@@ -505,6 +540,12 @@ func TestDownRemoveImages(t *testing.T) {
Return(client.ImageInspectResult{InspectResponse: image.InspectResponse{RepoTags: []string{"registry.example.com/remote-image-tagged:v1.0"}}}, nil).
AnyTimes()
+ // no relay-link network for this project (no provider service involved);
+ // down() runs twice in this test (--rmi=local then --rmi=all)
+ api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter(strings.ToLower(testProject)).Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil).AnyTimes()
+
localImagesToBeRemoved := []string{
"testproject-local-anonymous:latest",
"local-named-image:latest",
@@ -580,6 +621,11 @@ func TestDownRemoveImages_NoLabel(t *testing.T) {
api.EXPECT().ImageRemove(gomock.Any(), "testproject-service1:latest", client.ImageRemoveOptions{}).Return(client.ImageRemoveResult{}, nil)
+ // no relay-link network for this project (no provider service involved)
+ api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter(strings.ToLower(testProject)).Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
api.EXPECT().ContainerList(gomock.Any(), hookFilterListOpt()).Return(client.ContainerListResult{}, nil)
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{Images: "local"})
@@ -1200,6 +1246,10 @@ func TestDownRemovesRetainedPreStartHookContainers(t *testing.T) {
api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
Filters: projectFilter(strings.ToLower(testProject)),
}).Return(client.NetworkListResult{}, nil)
+ // no relay-link network for this project (no provider service involved)
+ api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter(strings.ToLower(testProject)).Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
// Hook container scan finds one retained pre_start container.
hookCtr := container.Summary{
@@ -1242,6 +1292,10 @@ func TestDownHookContainerRemovalFailureIsNonFatal(t *testing.T) {
api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
Filters: projectFilter(strings.ToLower(testProject)),
}).Return(client.NetworkListResult{}, nil)
+ // no relay-link network for this project (no provider service involved)
+ api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter(strings.ToLower(testProject)).Add("label", compose.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
// Hook scan finds one container.
hookCtr := container.Summary{
diff --git a/pkg/compose/plugins_control_test.go b/pkg/compose/plugins_control_test.go
index f1d80ec908..ccb77a41be 100644
--- a/pkg/compose/plugins_control_test.go
+++ b/pkg/compose/plugins_control_test.go
@@ -101,9 +101,12 @@ func TestHelperProviderConfig(t *testing.T) {
// TestExecutePlugin_GetRelayInfo runs executePlugin against a fake provider
// (this test binary re-executed, see TestHelperProviderRelayInfo): the
-// get-relay-info message must be answered with one JSON line listing the
-// networks the relay would join — the consumers' networks — each with its
-// engine-assigned gateway.
+// get-relay-info message must be answered with one JSON line naming the
+// service's dedicated relay-link network — created on demand — with its
+// engine-assigned gateway. Never one of the project's own declared
+// networks (proj_backend here): those are joined by the dependent too, so
+// announcing one of them would give the provider an address every sibling
+// on it can also reach.
func TestExecutePlugin_GetRelayInfo(t *testing.T) {
mockCtrl := gomock.NewController(t)
cli := mocks.NewMockCli(mockCtrl)
@@ -112,15 +115,18 @@ func TestExecutePlugin_GetRelayInfo(t *testing.T) {
svc, err := NewComposeService(cli, WithEventProcessor(noopEventProcessor{}))
assert.NilError(t, err)
- // a standalone engine: no Desktop label, the gateway comes from the
- // network's IPAM
+ // a standalone engine: no Desktop label, so relayInfo converges the
+ // dedicated relay-link network instead of announcing the host's loopback
apiClient.EXPECT().Info(gomock.Any(), gomock.Any()).Return(client.SystemInfoResult{}, nil)
+ apiClient.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
+ apiClient.EXPECT().NetworkCreate(gomock.Any(), "proj_db_relay", gomock.Any()).
+ Return(client.NetworkCreateResult{}, nil)
inspect := client.NetworkInspectResult{}
- inspect.Network.Name = "proj_backend"
+ inspect.Network.Name = "proj_db_relay"
inspect.Network.IPAM.Config = []network.IPAMConfig{
{Gateway: netip.MustParseAddr("172.18.0.1")},
}
- apiClient.EXPECT().NetworkInspect(gomock.Any(), "proj_backend", gomock.Any()).
+ apiClient.EXPECT().NetworkInspect(gomock.Any(), "proj_db_relay", gomock.Any()).
Return(inspect, nil)
// assignment style: Networks is a field promoted from the embedded
@@ -145,7 +151,7 @@ func TestExecutePlugin_GetRelayInfo(t *testing.T) {
variables, err := svc.(*composeService).executePlugin(t.Context(), project, cmd, "up", service)
assert.NilError(t, err)
- assert.Equal(t, variables.prefixed["RELAY_NETWORK"], "proj_backend")
+ assert.Equal(t, variables.prefixed["RELAY_NETWORK"], "proj_db_relay")
assert.Equal(t, variables.prefixed["RELAY_GATEWAY"], "172.18.0.1")
}
@@ -168,57 +174,73 @@ func TestHelperProviderRelayInfo(t *testing.T) {
Gateway string `json:"gateway"`
} `json:"networks"`
}
- if err := json.NewDecoder(os.Stdin).Decode(&answer); err != nil || len(answer.Networks) != 1 {
+ if err := json.NewDecoder(os.Stdin).Decode(&answer); err != nil || len(answer.Networks) > 1 {
emit(JsonMessage{Type: ErrorType, Message: "bad relay-info answer"})
os.Exit(1)
}
- emit(JsonMessage{Type: SetEnvType, Message: "RELAY_NETWORK=" + answer.Networks[0].Name})
- emit(JsonMessage{Type: SetEnvType, Message: "RELAY_GATEWAY=" + answer.Networks[0].Gateway})
+ // zero entries (the relay-link network itself could not be resolved) is
+ // a valid best-effort answer, not a protocol error — report empty
+ // values rather than failing, same as an entry with an empty gateway.
+ var name, gateway string
+ if len(answer.Networks) == 1 {
+ name, gateway = answer.Networks[0].Name, answer.Networks[0].Gateway
+ }
+ emit(JsonMessage{Type: SetEnvType, Message: "RELAY_NETWORK=" + name})
+ emit(JsonMessage{Type: SetEnvType, Message: "RELAY_GATEWAY=" + gateway})
os.Exit(0)
}
// TestExecutePlugin_GetRelayInfoUnresolvedGateway covers relayInfo's
-// best-effort contract (relay.go): when a network's gateway cannot be
-// resolved — the inspect itself fails, or the IPAM config carries no valid
-// IPv4 gateway — the network is still listed, Gateway simply left empty,
-// rather than dropped or defaulted to something wrong.
+// best-effort contract (relay.go): the relay-link network's IPAM carrying
+// no valid IPv4 gateway is not an error — the network is still announced,
+// Gateway simply left empty, rather than dropped or defaulted to
+// something wrong. Failing to resolve the network's gateway AT ALL
+// (NetworkInspect itself erroring) is the one case relayInfo cannot
+// recover a name for either, so the answer carries no entry.
func TestExecutePlugin_GetRelayInfoUnresolvedGateway(t *testing.T) {
tests := []struct {
- name string
- setup func(apiClient *mocks.MockAPIClient)
+ name string
+ setup func(apiClient *mocks.MockAPIClient)
+ wantNetwork string
+ wantEntries int
}{
{
name: "NetworkInspect fails",
setup: func(apiClient *mocks.MockAPIClient) {
- apiClient.EXPECT().NetworkInspect(gomock.Any(), "proj_backend", gomock.Any()).
+ apiClient.EXPECT().NetworkInspect(gomock.Any(), "proj_db_relay", gomock.Any()).
Return(client.NetworkInspectResult{}, notFoundError{})
},
+ wantEntries: 0,
},
{
name: "IPAM config has an IPv6-only gateway",
setup: func(apiClient *mocks.MockAPIClient) {
inspect := client.NetworkInspectResult{}
- inspect.Network.Name = "proj_backend"
+ inspect.Network.Name = "proj_db_relay"
inspect.Network.IPAM.Config = []network.IPAMConfig{
// IPv6-only: valid address, but cfg.Gateway.Is4() is false
{Gateway: netip.MustParseAddr("fe80::1")},
}
- apiClient.EXPECT().NetworkInspect(gomock.Any(), "proj_backend", gomock.Any()).
+ apiClient.EXPECT().NetworkInspect(gomock.Any(), "proj_db_relay", gomock.Any()).
Return(inspect, nil)
},
+ wantNetwork: "proj_db_relay",
+ wantEntries: 1,
},
{
name: "IPAM config has no gateway at all",
setup: func(apiClient *mocks.MockAPIClient) {
inspect := client.NetworkInspectResult{}
- inspect.Network.Name = "proj_backend"
+ inspect.Network.Name = "proj_db_relay"
inspect.Network.IPAM.Config = []network.IPAMConfig{
// zero-value Gateway: cfg.Gateway.IsValid() is false
{},
}
- apiClient.EXPECT().NetworkInspect(gomock.Any(), "proj_backend", gomock.Any()).
+ apiClient.EXPECT().NetworkInspect(gomock.Any(), "proj_db_relay", gomock.Any()).
Return(inspect, nil)
},
+ wantNetwork: "proj_db_relay",
+ wantEntries: 1,
},
}
@@ -231,8 +253,11 @@ func TestExecutePlugin_GetRelayInfoUnresolvedGateway(t *testing.T) {
svc, err := NewComposeService(cli, WithEventProcessor(noopEventProcessor{}))
assert.NilError(t, err)
- // standalone engine: falls through to the NetworkInspect path
+ // standalone engine: converges the dedicated relay-link network
apiClient.EXPECT().Info(gomock.Any(), gomock.Any()).Return(client.SystemInfoResult{}, nil)
+ apiClient.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
+ apiClient.EXPECT().NetworkCreate(gomock.Any(), "proj_db_relay", gomock.Any()).
+ Return(client.NetworkCreateResult{}, nil)
tc.setup(apiClient)
app := types.ServiceConfig{Name: "app"}
@@ -257,9 +282,8 @@ func TestExecutePlugin_GetRelayInfoUnresolvedGateway(t *testing.T) {
assert.NilError(t, err)
// exactly the two expected vars: nothing silently dropped from the answer
assert.Equal(t, len(variables.prefixed), 2)
- // the network is still announced...
- assert.Equal(t, variables.prefixed["RELAY_NETWORK"], "proj_backend")
- // ...but with no gateway to bind to
+ assert.Equal(t, variables.prefixed["RELAY_NETWORK"], tc.wantNetwork)
+ // no gateway to bind to, whether the network was announced or not
assert.Equal(t, variables.prefixed["RELAY_GATEWAY"], "")
})
}
@@ -300,6 +324,6 @@ func TestExecutePlugin_GetRelayInfoDesktop(t *testing.T) {
variables, err := svc.(*composeService).executePlugin(t.Context(), project, cmd, "up", service)
assert.NilError(t, err)
- assert.Equal(t, variables.prefixed["RELAY_NETWORK"], "proj_backend")
+ assert.Equal(t, variables.prefixed["RELAY_NETWORK"], "desktop")
assert.Equal(t, variables.prefixed["RELAY_GATEWAY"], "127.0.0.1")
}
diff --git a/pkg/compose/relay.go b/pkg/compose/relay.go
index 6a8824e511..7f05280a5a 100644
--- a/pkg/compose/relay.go
+++ b/pkg/compose/relay.go
@@ -33,6 +33,7 @@ import (
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
"github.com/sirupsen/logrus"
+ "golang.org/x/sync/semaphore"
"github.com/docker/compose/v5/pkg/api"
)
@@ -125,71 +126,257 @@ func relayNetworks(project *types.Project, service types.ServiceConfig) []string
return keys
}
-// relayInfoAnswer is the get-relay-info reply: the project networks the
-// relay standing in for the provider service would join, each with the
-// address the HOST owns on that network — its gateway. A provider running
-// its service locally can bind that address: reachable from the relay
-// (same-bridge local delivery), yet not exposed on the LAN. Networks whose
-// gateway cannot be resolved are still listed, gateway omitted.
+// relayInfoAnswer is the get-relay-info reply: the network the relay
+// reaches the provider's own runtime through, with the address the HOST owns
+// on it — its gateway. A provider running its service locally can bind that
+// address: reachable from the relay (same-bridge local delivery), and —
+// unlike a project's own bridge networks, which every service on them can
+// also reach — joined by nothing else. That is isolation by network
+// membership, not something the address alone enforces: the gateway is an
+// address of the host itself, and a plain bind restricts by destination
+// address, not by arrival interface, so a local container able to route to
+// it can still reach the listener by IP. A provider keeps the endpoint
+// relay-only by binding ONLY this address and pinning the socket to the
+// bridge's own interface (SO_BINDTODEVICE on Linux). A single entry, or
+// none when it could not be resolved (see ensureRelayLinkNetwork): a
+// provider must treat that as "bind elsewhere".
type relayInfoAnswer struct {
Networks []relayNetworkInfo `json:"networks"`
}
type relayNetworkInfo struct {
- // Name is the concrete engine-level network name.
+ // Name is the engine-level name of the dedicated relay-link network on a
+ // standalone engine. Under Docker Desktop it is the placeholder
+ // "desktop": no such network exists there, so it is only meaningful
+ // for logging, never as an engine-level network reference.
Name string `json:"name"`
// Gateway is the address the provider's host owns that the relay can
- // reach on this network: the network's IPv4 gateway on a standalone
- // engine, the host's own loopback under Docker Desktop — whose proxy
- // dials host-process endpoints through 127.0.0.1, making it factually
- // the gateway to the host from the relay's vantage point. Empty when
- // unresolved (network not created yet, driver without a host-owned
- // gateway, IPv6-only).
+ // reach on this network: the dedicated relay-link network's IPv4
+ // gateway on a standalone engine, the host's own loopback under Docker
+ // Desktop — whose proxy dials host-process endpoints through
+ // 127.0.0.1, making it factually the gateway to the host from the
+ // relay's vantage point. Empty when unresolved (network not created
+ // yet, driver without a host-owned gateway, IPv6-only).
Gateway string `json:"gateway,omitempty"`
}
// relayInfo assembles the get-relay-info answer for one provider service:
-// the same network selection the relay deployment uses (relayNetworks),
-// resolved for the address a locally-run endpoint should bind. Compose owns
-// the platform knowledge — the provider just binds what is announced.
-// Best-effort by design: a provider must treat a missing gateway as "bind
-// elsewhere".
+// the dedicated relay-link network (ensureRelayLinkNetwork) — never one of
+// the project's own bridge networks, which every other service attached to
+// them could also reach — resolved for the address a locally-run endpoint
+// should bind. Compose owns the platform knowledge — the provider just
+// binds what is announced. Best-effort by design: a provider must treat a
+// missing gateway as "bind elsewhere".
func (s *composeService) relayInfo(ctx context.Context, project *types.Project, service types.ServiceConfig) relayInfoAnswer {
answer := relayInfoAnswer{Networks: []relayNetworkInfo{}}
- // project.Services is shared state mutated by concurrent provider runs
- // (the env-var injection in runPlugin writes it under mux): the network
- // selection reads it, so it belongs under the same mutex. The Docker
- // API inspects below do not — holding the lock across them would stall
- // every concurrent provider on the slowest inspect.
- mux.Lock()
- names := make([]string, 0)
- for _, key := range relayNetworks(project, service) {
- names = append(names, project.Networks[key].Name)
- }
- mux.Unlock()
// Under Docker Desktop the networks (and their gateways) live inside
- // the VM: unreachable AND unbindable from the provider's host. The
- // address a host process binds to be reached from the relay is the
- // host's own loopback, so that is what gets announced. Detection
- // errors fall through to the inspect path — best-effort.
- desktopActive, _ := s.isDesktopIntegrationActive(ctx)
- for _, name := range names {
- info := relayNetworkInfo{Name: name}
- if desktopActive {
- info.Gateway = "127.0.0.1"
- } else if inspected, err := s.apiClient().NetworkInspect(ctx, name, client.NetworkInspectOptions{}); err == nil {
- for _, cfg := range inspected.Network.IPAM.Config {
- if cfg.Gateway.IsValid() && cfg.Gateway.Is4() {
- info.Gateway = cfg.Gateway.String()
- break
- }
- }
- }
- answer.Networks = append(answer.Networks, info)
+ // the VM: unreachable AND unbindable from the provider's host, and a
+ // dedicated bridge network would be no more exclusive than any other —
+ // every network's gateway is, from the host's side, just the Desktop
+ // proxy's own loopback. The address a host process binds to be reached
+ // from the relay is that loopback, so that is what gets announced;
+ // there is nothing to create. Detection errors fall through to the
+ // dedicated-network path — best-effort.
+ if desktopActive, _ := s.isDesktopIntegrationActive(ctx); desktopActive {
+ answer.Networks = append(answer.Networks, relayNetworkInfo{Name: "desktop", Gateway: "127.0.0.1"})
+ return answer
+ }
+ name, gateway, err := s.ensureRelayLinkNetwork(ctx, project, service)
+ if err != nil {
+ logrus.Warnf("relay link network for service %q: %v", service.Name, err)
+ return answer
+ }
+ if name == "" {
+ // removed concurrently right after being created (see
+ // ensureRelayLinkNetwork): best-effort, no entry to give — the
+ // provider's next get-relay-info retries the whole thing
+ return answer
}
+ answer.Networks = append(answer.Networks, relayNetworkInfo{Name: name, Gateway: gateway})
return answer
}
+// relayLinkNetworkName is the deterministic name of the dedicated network
+// created for one provider service's relay link — the sole channel between
+// the relay container and the provider's own runtime. Never a project's
+// user-declared network: distinguishing it structurally rules out any name
+// collision with one, and keeps its lifecycle independent of the project's
+// own declared topology (it lives and dies with the service's relay, not
+// with `up`/`down` of the whole project).
+func relayLinkNetworkName(projectName, serviceName string) string {
+ return fmt.Sprintf("%s_%s_relay", projectName, serviceName)
+}
+
+// findRelayLinkNetwork looks up a service's dedicated relay link network
+// without creating it: get-relay-info (ensureRelayLinkNetwork, below) is the
+// only place that ever creates one, because sending that message is itself
+// the provider's declaration that it binds locally and needs the address —
+// docs/extension.md: "Only meaningful for a provider running its service
+// locally; a provider backing the service with a remote resource never
+// needs it." A provider that publishes an endpoint without ever asking
+// (a remote resource, e.g. an RDS instance) must never get one conjured
+// for it just because it happened to publish something.
+func (s *composeService) findRelayLinkNetwork(ctx context.Context, projectName, serviceName string) (name string, ok bool, err error) {
+ existing, err := s.listRelayLinkNetworks(ctx, projectName, serviceName)
+ if err != nil {
+ return "", false, err
+ }
+ return relayLinkNetworkName(projectName, serviceName), len(existing) > 0, nil
+}
+
+// listRelayLinkNetworks lists the relay-link networks of one provider
+// service, or of every provider service of the project when serviceName is
+// empty. Selection is by label alone — the one criterion that still holds
+// for a project reconstructed from live containers.
+func (s *composeService) listRelayLinkNetworks(ctx context.Context, projectName, serviceName string) ([]network.Summary, error) {
+ filters := projectFilter(projectName).Add("label", api.RelayNetworkLabel)
+ if serviceName != "" {
+ filters = filters.Add("label", serviceFilter(serviceName))
+ }
+ existing, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{Filters: filters})
+ if err != nil {
+ if serviceName == "" {
+ return nil, fmt.Errorf("list relay link networks for project %s: %w", projectName, err)
+ }
+ return nil, fmt.Errorf("list relay link network for service %s: %w", serviceName, err)
+ }
+ return existing.Items, nil
+}
+
+// ensureRelayLinkNetwork converges the dedicated bridge network one provider
+// service's relay link binds to: created on first get-relay-info request,
+// reused across every later one, and carrying no traffic other than the
+// relay reaching the provider's runtime. Unlike relayNetworks (the
+// dependents' networks the relay joins to expose the compose-native
+// alias), nothing else is ever attached to this one — not a dependent, not
+// a sibling project container — so the answer has exactly one,
+// unambiguous gateway to give, on a network nothing else is a member of.
+//
+// Called only from relayInfo, in response to the provider's own
+// get-relay-info request — see findRelayLinkNetwork for why creation must
+// stay gated on that signal, not on endpoints merely being published.
+//
+// internal: true — the network never needs, or gets, outbound connectivity;
+// its only job is carrying the relay's own traffic to the address the
+// provider binds. Docker still assigns it a host-owned gateway address like
+// any other bridge network regardless of the internal flag.
+func (s *composeService) ensureRelayLinkNetwork(ctx context.Context, project *types.Project, service types.ServiceConfig) (name string, gateway string, err error) {
+ name, ok, err := s.findRelayLinkNetwork(ctx, project.Name, service.Name)
+ if err != nil {
+ return "", "", err
+ }
+ if ok {
+ gw, err := s.relayLinkNetworkGateway(ctx, name)
+ switch {
+ case err == nil:
+ return name, gw, nil
+ case errdefs.IsNotFound(err):
+ // removed concurrently between the list above and this inspect
+ // (e.g. a same-service up that just decided to stop publishing):
+ // fall through to create it, same as if it had never existed
+ default:
+ return "", "", err
+ }
+ }
+
+ if _, err := s.apiClient().NetworkCreate(ctx, name, client.NetworkCreateOptions{
+ Labels: map[string]string{
+ api.ProjectLabel: project.Name,
+ api.ServiceLabel: service.Name,
+ api.RelayNetworkLabel: "true",
+ },
+ Driver: "bridge",
+ Internal: true,
+ }); err != nil {
+ if !errdefs.IsConflict(err) {
+ return "", "", fmt.Errorf("create relay link network for service %s: %w", service.Name, err)
+ }
+ // a concurrent up for the same service creating it first is not a
+ // failure — same tolerance createNetwork already has for a
+ // project's own declared networks. But the deterministic name can
+ // also collide with an unrelated, unlabeled network (e.g. a user
+ // declaring networks: {_relay: {}}): re-check by label
+ // before inspecting by name, so a name clash is never mistaken for
+ // the relay's own network and adopted into the isolation boundary.
+ if _, ok, err := s.findRelayLinkNetwork(ctx, project.Name, service.Name); err != nil {
+ return "", "", err
+ } else if !ok {
+ return "", "", fmt.Errorf("create relay link network for service %s: a network named %q already exists and is not a relay link network", service.Name, name)
+ }
+ }
+
+ gw, err := s.relayLinkNetworkGateway(ctx, name)
+ switch {
+ case err == nil:
+ return name, gw, nil
+ case errdefs.IsNotFound(err):
+ // removed concurrently between the create (won or lost to a
+ // conflict) and this inspect, e.g. a concurrent down: best-effort,
+ // same as the ok+NotFound case above — the provider's next
+ // get-relay-info retries the whole thing
+ return "", "", nil
+ default:
+ return "", "", err
+ }
+}
+
+func (s *composeService) relayLinkNetworkGateway(ctx context.Context, idOrName string) (string, error) {
+ inspected, err := s.apiClient().NetworkInspect(ctx, idOrName, client.NetworkInspectOptions{})
+ if err != nil {
+ return "", fmt.Errorf("inspect relay link network %s: %w", idOrName, err)
+ }
+ for _, cfg := range inspected.Network.IPAM.Config {
+ if cfg.Gateway.IsValid() && cfg.Gateway.Is4() {
+ return cfg.Gateway.String(), nil
+ }
+ }
+ return "", nil
+}
+
+// removeRelayLinkNetwork removes a service's relay link network, if any —
+// the counterpart to ensureRelayLinkNetwork, called wherever the service's
+// relay itself is torn down (see removeServiceRelay) so the network never
+// outlives the relay it exists for.
+func (s *composeService) removeRelayLinkNetwork(ctx context.Context, projectName, serviceName string) error {
+ existing, err := s.listRelayLinkNetworks(ctx, projectName, serviceName)
+ if err != nil {
+ return err
+ }
+ for _, n := range existing {
+ if err := s.removeRelayLinkNetworkByID(ctx, n); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// removeRelayLinkNetworkByID is the single place a relay-link network is
+// removed, shared by the up path (removeRelayLinkNetwork) and the down path
+// (ensureRelayLinkNetworksDown). Every caller removes the relay container
+// first, but the daemon disconnects its endpoints asynchronously, so a
+// ContainerRemove that already returned can still race NetworkRemove: a
+// conflict (errdefs.IsConflict) is tolerated, left for a later up/down to
+// retry, as is the network already being gone (errdefs.IsNotFound).
+func (s *composeService) removeRelayLinkNetworkByID(ctx context.Context, n network.Summary) error {
+ eventName := "Network " + n.Name
+ s.events.On(removingEvent(eventName))
+ if _, err := s.apiClient().NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
+ switch {
+ case errdefs.IsNotFound(err):
+ s.events.On(newEvent(eventName, api.Warning, "No resource found to remove"))
+ return nil
+ case errdefs.IsConflict(err):
+ s.events.On(newEvent(eventName, api.Warning, "Resource is still in use"))
+ return nil
+ default:
+ s.events.On(errorEvent(eventName, err.Error()))
+ return fmt.Errorf("remove relay link network %s: %w", n.Name, err)
+ }
+ }
+ s.events.On(removedEvent(eventName))
+ return nil
+}
+
// ensureServiceRelay converges the relay container standing in for a provider
// service that published endpoints: consumers reach the provider's resource
// at the compose-native address (http://:) through it. The
@@ -207,6 +394,30 @@ func (s *composeService) ensureServiceRelay(ctx context.Context, project *types.
identity := relayIdentity(routes)
name := getContainerName(project.Name, service, 1)
+ if len(networkKeys) == 0 {
+ logrus.Warnf("service %q published endpoints but no service depends on it and the project has no default network; skipping relay", service.Name)
+ // a relay from a previous up (dependents have since dropped to zero)
+ // must not linger with stale network attachments, and an earlier
+ // get-relay-info in this same up may have speculatively created the
+ // link network before this outcome was known — removeServiceRelay
+ // clears both.
+ return s.removeServiceRelay(ctx, project.Name, service.Name)
+ }
+
+ // Only FOUND, never created here: a dedicated network exists only when
+ // the provider itself asked for one via get-relay-info (relayInfo owns
+ // creation — see findRelayLinkNetwork), which is the provider's own
+ // declaration that it binds locally. A provider backing the service
+ // with a remote resource (an RDS instance, say) can publish an endpoint
+ // without ever asking, and must get no network conjured for it just
+ // because it did — nothing on its side would ever use one.
+ linkNetwork := ""
+ if linkName, ok, err := s.findRelayLinkNetwork(ctx, project.Name, service.Name); err != nil {
+ return err
+ } else if ok {
+ linkNetwork = linkName
+ }
+
existing, err := s.findRelayContainer(ctx, project.Name, service.Name)
if err != nil {
return err
@@ -216,7 +427,7 @@ func (s *composeService) ensureServiceRelay(ctx context.Context, project *types.
// The identity only covers image+routes: a dependent service
// added on a new network after the relay is already up must
// still be connected, whether or not anything else changed.
- if err := s.ensureRelayNetworks(ctx, project, existing, service, networkKeys); err != nil {
+ if err := s.ensureRelayNetworks(ctx, project, existing, service, networkKeys, linkNetwork); err != nil {
return err
}
switch existing.State {
@@ -250,13 +461,8 @@ func (s *composeService) ensureServiceRelay(ctx context.Context, project *types.
}
}
- if len(networkKeys) == 0 {
- logrus.Warnf("service %q published endpoints but no service depends on it and the project has no default network; skipping relay", service.Name)
- return nil
- }
-
s.events.On(creatingEvent("Relay " + name))
- id, err := s.createRelayContainer(ctx, project, service, name, routes, identity, networkKeys)
+ id, err := s.createRelayContainer(ctx, project, service, name, routes, identity, networkKeys, linkNetwork)
if err != nil {
return err
}
@@ -268,12 +474,15 @@ func (s *composeService) ensureServiceRelay(ctx context.Context, project *types.
}
// ensureRelayNetworks connects an already up-to-date relay to any network in
-// networkKeys it isn't attached to yet. relayIdentity hashes image+routes
-// only, not network topology, so a service added later on a new network
-// leaves the relay's identity — and so the reuse decision in
-// ensureServiceRelay — unchanged; without this, the relay would silently
-// stay unreachable from that network's consumers.
-func (s *composeService) ensureRelayNetworks(ctx context.Context, project *types.Project, existing *container.Summary, service types.ServiceConfig, networkKeys []string) error {
+// networkKeys it isn't attached to yet, plus linkNetwork (empty under
+// Desktop — see ensureServiceRelay) without a service alias: nothing
+// addresses the relay by name there, it exists purely so the relay can
+// reach the provider. relayIdentity hashes image+routes only, not network
+// topology, so a service added later on a new network leaves the relay's
+// identity — and so the reuse decision in ensureServiceRelay — unchanged;
+// without this, the relay would silently stay unreachable from that
+// network's consumers (or, for linkNetwork, from the provider itself).
+func (s *composeService) ensureRelayNetworks(ctx context.Context, project *types.Project, existing *container.Summary, service types.ServiceConfig, networkKeys []string, linkNetwork string) error {
connected := map[string]bool{}
if existing.NetworkSettings != nil {
for name := range existing.NetworkSettings.Networks {
@@ -296,9 +505,51 @@ func (s *composeService) ensureRelayNetworks(ctx context.Context, project *types
return fmt.Errorf("connect relay for service %s to network %s: %w", service.Name, netName, err)
}
}
+ if linkNetwork != "" && !connected[linkNetwork] {
+ if _, err := s.apiClient().NetworkConnect(ctx, linkNetwork, client.NetworkConnectOptions{
+ Container: existing.ID,
+ }); err != nil && !errdefs.IsConflict(err) {
+ return fmt.Errorf("connect relay for service %s to its relay link network: %w", service.Name, err)
+ }
+ }
return nil
}
+// ensureRelayLinkNetworksDown returns down ops removing every provider
+// service's relay link network — down.go's counterpart to
+// ensureRelayLinkNetwork: removeServiceRelay's own network cleanup only
+// runs when a later `up` decides a relay is no longer needed, a path a
+// full `down` never takes (the relay container itself is swept by the
+// generic per-service container removal instead), so without this the
+// dedicated network would outlive the relay it was created for.
+// Looked up directly by label — not by walking project.Services and
+// checking Provider — because a project reconstructed from live containers
+// (getProjectWithResources, the path a `down` without an explicit compose
+// file takes) never repopulates Provider: nothing in a container's own
+// labels says its service declared one, so a per-service check here would
+// silently skip every service and leak the network on every such down —
+// the common case, not an edge one. One op per network, each gated on the
+// shared limiter like its siblings.
+func (s *composeService) ensureRelayLinkNetworksDown(ctx context.Context, project *types.Project, limiter *semaphore.Weighted) []downOp {
+ networks, err := s.listRelayLinkNetworks(ctx, project.Name, "")
+ if err != nil {
+ // surfaced by the op rather than aborting down: the rest of the
+ // project's cleanup is independent of this lookup
+ return []downOp{func() error { return err }}
+ }
+ ops := make([]downOp, 0, len(networks))
+ for _, n := range networks {
+ ops = append(ops, func() error {
+ if err := acquireSlot(ctx, limiter); err != nil {
+ return err
+ }
+ defer releaseSlot(limiter)
+ return s.removeRelayLinkNetworkByID(ctx, n)
+ })
+ }
+ return ops
+}
+
// waitRelayRemoved polls until the service's relay container is gone, giving
// an in-progress daemon-side removal time to release the container's name.
func (s *composeService) waitRelayRemoved(ctx context.Context, projectName, serviceName string) error {
@@ -331,22 +582,28 @@ func (s *composeService) removeServiceRelay(ctx context.Context, projectName, se
if err != nil {
return err
}
- if existing == nil {
- return nil
- }
- eventID := "Relay " + getCanonicalContainerName(*existing)
- s.events.On(removingEvent(eventID))
- if existing.State == container.StateRemoving {
- // the daemon is already removing it: a concurrent ContainerRemove
- // fails with "removal already in progress", so wait for the name
- // to free up instead
- if err := s.waitRelayRemoved(ctx, projectName, serviceName); err != nil {
- return err
+ if existing != nil {
+ eventID := "Relay " + getCanonicalContainerName(*existing)
+ s.events.On(removingEvent(eventID))
+ if existing.State == container.StateRemoving {
+ // the daemon is already removing it: a concurrent ContainerRemove
+ // fails with "removal already in progress", so wait for the name
+ // to free up instead
+ if err := s.waitRelayRemoved(ctx, projectName, serviceName); err != nil {
+ return err
+ }
+ } else if _, err := s.apiClient().ContainerRemove(ctx, existing.ID, client.ContainerRemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) {
+ return fmt.Errorf("remove stale relay for service %s: %w", serviceName, err)
}
- } else if _, err := s.apiClient().ContainerRemove(ctx, existing.ID, client.ContainerRemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) {
- return fmt.Errorf("remove stale relay for service %s: %w", serviceName, err)
+ s.events.On(removedEvent(eventID))
+ }
+ // Attempted even when no relay container exists: get-relay-info can
+ // create the link network speculatively (a provider may ask before
+ // deciding whether to publish an endpoint), leaving it orphaned if the
+ // relay itself never got deployed.
+ if err := s.removeRelayLinkNetwork(ctx, projectName, serviceName); err != nil {
+ return err
}
- s.events.On(removedEvent(eventID))
return nil
}
@@ -368,7 +625,9 @@ func (s *composeService) findRelayContainer(ctx context.Context, projectName, se
return &result.Items[0], nil
}
-func (s *composeService) createRelayContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name, routes, identity string, networkKeys []string) (string, error) {
+func (s *composeService) createRelayContainer(ctx context.Context, project *types.Project, service types.ServiceConfig,
+ name, routes, identity string, networkKeys []string, linkNetwork string,
+) (string, error) {
labels := types.Labels{
api.ProjectLabel: project.Name,
api.ServiceLabel: service.Name,
@@ -454,6 +713,17 @@ func (s *composeService) createRelayContainer(ctx context.Context, project *type
return "", fmt.Errorf("connect relay for service %s to network %s: %w", service.Name, netName, err)
}
}
+ if linkNetwork != "" {
+ // no alias: nothing addresses the relay by name on this network, it
+ // exists purely so the relay can reach the provider (see
+ // ensureRelayLinkNetwork).
+ if _, err := s.apiClient().NetworkConnect(ctx, linkNetwork, client.NetworkConnectOptions{Container: created.ID}); err != nil {
+ if _, rmErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true}); rmErr != nil {
+ logrus.Warnf("removing half-connected relay %s: %v", name, rmErr)
+ }
+ return "", fmt.Errorf("connect relay for service %s to its relay link network: %w", service.Name, err)
+ }
+ }
return created.ID, nil
}
diff --git a/pkg/compose/relay_test.go b/pkg/compose/relay_test.go
index 7743a64df1..3f3e05638a 100644
--- a/pkg/compose/relay_test.go
+++ b/pkg/compose/relay_test.go
@@ -18,6 +18,8 @@ package compose
import (
"context"
+ "errors"
+ "net/netip"
"testing"
"github.com/compose-spec/compose-go/v2/types"
@@ -26,6 +28,7 @@ import (
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
"go.uber.org/mock/gomock"
+ "golang.org/x/sync/semaphore"
"gotest.tools/v3/assert"
"github.com/docker/compose/v5/pkg/api"
@@ -134,6 +137,381 @@ func TestRelayNetworks(t *testing.T) {
assert.DeepEqual(t, relayNetworks(project, lonely), []string{"default"})
}
+// relayLinkNetworkName is deterministic and namespaced by project+service,
+// so two provider services in the same project — or the same service name
+// in two different projects — never collide.
+func TestRelayLinkNetworkName(t *testing.T) {
+ assert.Equal(t, relayLinkNetworkName("p", "db"), "p_db_relay")
+ assert.Assert(t, relayLinkNetworkName("p", "db") != relayLinkNetworkName("p", "cache"))
+ assert.Assert(t, relayLinkNetworkName("p", "db") != relayLinkNetworkName("q", "db"))
+}
+
+// actualNetworks (compose.go) indexes discovered networks by their
+// NetworkLabel value to rebuild project.Networks when down runs without an
+// explicit compose file. A relay-link network never carries that label —
+// it is never one of the project's own declared networks — so folding it
+// in the same way would corrupt the map with a bogus empty-key entry
+// instead of a real one. It must be excluded, not merely fall through with
+// an empty key.
+func TestActualNetworksExcludesRelayLinkNetwork(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{
+ {Network: network.Network{
+ Name: "p_default",
+ Labels: map[string]string{api.NetworkLabel: "default"},
+ }},
+ {Network: network.Network{
+ Name: "p_db_relay",
+ Labels: map[string]string{api.ProjectLabel: "p", api.ServiceLabel: "db", api.RelayNetworkLabel: "true"},
+ }},
+ },
+ }, nil)
+
+ networks, err := svc.actualNetworks(t.Context(), "p")
+ assert.NilError(t, err)
+ assert.Equal(t, len(networks), 1)
+ _, ok := networks["default"]
+ assert.Assert(t, ok)
+ _, ok = networks[""]
+ assert.Assert(t, !ok, "the relay-link network must not fold into a bogus empty-key entry")
+}
+
+// A second convergence for the same service must reuse the network it
+// already created — not attempt to create it again — and report the
+// gateway resolved from the EXISTING network's own inspect, not a fresh one.
+func TestEnsureRelayLinkNetworkReusesExisting(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ project := &types.Project{Name: "p"}
+ service := types.ServiceConfig{Name: "db", Provider: &types.ServiceProviderConfig{Type: "test"}}
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil)
+ inspect := client.NetworkInspectResult{}
+ inspect.Network.IPAM.Config = []network.IPAMConfig{{Gateway: netip.MustParseAddr("172.20.0.1")}}
+ apiMock.EXPECT().NetworkInspect(gomock.Any(), "p_db_relay", gomock.Any()).Return(inspect, nil)
+
+ name, gateway, err := svc.ensureRelayLinkNetwork(t.Context(), project, service)
+ assert.NilError(t, err)
+ assert.Equal(t, name, "p_db_relay")
+ assert.Equal(t, gateway, "172.20.0.1")
+}
+
+// Absent, the network is created bridge/internal, labeled for later lookup
+// (find-or-create, and removeRelayLinkNetwork's own filter), and its
+// gateway resolved from the fresh inspect.
+func TestEnsureRelayLinkNetworkCreatesWhenAbsent(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ project := &types.Project{Name: "p"}
+ service := types.ServiceConfig{Name: "db", Provider: &types.ServiceProviderConfig{Type: "test"}}
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
+ var created client.NetworkCreateOptions
+ apiMock.EXPECT().NetworkCreate(gomock.Any(), "p_db_relay", gomock.Any()).
+ DoAndReturn(func(_ context.Context, _ string, opts client.NetworkCreateOptions) (client.NetworkCreateResult, error) {
+ created = opts
+ return client.NetworkCreateResult{}, nil
+ })
+ inspect := client.NetworkInspectResult{}
+ inspect.Network.IPAM.Config = []network.IPAMConfig{{Gateway: netip.MustParseAddr("172.21.0.1")}}
+ apiMock.EXPECT().NetworkInspect(gomock.Any(), "p_db_relay", gomock.Any()).Return(inspect, nil)
+
+ name, gateway, err := svc.ensureRelayLinkNetwork(t.Context(), project, service)
+ assert.NilError(t, err)
+ assert.Equal(t, name, "p_db_relay")
+ assert.Equal(t, gateway, "172.21.0.1")
+ assert.Equal(t, created.Driver, "bridge")
+ assert.Assert(t, created.Internal)
+ assert.Equal(t, created.Labels[api.ProjectLabel], "p")
+ assert.Equal(t, created.Labels[api.ServiceLabel], "db")
+ assert.Equal(t, created.Labels[api.RelayNetworkLabel], "true")
+}
+
+// A concurrent up for the same service creating the network first is the
+// desired state, not a failure — same tolerance createNetwork already has
+// for a project's own declared networks.
+func TestEnsureRelayLinkNetworkToleratesConcurrentCreate(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ project := &types.Project{Name: "p"}
+ service := types.ServiceConfig{Name: "db", Provider: &types.ServiceProviderConfig{Type: "test"}}
+
+ gomock.InOrder(
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil),
+ apiMock.EXPECT().NetworkCreate(gomock.Any(), "p_db_relay", gomock.Any()).
+ Return(client.NetworkCreateResult{}, conflictError{}),
+ // the post-conflict label re-check: the concurrent up's network is
+ // now visible, confirming the conflict was ours to adopt
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil),
+ apiMock.EXPECT().NetworkInspect(gomock.Any(), "p_db_relay", gomock.Any()).
+ Return(client.NetworkInspectResult{}, nil),
+ )
+
+ _, _, err = svc.ensureRelayLinkNetwork(t.Context(), project, service)
+ assert.NilError(t, err)
+}
+
+// A NetworkCreate conflict on the deterministic name must not be trusted
+// blindly: if it comes from an unrelated, unlabeled network happening to
+// share the name (e.g. a user declaring networks: {db_relay: {}}) rather
+// than a concurrent up for the same service, ensureRelayLinkNetwork must
+// fail instead of adopting that network into the relay's isolation
+// boundary — the re-check by label (findRelayLinkNetwork) comes back empty.
+func TestEnsureRelayLinkNetworkConflictWithUnrelatedNetworkFails(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ project := &types.Project{Name: "p"}
+ service := types.ServiceConfig{Name: "db", Provider: &types.ServiceProviderConfig{Type: "test"}}
+
+ gomock.InOrder(
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil),
+ apiMock.EXPECT().NetworkCreate(gomock.Any(), "p_db_relay", gomock.Any()).
+ Return(client.NetworkCreateResult{}, conflictError{}),
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil),
+ )
+ // No NetworkInspect: the label re-check coming back empty must short
+ // circuit before ever inspecting the colliding network by name.
+
+ _, _, err = svc.ensureRelayLinkNetwork(t.Context(), project, service)
+ assert.ErrorContains(t, err, "already exists and is not a relay link network")
+}
+
+// removeRelayLinkNetwork is a no-op when nothing was ever created for the
+// service — no NetworkRemove call, which mockCtrl.Finish would catch as an
+// unexpected call anyway.
+func TestRemoveRelayLinkNetworkNoopWhenAbsent(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
+
+ assert.NilError(t, svc.removeRelayLinkNetwork(t.Context(), "p", "db"))
+}
+
+// Every caller removes the relay container first, but the daemon disconnects
+// its network endpoints asynchronously: NetworkRemove can still race that
+// disconnect and return a conflict. removeRelayLinkNetwork must tolerate it
+// like removeServiceRelay's other best-effort cleanup steps, not fail the
+// caller outright.
+func TestRemoveRelayLinkNetworkToleratesActiveEndpointsConflict(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil)
+ apiMock.EXPECT().NetworkRemove(gomock.Any(), "net-1", gomock.Any()).
+ Return(client.NetworkRemoveResult{}, conflictError{})
+
+ assert.NilError(t, svc.removeRelayLinkNetwork(t.Context(), "p", "db"))
+}
+
+// The network disappearing between the list and the remove (a concurrent
+// down) is the desired end state, not a failure.
+func TestRemoveRelayLinkNetworkToleratesAlreadyGone(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil)
+ apiMock.EXPECT().NetworkRemove(gomock.Any(), "net-1", gomock.Any()).
+ Return(client.NetworkRemoveResult{}, errdefs.ErrNotFound)
+
+ assert.NilError(t, svc.removeRelayLinkNetwork(t.Context(), "p", "db"))
+}
+
+// ensureRelayLinkNetworksDown removes every provider service's relay link
+// network on a full `down` — the path relay container removal itself
+// already takes generically (matched by ServiceLabel), but that a project's
+// own ensureNetworksDown never covers, since this network is never one of
+// project.Networks.
+// ensureRelayLinkNetworksDown looks up every relay-link network for the
+// project directly by label — never by walking project.Services and
+// checking Provider, which a project reconstructed from live containers
+// (docker compose down without an explicit compose file) never
+// repopulates. Services carries no Provider info at all here, on purpose:
+// this is exactly that reconstructed shape, and the cleanup must still
+// find and remove the network.
+func TestEnsureRelayLinkNetworksDown(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ project := &types.Project{
+ Name: "p",
+ Services: types.Services{
+ "db": {Name: "db"},
+ "app": {Name: "app"},
+ },
+ }
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter("p").Add("label", api.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil)
+ apiMock.EXPECT().NetworkRemove(gomock.Any(), "net-1", gomock.Any()).Return(client.NetworkRemoveResult{}, nil)
+
+ ops := svc.ensureRelayLinkNetworksDown(t.Context(), project, nil)
+ assert.Equal(t, len(ops), 1)
+ for _, op := range ops {
+ assert.NilError(t, op())
+ }
+}
+
+// With no relay-link network there is no op at all, so down's "No resource
+// found to remove" warning stays reachable for a project with nothing else.
+func TestEnsureRelayLinkNetworksDownNoNetworkNoOp(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
+
+ assert.Equal(t, len(svc.ensureRelayLinkNetworksDown(t.Context(), &types.Project{Name: "p"}, nil)), 0)
+}
+
+// A failing lookup must not abort down's other cleanup: it comes back as an
+// op carrying the error, run alongside the rest.
+func TestEnsureRelayLinkNetworksDownSurfacesListError(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).
+ Return(client.NetworkListResult{}, errors.New("daemon unavailable"))
+
+ ops := svc.ensureRelayLinkNetworksDown(t.Context(), &types.Project{Name: "p"}, nil)
+ assert.Equal(t, len(ops), 1)
+ assert.ErrorContains(t, ops[0](), "daemon unavailable")
+}
+
+// A network whose relay container removal hasn't been reflected by the
+// daemon's async disconnect yet must be left in place, not fail the whole
+// down — a later down retries and finds it gone.
+func TestEnsureRelayLinkNetworksDownToleratesStillInUse(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil)
+ apiMock.EXPECT().NetworkRemove(gomock.Any(), "net-1", gomock.Any()).
+ Return(client.NetworkRemoveResult{}, conflictError{})
+
+ ops := svc.ensureRelayLinkNetworksDown(t.Context(), &types.Project{Name: "p"}, nil)
+ assert.Equal(t, len(ops), 1)
+ assert.NilError(t, ops[0]())
+}
+
+// One op per network: a failure removing one relay-link network must not
+// stop the others from being cleaned up.
+func TestEnsureRelayLinkNetworksDownOpsAreIndependent(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{
+ {Network: network.Network{ID: "net-1", Name: "p_db_relay"}},
+ {Network: network.Network{ID: "net-2", Name: "p_cache_relay"}},
+ },
+ }, nil)
+ apiMock.EXPECT().NetworkRemove(gomock.Any(), "net-1", gomock.Any()).
+ Return(client.NetworkRemoveResult{}, errors.New("transient daemon error"))
+ apiMock.EXPECT().NetworkRemove(gomock.Any(), "net-2", gomock.Any()).Return(client.NetworkRemoveResult{}, nil)
+
+ ops := svc.ensureRelayLinkNetworksDown(t.Context(), &types.Project{Name: "p"}, nil)
+ assert.Equal(t, len(ops), 2)
+ assert.ErrorContains(t, ops[0](), "transient daemon error")
+ assert.NilError(t, ops[1]())
+}
+
+// Like its siblings, every relay-link network removal gates itself on the
+// shared down limiter: with no slot available, the op never reaches the
+// engine.
+func TestEnsureRelayLinkNetworksDownHonorsLimiter(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil)
+
+ limiter := semaphore.NewWeighted(1)
+ assert.NilError(t, limiter.Acquire(t.Context(), 1))
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+
+ ops := svc.ensureRelayLinkNetworksDown(ctx, &types.Project{Name: "p"}, limiter)
+ assert.Equal(t, len(ops), 1)
+ assert.ErrorIs(t, ops[0](), context.Canceled)
+}
+
// ensureServiceRelay runs concurrently per provider service under the shared
// project mutex released before Docker API work: another goroutine may
// connect the relay to the same network in the window after our
@@ -157,7 +535,7 @@ func TestEnsureRelayNetworksTreatsAlreadyConnectedAsSuccess(t *testing.T) {
apiMock.EXPECT().NetworkConnect(gomock.Any(), "p_frontend", gomock.Any()).
Return(client.NetworkConnectResult{}, conflictError{})
- err = svc.ensureRelayNetworks(t.Context(), project, existing, service, []string{"frontend"})
+ err = svc.ensureRelayNetworks(t.Context(), project, existing, service, []string{"frontend"}, "")
assert.NilError(t, err)
}
@@ -195,7 +573,7 @@ func TestEnsureRelayNetworksConnectsOnlyMissingNetworks(t *testing.T) {
EndpointConfig: &network.EndpointSettings{Aliases: []string{"db"}},
}).Return(client.NetworkConnectResult{}, nil)
- err = svc.ensureRelayNetworks(t.Context(), project, existing, service, []string{"backend", "frontend"})
+ err = svc.ensureRelayNetworks(t.Context(), project, existing, service, []string{"backend", "frontend"}, "")
assert.NilError(t, err)
}
@@ -234,17 +612,98 @@ func TestEnsureServiceRelayConnectsMissingNetworkWithoutRecreating(t *testing.T)
},
}
+ // the provider already asked get-relay-info earlier in this up (a local
+ // binding), so its dedicated network already exists: ensureServiceRelay
+ // only looks it up, never creates one itself (see findRelayLinkNetwork)
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil)
+
apiMock.EXPECT().ContainerList(gomock.Any(), gomock.Any()).
Return(client.ContainerListResult{Items: []container.Summary{existing}}, nil)
apiMock.EXPECT().NetworkConnect(gomock.Any(), "p_frontend", client.NetworkConnectOptions{
Container: "relay-1",
EndpointConfig: &network.EndpointSettings{Aliases: []string{"db"}},
}).Return(client.NetworkConnectResult{}, nil)
+ apiMock.EXPECT().NetworkConnect(gomock.Any(), "p_db_relay", client.NetworkConnectOptions{
+ Container: "relay-1",
+ }).Return(client.NetworkConnectResult{}, nil)
err = svc.ensureServiceRelay(t.Context(), project, db, endpoints, []string{"backend", "frontend"})
assert.NilError(t, err)
}
+// A provider backing the service with a remote resource (an RDS instance,
+// say) publishes an endpoint without ever having sent get-relay-info: no
+// dedicated network exists for it, and ensureServiceRelay must not conjure
+// one just because an endpoint was published — connecting the relay to the
+// dependents' networks is the only thing it does here. No NetworkCreate,
+// and the only NetworkConnect is to p_frontend — mockCtrl.Finish would
+// catch either an unwanted create or an unwanted link-network connect.
+func TestEnsureServiceRelaySkipsLinkNetworkForRemoteProvider(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ project := &types.Project{
+ Name: "p",
+ Networks: types.Networks{"frontend": {Name: "p_frontend"}},
+ }
+ db := types.ServiceConfig{Name: "db", Provider: &types.ServiceProviderConfig{Type: "test"}}
+ endpoints := map[int]string{5432: "rds-instance.us-east-1.rds.amazonaws.com:5432"}
+ identity := relayIdentity(relayRoutesSpec(endpoints))
+
+ existing := container.Summary{
+ ID: "relay-1",
+ State: container.StateRunning,
+ Labels: map[string]string{api.RelayLabel: identity},
+ }
+
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
+ apiMock.EXPECT().ContainerList(gomock.Any(), gomock.Any()).
+ Return(client.ContainerListResult{Items: []container.Summary{existing}}, nil)
+ apiMock.EXPECT().NetworkConnect(gomock.Any(), "p_frontend", client.NetworkConnectOptions{
+ Container: "relay-1",
+ EndpointConfig: &network.EndpointSettings{Aliases: []string{"db"}},
+ }).Return(client.NetworkConnectResult{}, nil)
+
+ err = svc.ensureServiceRelay(t.Context(), project, db, endpoints, []string{"frontend"})
+ assert.NilError(t, err)
+}
+
+// Dependents dropping to zero while a relay from a previous up is still
+// running must tear that relay down, not merely skip creating a new one:
+// leaving it running would strand it with stale network attachments and
+// routes forever (regression: the zero-networkKeys early return used to run
+// before the existing-relay lookup, so it never reached the stale container).
+func TestEnsureServiceRelayRemovesExistingWhenNoDependents(t *testing.T) {
+ mockCtrl := gomock.NewController(t)
+ defer mockCtrl.Finish()
+ apiMock, cli := prepareMocks(mockCtrl)
+ tested, err := NewComposeService(cli)
+ assert.NilError(t, err)
+ svc := tested.(*composeService)
+
+ project := &types.Project{Name: "p"}
+ db := types.ServiceConfig{Name: "db", Provider: &types.ServiceProviderConfig{Type: "test"}}
+ endpoints := map[int]string{80: "host.docker.internal:49152"}
+
+ apiMock.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{
+ Items: []container.Summary{{ID: "relay-1", Names: []string{"/p-db-1"}}},
+ }, nil)
+ apiMock.EXPECT().ContainerRemove(gomock.Any(), "relay-1", client.ContainerRemoveOptions{Force: true}).
+ Return(client.ContainerRemoveResult{}, nil)
+ apiMock.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter("p").Add("label", serviceFilter("db")).Add("label", api.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
+
+ err = svc.ensureServiceRelay(t.Context(), project, db, endpoints, nil)
+ assert.NilError(t, err)
+}
+
// Process-level commands refuse relay containers: there is no service
// process in them to act on.
func TestCheckRelayTarget(t *testing.T) {
@@ -273,6 +732,11 @@ func TestRemoveServiceRelayRemovesExisting(t *testing.T) {
}, nil)
apiMock.EXPECT().ContainerRemove(gomock.Any(), "relay-1", client.ContainerRemoveOptions{Force: true}).
Return(client.ContainerRemoveResult{}, nil)
+ // removeServiceRelay always also tries the relay-link network, even
+ // when nothing is left connected to it.
+ apiMock.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{
+ Filters: projectFilter("p").Add("label", serviceFilter("db")).Add("label", api.RelayNetworkLabel),
+ }).Return(client.NetworkListResult{}, nil)
assert.NilError(t, svc.removeServiceRelay(t.Context(), "p", "db"))
}
@@ -294,6 +758,13 @@ func TestCreateRelayContainerDropsCapabilities(t *testing.T) {
}
db := types.ServiceConfig{Name: "db", Provider: &types.ServiceProviderConfig{Type: "test"}}
+ // the provider already asked get-relay-info earlier in this up, so its
+ // dedicated network already exists: createRelayContainer connects the
+ // fresh container to it once created.
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{
+ Items: []network.Summary{{Network: network.Network{ID: "net-1", Name: "p_db_relay"}}},
+ }, nil)
+
apiMock.EXPECT().ContainerList(gomock.Any(), gomock.Any()).
Return(client.ContainerListResult{}, nil)
@@ -303,6 +774,9 @@ func TestCreateRelayContainerDropsCapabilities(t *testing.T) {
got = opts
return client.ContainerCreateResult{ID: "relay-1"}, nil
})
+ apiMock.EXPECT().NetworkConnect(gomock.Any(), "p_db_relay", client.NetworkConnectOptions{
+ Container: "relay-1",
+ }).Return(client.NetworkConnectResult{}, nil)
apiMock.EXPECT().ContainerStart(gomock.Any(), "relay-1", gomock.Any()).
Return(client.ContainerStartResult{}, nil)
@@ -325,6 +799,10 @@ func TestRemoveServiceRelayNoopWhenNoneExists(t *testing.T) {
svc := tested.(*composeService)
apiMock.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{}, nil)
+ // get-relay-info can create the link network speculatively before the
+ // relay itself ever deploys: removeServiceRelay must still try to clean
+ // it up even when no relay container ever existed.
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
assert.NilError(t, svc.removeServiceRelay(t.Context(), "p", "db"))
}
@@ -345,6 +823,7 @@ func TestRemoveServiceRelayIgnoresNotFound(t *testing.T) {
}, nil)
apiMock.EXPECT().ContainerRemove(gomock.Any(), "relay-1", client.ContainerRemoveOptions{Force: true}).
Return(client.ContainerRemoveResult{}, errdefs.ErrNotFound.WithMessage("already removed"))
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
assert.NilError(t, svc.removeServiceRelay(t.Context(), "p", "db"))
}
@@ -364,6 +843,7 @@ func TestRemoveServiceRelayWaitsWhenAlreadyRemoving(t *testing.T) {
Items: []container.Summary{{ID: "relay-1", Names: []string{"/p-db-1"}, State: container.StateRemoving}},
}, nil)
apiMock.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{}, nil).After(first)
+ apiMock.EXPECT().NetworkList(gomock.Any(), gomock.Any()).Return(client.NetworkListResult{}, nil)
assert.NilError(t, svc.removeServiceRelay(t.Context(), "p", "db"))
}