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")) }