diff --git a/pkg/compose/create.go b/pkg/compose/create.go index cc0b4afbc..0ea3368c9 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -368,10 +368,13 @@ func (s *composeService) prepareContainerMACAddress(service types.ServiceConfig, return nil } -func getAliases(project *types.Project, service types.ServiceConfig, serviceIndex int, cfg *types.ServiceNetworkConfig, useNetworkAliases bool) []string { +func getAliases(project *types.Project, service types.ServiceConfig, serviceIndex int, networkKey string, cfg *types.ServiceNetworkConfig, useNetworkAliases bool) []string { aliases := []string{getContainerName(project.Name, service, serviceIndex)} if useNetworkAliases { - aliases = append(aliases, service.Name) + // service name is not registered as an alias on external networks; see warnExternalNetworkAliases + if n := project.Networks[networkKey]; !n.External { + aliases = append(aliases, service.Name) + } if cfg != nil { aliases = append(aliases, cfg.Aliases...) } @@ -445,7 +448,7 @@ func createEndpointSettings(p *types.Project, service types.ServiceConfig, servi } return &network.EndpointSettings{ - Aliases: getAliases(p, service, serviceIndex, config, useNetworkAliases), + Aliases: getAliases(p, service, serviceIndex, networkKey, config, useNetworkAliases), Links: links, IPAddress: ipv4Address, IPv6Gateway: ipv6Address, diff --git a/pkg/compose/create_test.go b/pkg/compose/create_test.go index e08e4227d..7c3c2b730 100644 --- a/pkg/compose/create_test.go +++ b/pkg/compose/create_test.go @@ -365,6 +365,26 @@ func TestCreateEndpointSettings(t *testing.T) { }, cmpopts.EquateComparable(netip.Addr{}))) } +func TestCreateEndpointSettings_ExternalNetwork(t *testing.T) { + eps, err := createEndpointSettings(&composetypes.Project{ + Name: "projName", + Networks: composetypes.Networks{ + "netName": {External: true}, + }, + }, composetypes.ServiceConfig{ + Name: "serviceName", + ContainerName: "containerName", + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "netName": { + Aliases: []string{"alias1"}, + }, + }, + }, 0, "netName", nil, true) + assert.NilError(t, err) + assert.Check(t, cmp.DeepEqual(eps.Aliases, []string{"containerName", "alias1"}), + "service name must not be added as alias on external networks") +} + func Test_buildContainerVolumes(t *testing.T) { pwd, err := os.Getwd() assert.NilError(t, err) diff --git a/pkg/compose/up.go b/pkg/compose/up.go index b2bcb3f7d..8cfccede8 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -23,6 +23,7 @@ import ( "os" "os/signal" "slices" + "strings" "sync" "sync/atomic" "syscall" @@ -42,6 +43,7 @@ import ( ) func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { //nolint:gocyclo + warnExternalNetworkAliases(project) err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { err := s.create(ctx, project, options.Create) if err != nil { @@ -314,3 +316,35 @@ func shouldFollowStartEvent(event api.ContainerEvent, attached []string, attachT } return true } + +// warnExternalNetworkAliases emits one warning per external network the project is connected to, listing the services +// whose service-name alias is intentionally not registered (see getAliases). Services that already include their own +// name under the network's explicit aliases are excluded, so the warning disappears once the user adopts the +// workaround. Must be called before Run() to avoid interleaving with the TUI progress display. +func warnExternalNetworkAliases(project *types.Project) { + byNetwork := map[string][]string{} + for _, service := range project.Services { + for networkKey, cfg := range service.Networks { + n := project.Networks[networkKey] + if !n.External { + continue + } + if cfg != nil && slices.Contains(cfg.Aliases, service.Name) { + continue + } + byNetwork[networkKey] = append(byNetwork[networkKey], service.Name) + } + } + networks := make([]string, 0, len(byNetwork)) + for k := range byNetwork { + networks = append(networks, k) + } + slices.Sort(networks) + for _, networkKey := range networks { + services := byNetwork[networkKey] + slices.Sort(services) + logrus.Warnf("service names [%s] not registered as aliases on external "+ + "network %q; add them under each service's networks.%s.aliases if needed", + strings.Join(services, ", "), networkKey, networkKey) + } +} diff --git a/pkg/compose/up_test.go b/pkg/compose/up_test.go index f38a9e1ae..842d7d40c 100644 --- a/pkg/compose/up_test.go +++ b/pkg/compose/up_test.go @@ -17,8 +17,12 @@ package compose import ( + "bytes" + "strings" "testing" + composetypes "github.com/compose-spec/compose-go/v2/types" + "github.com/sirupsen/logrus" "gotest.tools/v3/assert" "github.com/docker/compose/v5/pkg/api" @@ -102,3 +106,132 @@ func TestShouldFollowStartEvent(t *testing.T) { }) } } + +func TestWarnExternalNetworkAliases(t *testing.T) { + tests := []struct { + name string + project *composetypes.Project + expected []string + notExpected []string + }{ + { + name: "internal-only network emits no warning", + project: &composetypes.Project{ + Networks: composetypes.Networks{"internal": {}}, + Services: composetypes.Services{ + "web": { + Name: "web", + Networks: map[string]*composetypes.ServiceNetworkConfig{"internal": nil}, + }, + }, + }, + notExpected: []string{"not registered as aliases"}, + }, + { + name: "external network emits one warning listing services in sorted order", + project: &composetypes.Project{ + Networks: composetypes.Networks{"shared": {External: true}}, + Services: composetypes.Services{ + "web": { + Name: "web", + Networks: map[string]*composetypes.ServiceNetworkConfig{"shared": nil}, + }, + "db": { + Name: "db", + Networks: map[string]*composetypes.ServiceNetworkConfig{"shared": nil}, + }, + }, + }, + expected: []string{ + `service names [db, web] not registered as aliases on external network`, + `networks.shared.aliases`, + }, + }, + { + name: "service with explicit self-alias is excluded from the warning", + project: &composetypes.Project{ + Networks: composetypes.Networks{"shared": {External: true}}, + Services: composetypes.Services{ + "web": { + Name: "web", + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "shared": {Aliases: []string{"web"}}, + }, + }, + "db": { + Name: "db", + Networks: map[string]*composetypes.ServiceNetworkConfig{"shared": nil}, + }, + }, + }, + expected: []string{ + `service names [db] not registered as aliases on external network`, + `networks.shared.aliases`, + }, + notExpected: []string{"web"}, + }, + { + name: "all services explicitly self-aliased emits no warning", + project: &composetypes.Project{ + Networks: composetypes.Networks{"shared": {External: true}}, + Services: composetypes.Services{ + "web": { + Name: "web", + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "shared": {Aliases: []string{"web"}}, + }, + }, + }, + }, + notExpected: []string{"not registered as aliases"}, + }, + { + name: "multiple external networks each emit their own warning", + project: &composetypes.Project{ + Networks: composetypes.Networks{ + "sharedA": {External: true}, + "sharedB": {External: true}, + }, + Services: composetypes.Services{ + "web": { + Name: "web", + Networks: map[string]*composetypes.ServiceNetworkConfig{ + "sharedA": nil, + "sharedB": nil, + }, + }, + }, + }, + expected: []string{ + `networks.sharedA.aliases`, + `networks.sharedB.aliases`, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + origOut := logrus.StandardLogger().Out + origLevel := logrus.GetLevel() + origFormatter := logrus.StandardLogger().Formatter + logrus.SetOutput(&buf) + logrus.SetLevel(logrus.WarnLevel) + logrus.SetFormatter(&logrus.TextFormatter{DisableColors: true, DisableTimestamp: true}) + t.Cleanup(func() { + logrus.SetOutput(origOut) + logrus.SetLevel(origLevel) + logrus.SetFormatter(origFormatter) + }) + + warnExternalNetworkAliases(tt.project) + + out := buf.String() + for _, s := range tt.expected { + assert.Assert(t, strings.Contains(out, s), "expected %q in output:\n%s", s, out) + } + for _, s := range tt.notExpected { + assert.Assert(t, !strings.Contains(out, s), "did not expect %q in output:\n%s", s, out) + } + }) + } +} diff --git a/pkg/e2e/fixtures/network-external-alias/compose.yaml b/pkg/e2e/fixtures/network-external-alias/compose.yaml new file mode 100644 index 000000000..9b29b619a --- /dev/null +++ b/pkg/e2e/fixtures/network-external-alias/compose.yaml @@ -0,0 +1,21 @@ +networks: + default: {} + external-net: + external: true + name: ${EXTERNAL_NETWORK} + +services: + web: + image: busybox + command: sleep infinity + networks: + - default + - external-net + db: + image: busybox + command: sleep infinity + networks: + default: {} + external-net: + aliases: + - db diff --git a/pkg/e2e/networks_test.go b/pkg/e2e/networks_test.go index d4fbc3659..3d65be8dc 100644 --- a/pkg/e2e/networks_test.go +++ b/pkg/e2e/networks_test.go @@ -219,3 +219,63 @@ func TestNetworkRecreate(t *testing.T) { t.Fatalf("unexpected output, missing expected events, stderr: %s", err) } } + +func TestExternalNetworkAliases(t *testing.T) { + const projectName = "network_external_alias_e2e" + const externalNet = projectName + "_external" + + c := NewParallelCLI(t, WithEnv("EXTERNAL_NETWORK="+externalNet)) + + c.RunDockerOrExitError(t, "network", "rm", externalNet) + c.RunDockerCmd(t, "network", "create", externalNet) + t.Cleanup(func() { + c.cleanupWithDown(t, projectName) + c.RunDockerOrExitError(t, "network", "rm", externalNet) + }) + + upRes := c.RunDockerComposeCmd(t, "-f", "./fixtures/network-external-alias/compose.yaml", + "--project-name", projectName, + "up", "-d") + + internalNet := projectName + "_default" + + t.Run("warning lists services without explicit external-net alias and excludes self-aliased ones", func(t *testing.T) { + var warningLine string + for line := range strings.SplitSeq(upRes.Combined(), "\n") { + if strings.Contains(line, `not registered as aliases on external network`) { + warningLine = line + break + } + } + assert.Assert(t, warningLine != "", "expected warning line in output:\n%s", upRes.Combined()) + assert.Assert(t, strings.Contains(warningLine, "web"), warningLine) + assert.Assert(t, strings.Contains(warningLine, "external-net"), warningLine) + assert.Assert(t, !strings.Contains(warningLine, "db"), + "db declares its own external-net alias and must be excluded from the warning: %s", warningLine) + }) + + t.Run("service name is an alias on internal network", func(t *testing.T) { + res := c.RunDockerCmd(t, "inspect", projectName+"-web-1", "-f", + fmt.Sprintf(`{{ range (index .NetworkSettings.Networks %q).Aliases }}[{{ . }}]{{ end }}`, internalNet)) + assert.Assert(t, strings.Contains(res.Stdout(), "[web]"), res.Stdout()) + }) + + t.Run("service name is not an alias on external network", func(t *testing.T) { + res := c.RunDockerCmd(t, "inspect", projectName+"-web-1", "-f", + fmt.Sprintf(`{{ range (index .NetworkSettings.Networks %q).Aliases }}[{{ . }}]{{ end }}`, externalNet)) + assert.Assert(t, !strings.Contains(res.Stdout(), "[web]"), res.Stdout()) + }) + + t.Run("explicit alias under networks..aliases is registered on external network", func(t *testing.T) { + res := c.RunDockerCmd(t, "inspect", projectName+"-db-1", "-f", + fmt.Sprintf(`{{ range (index .NetworkSettings.Networks %q).Aliases }}[{{ . }}]{{ end }}`, externalNet)) + assert.Assert(t, strings.Contains(res.Stdout(), "[db]"), res.Stdout()) + }) + + t.Run("service name resolves on internal network", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/network-external-alias/compose.yaml", + "--project-name", projectName, + "exec", "-T", "web", "ping", "-c1", "db") + assert.Assert(t, strings.Contains(res.Combined(), "1 packets transmitted"), res.Combined()) + }) +}