From 3cea455c70f5058b8fb37637512623f5ce4a3540 Mon Sep 17 00:00:00 2001 From: uditDewan Date: Tue, 14 Jul 2026 22:06:25 -0400 Subject: [PATCH] cli/compose/loader: don't drop tcp port when udp uses same published port When merging multiple compose files, ports were keyed only by their published port, so a service publishing the same port on both tcp and udp (e.g. "443:443" and "443:443/udp") silently lost one of the two entries during the merge, even when the override file did not define any ports. Include the protocol in the merge key, treating an omitted protocol as tcp (the default for the long syntax), and make the merged order deterministic when the same published port is used for both protocols. Fixes #6223 Signed-off-by: uditDewan --- cli/compose/loader/merge.go | 23 ++++++++++++-- cli/compose/loader/merge_test.go | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/cli/compose/loader/merge.go b/cli/compose/loader/merge.go index 9d8da3c6d81b..b7fa0fd09169 100644 --- a/cli/compose/loader/merge.go +++ b/cli/compose/loader/merge.go @@ -123,6 +123,14 @@ func toServiceConfigObjConfigsMap(s any) (map[any]any, error) { return m, nil } +// servicePortKey identifies a port during a merge; the same published +// port may be exposed for both tcp and udp, so the protocol is part of +// the identity. +type servicePortKey struct { + published uint32 + protocol string +} + func toServicePortConfigsMap(s any) (map[any]any, error) { ports, ok := s.([]types.ServicePortConfig) if !ok { @@ -130,7 +138,13 @@ func toServicePortConfigsMap(s any) (map[any]any, error) { } m := map[any]any{} for _, p := range ports { - m[p.Published] = p + protocol := p.Protocol + if protocol == "" { + // long syntax allows the protocol to be omitted, in which + // case it defaults to tcp + protocol = "tcp" + } + m[servicePortKey{published: p.Published, protocol: protocol}] = p } return m, nil } @@ -172,7 +186,12 @@ func toServicePortConfigsSlice(dst reflect.Value, m map[any]any) error { for _, v := range m { s = append(s, v.(types.ServicePortConfig)) } - sort.Slice(s, func(i, j int) bool { return s[i].Published < s[j].Published }) + sort.Slice(s, func(i, j int) bool { + if s[i].Published != s[j].Published { + return s[i].Published < s[j].Published + } + return s[i].Protocol < s[j].Protocol + }) dst.Set(reflect.ValueOf(s)) return nil } diff --git a/cli/compose/loader/merge_test.go b/cli/compose/loader/merge_test.go index 11c1e888ead2..ac75b14989bd 100644 --- a/cli/compose/loader/merge_test.go +++ b/cli/compose/loader/merge_test.go @@ -280,6 +280,58 @@ func TestLoadMultipleServicePorts(t *testing.T) { }, }, }, + { + name: "same_published_different_protocol", + portBase: map[string]any{ + "ports": []any{ + "443:443", + "443:443/udp", + }, + }, + portOverride: map[string]any{}, + expected: []types.ServicePortConfig{ + { + Mode: "ingress", + Published: 443, + Target: 443, + Protocol: "tcp", + }, + { + Mode: "ingress", + Published: 443, + Target: 443, + Protocol: "udp", + }, + }, + }, + { + name: "override_same_published_different_protocol", + portBase: map[string]any{ + "ports": []any{ + "443:443", + "443:443/udp", + }, + }, + portOverride: map[string]any{ + "ports": []any{ + "443:8443/udp", + }, + }, + expected: []types.ServicePortConfig{ + { + Mode: "ingress", + Published: 443, + Target: 443, + Protocol: "tcp", + }, + { + Mode: "ingress", + Published: 443, + Target: 8443, + Protocol: "udp", + }, + }, + }, { name: "override_same_published", portBase: map[string]any{