mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
feat(compose): dedicated network for the provider relay link
get-relay-info answers pointed a locally-bound provider at one of the dependents' own networks: shared infrastructure every other service on it could also reach, and no more isolated than any project bridge. Provision a dedicated, internal:true bridge network scoped to that one provider service instead, created only when the provider itself sends get-relay-info -- the provider's own declaration that it binds locally, never for a provider backing a remote resource. The relay container joins it alongside the dependents' networks it already joins; it is torn down with the relay on endpoint changes and on down, including the no-compose-file down path. The network's lifecycle is hardened against the races a shared, project-scoped resource runs into: - a NetworkCreate conflict on the deterministic name re-checks by label (not just by name) before adopting the existing network, so a genuine name clash with an unrelated, user-declared network fails loudly instead of being silently adopted into the relay's isolation boundary; - a NotFound gateway lookup after losing that conflict falls back to an unresolved answer instead of propagating the error; - removeRelayLinkNetwork and ensureRelayLinkNetworksDown both tolerate a NetworkRemove conflict from the daemon's asynchronous endpoint disconnect, which can race the relay container's own removal; - ensureRelayLinkNetworksDown accumulates failures with errors.Join across every relay-link network of a project instead of aborting on the first one, and skips (warn-only) a network that still has active endpoints, mirroring removeNetwork's existing tolerance for regular project networks. docs/extension.md documents that the "desktop" network name in the get-relay-info answer (under Docker Desktop) is an opaque placeholder, never a real Docker network a provider can inspect or connect to. Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
committed by
Guillaume Lours
parent
803796eeee
commit
27c9a231ea
+60
-11
@@ -211,26 +211,75 @@ 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<br/>(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<br/>relay-link network for "database"
|
||||
Compose--)Provider: json {"networks":[{"name":"myproject_database_relay",<br/>"gateway":"172.20.0.1"}]}
|
||||
Provider->>Provider: bind local endpoint to 172.20.0.1
|
||||
Provider--)Compose: json { "type": "publish-endpoint",<br/>"message": "80=172.20.0.1:49152" }
|
||||
Compose->>relay: deploy, join dependents' networks<br/>AND the relay-link network
|
||||
end
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Compose
|
||||
participant Provider
|
||||
participant resource as remote resource<br/>(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:<br/>get-relay-info is never sent
|
||||
Provider--)Compose: json { "type": "publish-endpoint",<br/>"message": "80=resource.example.com:5432" }
|
||||
Compose->>relay: deploy, join dependents' networks only<br/>(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) but joined by nothing else, so never reachable from the LAN or from a project's own
|
||||
service networks; 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, 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
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ 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.
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)...)
|
||||
|
||||
if options.Images != "" {
|
||||
ops = append(ops, s.ensureImagesDown(ctx, project, options, limiter)...)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+318
-74
@@ -20,6 +20,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
@@ -125,12 +126,14 @@ 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. 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"`
|
||||
}
|
||||
@@ -139,57 +142,201 @@ type relayNetworkInfo struct {
|
||||
// Name is the concrete engine-level network name.
|
||||
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) {
|
||||
name = relayLinkNetworkName(projectName, serviceName)
|
||||
filters := projectFilter(projectName).Add("label", serviceFilter(serviceName)).Add("label", api.RelayNetworkLabel)
|
||||
existing, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("list relay link network for service %s: %w", serviceName, err)
|
||||
}
|
||||
return name, len(existing.Items) > 0, 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, exclusive gateway to give.
|
||||
//
|
||||
// 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: {<service>_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. Every caller removes the relay container
|
||||
// first, so the network is not expected to still have endpoints attached —
|
||||
// but the daemon disconnects them asynchronously, so a ContainerRemove that
|
||||
// already returned can still race NetworkRemove here; this tolerates that
|
||||
// (errdefs.IsConflict) the same way ensureRelayLinkNetworksDown does for the
|
||||
// down path, besides the network already being gone (errdefs.IsNotFound).
|
||||
func (s *composeService) removeRelayLinkNetwork(ctx context.Context, projectName, serviceName string) error {
|
||||
filters := projectFilter(projectName).Add("label", serviceFilter(serviceName)).Add("label", api.RelayNetworkLabel)
|
||||
existing, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list relay link network for service %s: %w", serviceName, err)
|
||||
}
|
||||
for _, n := range existing.Items {
|
||||
if _, err := s.apiClient().NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
if errdefs.IsConflict(err) {
|
||||
logrus.Warnf("relay link network %s still has active endpoints, skipping removal", n.Name)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("remove relay link network for service %s: %w", serviceName, err)
|
||||
}
|
||||
}
|
||||
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://<service>:<port>) through it. The
|
||||
@@ -207,6 +354,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 +387,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 +421,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 +434,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 +465,65 @@ 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.
|
||||
func (s *composeService) ensureRelayLinkNetworksDown(ctx context.Context, project *types.Project) []downOp {
|
||||
return []downOp{func() error {
|
||||
filters := projectFilter(project.Name).Add("label", api.RelayNetworkLabel)
|
||||
networks, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list relay link networks for project %s: %w", project.Name, err)
|
||||
}
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
// one project can have several provider services, each with its
|
||||
// own relay-link network: a transient inspect/remove failure on
|
||||
// one must not leave the rest unprocessed, unlike a single
|
||||
// return would.
|
||||
inspected, err := s.apiClient().NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if errdefs.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("inspect relay link network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
if len(inspected.Network.Containers) > 0 {
|
||||
// the daemon's async disconnect of the just-removed relay
|
||||
// container hasn't caught up yet; a later down retries
|
||||
logrus.Warnf("relay link network %s is still in use, skipping removal", n.Name)
|
||||
continue
|
||||
}
|
||||
if _, err := s.apiClient().NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !errdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("remove relay link network %s: %w", n.Name, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}}
|
||||
}
|
||||
|
||||
// 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 +556,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 +599,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 +687,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
|
||||
}
|
||||
|
||||
|
||||
+418
-3
@@ -18,6 +18,8 @@ package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
@@ -134,6 +136,321 @@ 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"))
|
||||
}
|
||||
|
||||
// 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().NetworkInspect(gomock.Any(), "net-1", gomock.Any()).
|
||||
Return(client.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Name: "p_db_relay"}}}, nil)
|
||||
apiMock.EXPECT().NetworkRemove(gomock.Any(), "net-1", gomock.Any()).Return(client.NetworkRemoveResult{}, nil)
|
||||
|
||||
ops := svc.ensureRelayLinkNetworksDown(t.Context(), project)
|
||||
assert.Equal(t, len(ops), 1)
|
||||
for _, op := range ops {
|
||||
assert.NilError(t, op())
|
||||
}
|
||||
}
|
||||
|
||||
// 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 TestEnsureRelayLinkNetworksDownSkipsStillInUse(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"}
|
||||
|
||||
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(), "net-1", gomock.Any()).Return(client.NetworkInspectResult{
|
||||
Network: network.Inspect{
|
||||
Network: network.Network{Name: "p_db_relay"},
|
||||
Containers: map[string]network.EndpointResource{
|
||||
"relay-1": {Name: "p-db-1"},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
ops := svc.ensureRelayLinkNetworksDown(t.Context(), project)
|
||||
assert.Equal(t, len(ops), 1)
|
||||
for _, op := range ops {
|
||||
assert.NilError(t, op())
|
||||
}
|
||||
}
|
||||
|
||||
// A transient inspect failure on one project's relay-link network must not
|
||||
// stop the others from being cleaned up: each is independent.
|
||||
func TestEnsureRelayLinkNetworksDownContinuesPastInspectError(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"}
|
||||
|
||||
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().NetworkInspect(gomock.Any(), "net-1", gomock.Any()).
|
||||
Return(client.NetworkInspectResult{}, errors.New("transient daemon error"))
|
||||
apiMock.EXPECT().NetworkInspect(gomock.Any(), "net-2", gomock.Any()).
|
||||
Return(client.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Name: "p_cache_relay"}}}, nil)
|
||||
apiMock.EXPECT().NetworkRemove(gomock.Any(), "net-2", gomock.Any()).Return(client.NetworkRemoveResult{}, nil)
|
||||
|
||||
ops := svc.ensureRelayLinkNetworksDown(t.Context(), project)
|
||||
assert.Equal(t, len(ops), 1)
|
||||
for _, op := range ops {
|
||||
assert.ErrorContains(t, op(), "transient daemon error")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +474,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 +512,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,6 +551,57 @@ 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{
|
||||
@@ -241,7 +609,35 @@ func TestEnsureServiceRelayConnectsMissingNetworkWithoutRecreating(t *testing.T)
|
||||
EndpointConfig: &network.EndpointSettings{Aliases: []string{"db"}},
|
||||
}).Return(client.NetworkConnectResult{}, nil)
|
||||
|
||||
err = svc.ensureServiceRelay(t.Context(), project, db, endpoints, []string{"backend", "frontend"})
|
||||
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(), gomock.Any()).Return(client.NetworkListResult{}, nil)
|
||||
|
||||
err = svc.ensureServiceRelay(t.Context(), project, db, endpoints, nil)
|
||||
assert.NilError(t, err)
|
||||
}
|
||||
|
||||
@@ -273,6 +669,9 @@ 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(), gomock.Any()).Return(client.NetworkListResult{}, nil)
|
||||
|
||||
assert.NilError(t, svc.removeServiceRelay(t.Context(), "p", "db"))
|
||||
}
|
||||
@@ -294,6 +693,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 +709,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 +734,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 +758,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 +778,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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user