Repository: open-telemetry/opentelemetry-operator
Pull request #3951: Fix issue with port duplication for Containers
Author: login: iblancasa
Created: 2025-04-29T16:39:44Z | Merged: 2025-05-08T16:07:16Z

**Link to tracking Issue(s):** <Issue number if applicable>

- Resolves: #3950

**Testing:** unit tests + improve the E2E test.




Diff:
diff --git a/.chloggen/3950.yaml b/.chloggen/3950.yaml
new file mode 100755
index 0000000000..54f55b2f35
--- /dev/null
+++ b/.chloggen/3950.yaml
@@ -0,0 +1,21 @@
+# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
+change_type: bug_fix
+
+# The name of the component, or a single word describing the area of concern, (e.g. collector, target allocator, auto-instrumentation, opamp, github action)
+component: collector
+
+# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
+note: Fixes container port duplication for user-provided and operator inferred ports
+
+# One or more tracking issues related to the change
+issues: [3950]
+
+# (Optional) One or more lines of additional information to render under the primary note.
+# These lines will be padded with 2 spaces and then inserted directly into the document.
+# Use pipe (|) for multiline entries.
+subtext: |
+  This patch improves container port collisions when the user defines additional ports in the CR.
+
+  When ports are specified in the CR:
+  - If a port number from the CR matches a port inferred from the config, the inferred port is removed
+  - If a port name from the CR matches an inferred port name, the inferred port is renamed to "port-{number}"
diff --git a/internal/manifests/collector/container.go b/internal/manifests/collector/container.go
index feb4f2b34b..676918e9d4 100644
--- a/internal/manifests/collector/container.go
+++ b/internal/manifests/collector/container.go
@@ -32,20 +32,7 @@ func Container(cfg config.Config, logger logr.Logger, otelcol v1beta1.OpenTeleme
 		image = cfg.CollectorImage()
 	}
 
-	// build container ports from service ports
-	ports, err := getConfigContainerPorts(logger, otelcol.Spec.Config)
-	if err != nil {
-		logger.Error(err, "container ports config")
-	}
-
-	for _, p := range otelcol.Spec.Ports {
-		ports[p.Name] = corev1.ContainerPort{
-			Name:          p.Name,
-			ContainerPort: p.Port,
-			Protocol:      p.Protocol,
-			HostPort:      p.HostPort,
-		}
-	}
+	ports := getContainerPorts(logger, otelcol)
 
 	var volumeMounts []corev1.VolumeMount
 	argsMap := otelcol.Spec.Args
@@ -122,7 +109,7 @@ func Container(cfg config.Config, logger logr.Logger, otelcol v1beta1.OpenTeleme
 		Name:            naming.Container(),
 		Image:           image,
 		ImagePullPolicy: otelcol.Spec.ImagePullPolicy,
-		Ports:           portMapToList(ports),
+		Ports:           ports,
 		VolumeMounts:    volumeMounts,
 		Args:            args,
 		Env:             getContainerEnvVars(otelcol, logger),
@@ -135,8 +122,8 @@ func Container(cfg config.Config, logger logr.Logger, otelcol v1beta1.OpenTeleme
 	}
 }
 
-func getConfigContainerPorts(logger logr.Logger, conf v1beta1.Config) (map[string]corev1.ContainerPort, error) {
-	ports := map[string]corev1.ContainerPort{}
+func getConfigContainerPorts(logger logr.Logger, conf v1beta1.Config) ([]corev1.ContainerPort, error) {
+	ports := []corev1.ContainerPort{}
 	ps, err := conf.GetAllPorts(logger)
 	if err != nil {
 		return ports, err
@@ -155,11 +142,11 @@ func getConfigContainerPorts(logger logr.Logger, conf v1beta1.Config) (map[strin
 					"port.name.errs", nameErrs, "num.errs", numErrs)
 				continue
 			}
-			ports[truncName] = corev1.ContainerPort{
+			ports = append(ports, corev1.ContainerPort{
 				Name:          truncName,
 				ContainerPort: p.Port,
 				Protocol:      p.Protocol,
-			}
+			})
 		}
 	}
 
@@ -169,26 +156,15 @@ func getConfigContainerPorts(logger logr.Logger, conf v1beta1.Config) (map[strin
 		metricsPort = 8888
 	}
 
-	ports["metrics"] = corev1.ContainerPort{
+	ports = append(ports, corev1.ContainerPort{
 		Name:          "metrics",
 		ContainerPort: metricsPort,
 		Protocol:      corev1.ProtocolTCP,
-	}
+	})
 
 	return ports, nil
 }
 
-func portMapToList(portMap map[string]corev1.ContainerPort) []corev1.ContainerPort {
-	ports := make([]corev1.ContainerPort, 0, len(portMap))
-	for _, p := range portMap {
-		ports = append(ports, p)
-	}
-	sort.Slice(ports, func(i, j int) bool {
-		return ports[i].Name < ports[j].Name
-	})
-	return ports
-}
-
 func defaultProbeSettings(probe *corev1.Probe, probeConfig *v1beta1.Probe) {
 	if probe != nil && probeConfig != nil {
 		if probeConfig.InitialDelaySeconds != nil {
@@ -210,6 +186,108 @@ func defaultProbeSettings(probe *corev1.Probe, probeConfig *v1beta1.Probe) {
 	}
 }
 
+func getContainerPorts(logger logr.Logger, otelcol v1beta1.OpenTelemetryCollector) []corev1.ContainerPort {
+	// build container ports from service ports
+	ports, err := getConfigContainerPorts(logger, otelcol.Spec.Config)
+	if err != nil {
+		logger.Error(err, "container ports config")
+	}
+
+	if len(otelcol.Spec.Ports) > 0 {
+		// we should add all the ports from the CR
+		// there are two cases where problems might occur:
+		// 1) when the port number is already being used by a receiver
+		// 2) same, but for the port name
+		//
+		// in the first case, we remove the port we inferred from the list
+		// in the second case, we rename our inferred port to something like "port-%d"
+		portNumbers, portNames := extractPortNumbersAndNames(otelcol.Spec.Ports)
+		var resultingInferredPorts []corev1.ContainerPort
+		for _, inferred := range ports {
+			if filtered := filterContainerPort(logger, inferred, portNumbers, portNames); filtered != nil {
+				resultingInferredPorts = append(resultingInferredPorts, *filtered)
+			}
+		}
+		specPorts := getSpecPorts(otelcol.Spec.Ports, logger)
+		ports = append(specPorts, resultingInferredPorts...)
+	}
+
+	sort.Slice(ports, func(i, j int) bool {
+		return ports[i].Name < ports[j].Name
+	})
+	return ports
+}
+
+// filterContainerPort filters container ports to avoid conflicts with user-specified ports.
+// If the candidate port number is already in use, returns nil.
+// If the candidate port name conflicts with an existing name, attempts to use a fallback name of format "port-{number}".
+// If both the original name and fallback name are taken, returns nil with a warning log.
+// Otherwise returns the (potentially renamed) candidate port.
+func filterContainerPort(logger logr.Logger, candidate corev1.ContainerPort, portNumbers map[PortNumberKey]bool, portNames map[string]bool) *corev1.ContainerPort {
+	if portNumbers[newPortNumberKey(candidate.ContainerPort, candidate.Protocol)] {
+		return nil
+	}
+
+	// do we have the port name there already?
+	if portNames[candidate.Name] {
+		// there's already a port with the same name! do we have a 'port-%d' already?
+		fallbackName := fmt.Sprintf("port-%d", candidate.ContainerPort)
+		if portNames[fallbackName] {
+			// that wasn't expected, better skip this port
+			logger.V(2).Info("a port name specified in the CR clashes with an inferred port name, and the fallback port name clashes with another port name! Skipping this port.",
+				"inferred-port-name", candidate.Name,
+				"fallback-port-name", fallbackName,
+			)
+			return nil
+		}
+
+		candidate.Name = fallbackName
+		return &candidate
+	}
+
+	// this port is unique, return as is
+	return &candidate
+}
+
+func toContainerPorts(ports []v1beta1.PortsSpec) []corev1.ContainerPort {
+	var containerPorts []corev1.ContainerPort
+	for _, p := range ports {
+		containerPorts = append(containerPorts, corev1.ContainerPort{
+			Name:          p.Name,
+			ContainerPort: p.Port,
+			Protocol:      p.Protocol,
+			HostPort:      p.HostPort,
+		})
+	}
+	return containerPorts
+}
+
+// getSpecPorts takes a slice of PortsSpec and returns container ports, handling duplicate port names by generating unique fallback names.
+// If a port name is duplicated and its fallback name is also taken, that port is skipped with a log message.
+func getSpecPorts(ports []v1beta1.PortsSpec, logger logr.Logger) []corev1.ContainerPort {
+	// Handle duplicate port names in otelcol.Spec.Ports
+	seenNames := make(map[string]bool)
+	var specPorts []corev1.ContainerPort
+	for _, port := range toContainerPorts(ports) {
+		if seenNames[port.Name] {
+			// If we've seen this name before, generate a unique name
+			fallbackName := fmt.Sprintf("port-%d", port.ContainerPort)
+			if seenNames[fallbackName] {
+				// If the fallback name is also taken, skip this port
+				logger.V(2).Info("skipping port due to duplicate name and fallback name conflict",
+					"port-name", port.Name,
+					"fallback-name", fallbackName,
+				)
+				continue
+			}
+			port.Name = fallbackName
+		}
+		seenNames[port.Name] = true
+		specPorts = append(specPorts, port)
+	}
+	return specPorts
+}
+
 // getContainerEnvVars returns the environment variables for the collector container.
 // It combines user-defined environment variables from the OpenTelemetryCollector spec
 // with automatically inferred environment variables, giving precedence to user-defined ones.
diff --git a/internal/manifests/collector/container_test.go b/internal/manifests/collector/container_test.go
index d67ee3be5c..8ac249523a 100644
--- a/internal/manifests/collector/container_test.go
+++ b/internal/manifests/collector/container_test.go
@@ -174,10 +174,6 @@ service:
 				},
 			},
 			expectedPorts: []corev1.ContainerPort{
-				{
-					Name:          "examplereceiver",
-					ContainerPort: 12345,
-				},
 				metricContainerPort,
 				{
 					Name:          "testport1",
@@ -209,13 +205,13 @@ service:
 			},
 			expectedPorts: []corev1.ContainerPort{
 				{
-					Name:          "examplereceiver",
-					ContainerPort: 12345,
+					Name:          "port-11111",
+					ContainerPort: 11111,
 				},
 				metricContainerPort,
 				{
 					Name:          "testport1",
-					ContainerPort: 11111,
+					ContainerPort: 12345,
 				},
 			},
 		},
diff --git a/internal/manifests/collector/service.go b/internal/manifests/collector/service.go
index 25096e25c8..52bbab187d 100644
--- a/internal/manifests/collector/service.go
+++ b/internal/manifests/collector/service.go
@@ -220,6 +220,11 @@ func newPortNumberKey(port int32, protocol corev1.Protocol) PortNumberKey {
 	return PortNumberKey{Port: port, Protocol: protocol}
 }
 
+// filterPort filters service ports to avoid conflicts with user-specified ports.
+// If the candidate port number is already in use, returns nil.
+// If the candidate port name conflicts with an existing name, attempts to use a fallback name of format "port-{number}".
+// If both the original name and fallback name are taken, returns nil with a warning log.
+// Otherwise returns the (potentially renamed) candidate port.
 func filterPort(logger logr.Logger, candidate corev1.ServicePort, portNumbers map[PortNumberKey]bool, portNames map[string]bool) *corev1.ServicePort {
 	if portNumbers[newPortNumberKey(candidate.Port, candidate.Protocol)] {
 		return nil
@@ -231,9 +236,10 @@ func filterPort(logger logr.Logger, candidate corev1.ServicePort, portNumbers ma
 		fallbackName := fmt.Sprintf("port-%d", candidate.Port)
 		if portNames[fallbackName] {
 			// that wasn't expected, better skip this port
-			logger.V(2).Info("a port name specified in the CR clashes with an inferred port name, and the fallback port name clashes with another port name! Skipping this port.",
+			logger.V(0).Info("a port name specified in the CR clashes with an inferred port name, and the fallback port name clashes with another port name! Skipping this port.",
 				"inferred-port-name", candidate.Name,
 				"fallback-port-name", fallbackName,
+				"type", "warning",
 			)
 			return nil
 		}
diff --git a/tests/e2e/smoke-ports/00-install.yaml b/tests/e2e/smoke-ports/00-install.yaml
index cf47b6a137..66cf042629 100644
--- a/tests/e2e/smoke-ports/00-install.yaml
+++ b/tests/e2e/smoke-ports/00-install.yaml
@@ -1,4 +1,4 @@
-apiVersion: opentelemetry.io/v1alpha1
+apiVersion: opentelemetry.io/v1beta1
 kind: OpenTelemetryCollector
 metadata:
   name: smoke-ports
@@ -11,7 +11,7 @@ spec:
     protocol: TCP
     targetPort: 4317
     hostPort: 4317
-  config: |
+  config:
     receivers:
       jaeger:
         protocols:
diff --git a/tests/e2e/smoke-ports/01-assert.yaml b/tests/e2e/smoke-ports/01-assert.yaml
new file mode 100644
index 0000000000..3ec0b0f8eb
--- /dev/null
+++ b/tests/e2e/smoke-ports/01-assert.yaml
@@ -0,0 +1,29 @@
+apiVersion: apps/v1
+kind: DaemonSet
+metadata:
+  name: smoke-ports-collector
+spec:
+  template:
+    spec:
+       containers:
+       - name: otc-container
+         ports:
+         - containerPort: 9090
+           hostPort: 9090
+           name: custom-port
+           protocol: TCP
+         - containerPort: 8888
+           name: metrics
+           protocol: TCP
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: smoke-ports-collector
+spec:
+  ports:
+  - appProtocol: http
+    name: custom-port
+    port: 9090
+    protocol: TCP
+    targetPort: 9090
diff --git a/tests/e2e/smoke-ports/01-install.yaml b/tests/e2e/smoke-ports/01-install.yaml
new file mode 100644
index 0000000000..fc15363631
--- /dev/null
+++ b/tests/e2e/smoke-ports/01-install.yaml
@@ -0,0 +1,25 @@
+apiVersion: opentelemetry.io/v1beta1
+kind: OpenTelemetryCollector
+metadata:
+  name: smoke-ports
+spec:
+  mode: daemonset
+  ports:
+  - appProtocol: http
+    name: custom-port
+    port: 9090
+    protocol: TCP
+    targetPort: 9090
+    hostPort: 9090
+  config:
+    receivers:
+      nop: {}
+    processors:
+    exporters:
+      prometheus:
+        endpoint: "0.0.0.0:9090"
+    service:
+      pipelines:
+        traces:
+          receivers: [nop]
+          exporters: [prometheus]
diff --git a/tests/e2e/smoke-ports/chainsaw-test.yaml b/tests/e2e/smoke-ports/chainsaw-test.yaml
index 3d9816ddf9..ffd5f5e001 100755
--- a/tests/e2e/smoke-ports/chainsaw-test.yaml
+++ b/tests/e2e/smoke-ports/chainsaw-test.yaml
@@ -12,3 +12,9 @@ spec:
         file: 00-install.yaml
     - assert:
         file: 00-assert.yaml
+  - name: step-01
+    try:
+    - apply:
+        file: 01-install.yaml
+    - assert:
+        file: 01-assert.yaml


Reviews:
user: login: swiatekm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T18:02:11Z
user: login: iblancasa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T08:01:59Z
user: login: iblancasa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T08:35:11Z
user: login: swiatekm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T11:35:33Z
user: login: swiatekm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T11:36:14Z
user: login: jaronoff97
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T14:29:45Z
user: login: pavolloffay
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T15:35:02Z
user: login: iblancasa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T17:46:31Z
user: login: pavolloffay
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-08T16:07:09Z

Review comments:
diff_hunk: @@ -264,3 +240,98 @@ func defaultProbeSettings(probe *corev1.Probe, probeConfig *v1beta1.Probe) {
 		probe.TerminationGracePeriodSeconds = probeConfig.TerminationGracePeriodSeconds
 	}
 }
+
+func getContainerPorts(logger logr.Logger, otelcol v1beta1.OpenTelemetryCollector) []corev1.ContainerPort {
+	// build container ports from service ports
+	ports, err := getConfigContainerPorts(logger, otelcol.Spec.Config)
+	if err != nil {
+		logger.Error(err, "container ports config")
+	}
+
+	if len(otelcol.Spec.Ports) > 0 {
+		// we should add all the ports from the CR
+		// there are two cases where problems might occur:
+		// 1) when the port number is already being used by a receiver
+		// 2) same, but for the port name
+		//
+		// in the first case, we remove the port we inferred from the list
+		// in the second case, we rename our inferred port to something like "port-%d"
+		portNumbers, portNames := extractPortNumbersAndNames(otelcol.Spec.Ports)
+		var resultingInferredPorts []corev1.ContainerPort
+		for _, inferred := range ports {
+			if filtered := filterContainerPort(logger, inferred, portNumbers, portNames); filtered != nil {
+				resultingInferredPorts = append(resultingInferredPorts, *filtered)
+			}
+		}
+		specPorts := getSpecPorts(otelcol.Spec.Ports, logger)
+		ports = append(specPorts, resultingInferredPorts...)
+	}
+
+	sort.Slice(ports, func(i, j int) bool {
+		return ports[i].Name < ports[j].Name
+	})
+	return ports
+}
+
+func filterContainerPort(logger logr.Logger, candidate corev1.ContainerPort, portNumbers map[PortNumberKey]bool, portNames map[string]bool) *corev1.ContainerPort {
path: internal/manifests/collector/container.go
user: login: swiatekm
body: This function name isn't very descriptive. I tried to come up with a better one, and couldn't, which probably means the scope isn't right. How about we pull the whole loop from `getContainerPorts` here and name the function `deduplicateContainerPorts` or something like that?
created_at: 2025-04-30T17:58:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +240,98 @@ func defaultProbeSettings(probe *corev1.Probe, probeConfig *v1beta1.Probe) {
 		probe.TerminationGracePeriodSeconds = probeConfig.TerminationGracePeriodSeconds
 	}
 }
+
+func getContainerPorts(logger logr.Logger, otelcol v1beta1.OpenTelemetryCollector) []corev1.ContainerPort {
+	// build container ports from service ports
+	ports, err := getConfigContainerPorts(logger, otelcol.Spec.Config)
+	if err != nil {
+		logger.Error(err, "container ports config")
+	}
+
+	if len(otelcol.Spec.Ports) > 0 {
+		// we should add all the ports from the CR
+		// there are two cases where problems might occur:
+		// 1) when the port number is already being used by a receiver
+		// 2) same, but for the port name
+		//
+		// in the first case, we remove the port we inferred from the list
+		// in the second case, we rename our inferred port to something like "port-%d"
+		portNumbers, portNames := extractPortNumbersAndNames(otelcol.Spec.Ports)
+		var resultingInferredPorts []corev1.ContainerPort
+		for _, inferred := range ports {
+			if filtered := filterContainerPort(logger, inferred, portNumbers, portNames); filtered != nil {
+				resultingInferredPorts = append(resultingInferredPorts, *filtered)
+			}
+		}
+		specPorts := getSpecPorts(otelcol.Spec.Ports, logger)
+		ports = append(specPorts, resultingInferredPorts...)
+	}
+
+	sort.Slice(ports, func(i, j int) bool {
+		return ports[i].Name < ports[j].Name
+	})
+	return ports
+}
+
+func filterContainerPort(logger logr.Logger, candidate corev1.ContainerPort, portNumbers map[PortNumberKey]bool, portNames map[string]bool) *corev1.ContainerPort {
+	if portNumbers[newPortNumberKey(candidate.ContainerPort, candidate.Protocol)] {
+		return nil
+	}
+
+	// do we have the port name there already?
+	if portNames[candidate.Name] {
+		// there's already a port with the same name! do we have a 'port-%d' already?
+		fallbackName := fmt.Sprintf("port-%d", candidate.ContainerPort)
+		if portNames[fallbackName] {
+			// that wasn't expected, better skip this port
+			logger.V(2).Info("a port name specified in the CR clashes with an inferred port name, and the fallback port name clashes with another port name! Skipping this port.",
+				"inferred-port-name", candidate.Name,
+				"fallback-port-name", fallbackName,
+			)
+			return nil
+		}
+
+		candidate.Name = fallbackName
+		return &candidate
+	}
+
+	// this port is unique, return as is
+	return &candidate
+}
+
+func toContainerPorts(ports []v1beta1.PortsSpec) []corev1.ContainerPort {
+	var containerPorts []corev1.ContainerPort
+	for _, p := range ports {
+		containerPorts = append(containerPorts, corev1.ContainerPort{
+			Name:          p.Name,
+			ContainerPort: p.Port,
+			Protocol:      p.Protocol,
+			HostPort:      p.HostPort,
+		})
+	}
+	return containerPorts
+}
+
+func getSpecPorts(ports []v1beta1.PortsSpec, logger logr.Logger) []corev1.ContainerPort {
path: internal/manifests/collector/container.go
user: login: swiatekm
body: Could you add a docstring here?
created_at: 2025-04-30T17:58:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +240,98 @@ func defaultProbeSettings(probe *corev1.Probe, probeConfig *v1beta1.Probe) {
 		probe.TerminationGracePeriodSeconds = probeConfig.TerminationGracePeriodSeconds
 	}
 }
+
+func getContainerPorts(logger logr.Logger, otelcol v1beta1.OpenTelemetryCollector) []corev1.ContainerPort {
+	// build container ports from service ports
+	ports, err := getConfigContainerPorts(logger, otelcol.Spec.Config)
+	if err != nil {
+		logger.Error(err, "container ports config")
+	}
+
+	if len(otelcol.Spec.Ports) > 0 {
+		// we should add all the ports from the CR
+		// there are two cases where problems might occur:
+		// 1) when the port number is already being used by a receiver
+		// 2) same, but for the port name
+		//
+		// in the first case, we remove the port we inferred from the list
+		// in the second case, we rename our inferred port to something like "port-%d"
+		portNumbers, portNames := extractPortNumbersAndNames(otelcol.Spec.Ports)
+		var resultingInferredPorts []corev1.ContainerPort
+		for _, inferred := range ports {
+			if filtered := filterContainerPort(logger, inferred, portNumbers, portNames); filtered != nil {
+				resultingInferredPorts = append(resultingInferredPorts, *filtered)
+			}
+		}
+		specPorts := getSpecPorts(otelcol.Spec.Ports, logger)
+		ports = append(specPorts, resultingInferredPorts...)
+	}
+
+	sort.Slice(ports, func(i, j int) bool {
+		return ports[i].Name < ports[j].Name
+	})
+	return ports
+}
+
+func filterContainerPort(logger logr.Logger, candidate corev1.ContainerPort, portNumbers map[PortNumberKey]bool, portNames map[string]bool) *corev1.ContainerPort {
+	if portNumbers[newPortNumberKey(candidate.ContainerPort, candidate.Protocol)] {
+		return nil
+	}
+
+	// do we have the port name there already?
+	if portNames[candidate.Name] {
+		// there's already a port with the same name! do we have a 'port-%d' already?
+		fallbackName := fmt.Sprintf("port-%d", candidate.ContainerPort)
+		if portNames[fallbackName] {
+			// that wasn't expected, better skip this port
+			logger.V(2).Info("a port name specified in the CR clashes with an inferred port name, and the fallback port name clashes with another port name! Skipping this port.",
+				"inferred-port-name", candidate.Name,
+				"fallback-port-name", fallbackName,
+			)
+			return nil
+		}
+
+		candidate.Name = fallbackName
+		return &candidate
+	}
+
+	// this port is unique, return as is
+	return &candidate
+}
+
+func toContainerPorts(ports []v1beta1.PortsSpec) []corev1.ContainerPort {
+	var containerPorts []corev1.ContainerPort
+	for _, p := range ports {
+		containerPorts = append(containerPorts, corev1.ContainerPort{
+			Name:          p.Name,
+			ContainerPort: p.Port,
+			Protocol:      p.Protocol,
+			HostPort:      p.HostPort,
+		})
+	}
+	return containerPorts
+}
+
+func getSpecPorts(ports []v1beta1.PortsSpec, logger logr.Logger) []corev1.ContainerPort {
+	// Handle duplicate port names in otelcol.Spec.Ports
+	seenNames := make(map[string]bool)
+	var specPorts []corev1.ContainerPort
+	for _, port := range toContainerPorts(ports) {
+		if seenNames[port.Name] {
+			// If we've seen this name before, generate a unique name
+			fallbackName := fmt.Sprintf("port-%d", port.ContainerPort)
+			if seenNames[fallbackName] {
+				// If the fallback name is also taken, skip this port
+				logger.V(2).Info("skipping port due to duplicate name and fallback name conflict",
path: internal/manifests/collector/container.go
user: login: swiatekm
body: I don't mind this, but it should probably be a warning, as it's a user error. We should also have this in our validating webhook, although that doesn't need to happen in this PR.
created_at: 2025-04-30T17:59:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +240,98 @@ func defaultProbeSettings(probe *corev1.Probe, probeConfig *v1beta1.Probe) {
 		probe.TerminationGracePeriodSeconds = probeConfig.TerminationGracePeriodSeconds
 	}
 }
+
+func getContainerPorts(logger logr.Logger, otelcol v1beta1.OpenTelemetryCollector) []corev1.ContainerPort {
+	// build container ports from service ports
+	ports, err := getConfigContainerPorts(logger, otelcol.Spec.Config)
+	if err != nil {
+		logger.Error(err, "container ports config")
+	}
+
+	if len(otelcol.Spec.Ports) > 0 {
+		// we should add all the ports from the CR
+		// there are two cases where problems might occur:
+		// 1) when the port number is already being used by a receiver
+		// 2) same, but for the port name
+		//
+		// in the first case, we remove the port we inferred from the list
+		// in the second case, we rename our inferred port to something like "port-%d"
+		portNumbers, portNames := extractPortNumbersAndNames(otelcol.Spec.Ports)
+		var resultingInferredPorts []corev1.ContainerPort
+		for _, inferred := range ports {
+			if filtered := filterContainerPort(logger, inferred, portNumbers, portNames); filtered != nil {
+				resultingInferredPorts = append(resultingInferredPorts, *filtered)
+			}
+		}
+		specPorts := getSpecPorts(otelcol.Spec.Ports, logger)
+		ports = append(specPorts, resultingInferredPorts...)
+	}
+
+	sort.Slice(ports, func(i, j int) bool {
+		return ports[i].Name < ports[j].Name
+	})
+	return ports
+}
+
+func filterContainerPort(logger logr.Logger, candidate corev1.ContainerPort, portNumbers map[PortNumberKey]bool, portNames map[string]bool) *corev1.ContainerPort {
+	if portNumbers[newPortNumberKey(candidate.ContainerPort, candidate.Protocol)] {
+		return nil
+	}
+
+	// do we have the port name there already?
+	if portNames[candidate.Name] {
+		// there's already a port with the same name! do we have a 'port-%d' already?
+		fallbackName := fmt.Sprintf("port-%d", candidate.ContainerPort)
+		if portNames[fallbackName] {
+			// that wasn't expected, better skip this port
+			logger.V(2).Info("a port name specified in the CR clashes with an inferred port name, and the fallback port name clashes with another port name! Skipping this port.",
+				"inferred-port-name", candidate.Name,
+				"fallback-port-name", fallbackName,
+			)
+			return nil
+		}
+
+		candidate.Name = fallbackName
+		return &candidate
+	}
+
+	// this port is unique, return as is
+	return &candidate
+}
+
+func toContainerPorts(ports []v1beta1.PortsSpec) []corev1.ContainerPort {
+	var containerPorts []corev1.ContainerPort
+	for _, p := range ports {
+		containerPorts = append(containerPorts, corev1.ContainerPort{
+			Name:          p.Name,
+			ContainerPort: p.Port,
+			Protocol:      p.Protocol,
+			HostPort:      p.HostPort,
+		})
+	}
+	return containerPorts
+}
+
+func getSpecPorts(ports []v1beta1.PortsSpec, logger logr.Logger) []corev1.ContainerPort {
+	// Handle duplicate port names in otelcol.Spec.Ports
+	seenNames := make(map[string]bool)
+	var specPorts []corev1.ContainerPort
+	for _, port := range toContainerPorts(ports) {
+		if seenNames[port.Name] {
+			// If we've seen this name before, generate a unique name
+			fallbackName := fmt.Sprintf("port-%d", port.ContainerPort)
+			if seenNames[fallbackName] {
+				// If the fallback name is also taken, skip this port
+				logger.V(2).Info("skipping port due to duplicate name and fallback name conflict",
path: internal/manifests/collector/container.go
user: login: iblancasa
body: I just followed the same pattern we have for `Services` and `Ingresses`. I will change the other two too.
created_at: 2025-05-02T08:01:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +240,98 @@ func defaultProbeSettings(probe *corev1.Probe, probeConfig *v1beta1.Probe) {
 		probe.TerminationGracePeriodSeconds = probeConfig.TerminationGracePeriodSeconds
 	}
 }
+
+func getContainerPorts(logger logr.Logger, otelcol v1beta1.OpenTelemetryCollector) []corev1.ContainerPort {
+	// build container ports from service ports
+	ports, err := getConfigContainerPorts(logger, otelcol.Spec.Config)
+	if err != nil {
+		logger.Error(err, "container ports config")
+	}
+
+	if len(otelcol.Spec.Ports) > 0 {
+		// we should add all the ports from the CR
+		// there are two cases where problems might occur:
+		// 1) when the port number is already being used by a receiver
+		// 2) same, but for the port name
+		//
+		// in the first case, we remove the port we inferred from the list
+		// in the second case, we rename our inferred port to something like "port-%d"
+		portNumbers, portNames := extractPortNumbersAndNames(otelcol.Spec.Ports)
+		var resultingInferredPorts []corev1.ContainerPort
+		for _, inferred := range ports {
+			if filtered := filterContainerPort(logger, inferred, portNumbers, portNames); filtered != nil {
+				resultingInferredPorts = append(resultingInferredPorts, *filtered)
+			}
+		}
+		specPorts := getSpecPorts(otelcol.Spec.Ports, logger)
+		ports = append(specPorts, resultingInferredPorts...)
+	}
+
+	sort.Slice(ports, func(i, j int) bool {
+		return ports[i].Name < ports[j].Name
+	})
+	return ports
+}
+
+func filterContainerPort(logger logr.Logger, candidate corev1.ContainerPort, portNumbers map[PortNumberKey]bool, portNames map[string]bool) *corev1.ContainerPort {
path: internal/manifests/collector/container.go
user: login: iblancasa
body: We also have `filterPort`. Should we change this name too? I would prefer to keep it as it is to be similar to the `filterPort` pattern. Later we can refactor or something but I would prefer to keep the consistency between `Service`, `Ingress` and `Container`.
created_at: 2025-05-02T08:35:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -264,3 +240,98 @@ func defaultProbeSettings(probe *corev1.Probe, probeConfig *v1beta1.Probe) {
 		probe.TerminationGracePeriodSeconds = probeConfig.TerminationGracePeriodSeconds
 	}
 }
+
+func getContainerPorts(logger logr.Logger, otelcol v1beta1.OpenTelemetryCollector) []corev1.ContainerPort {
+	// build container ports from service ports
+	ports, err := getConfigContainerPorts(logger, otelcol.Spec.Config)
+	if err != nil {
+		logger.Error(err, "container ports config")
+	}
+
+	if len(otelcol.Spec.Ports) > 0 {
+		// we should add all the ports from the CR
+		// there are two cases where problems might occur:
+		// 1) when the port number is already being used by a receiver
+		// 2) same, but for the port name
+		//
+		// in the first case, we remove the port we inferred from the list
+		// in the second case, we rename our inferred port to something like "port-%d"
+		portNumbers, portNames := extractPortNumbersAndNames(otelcol.Spec.Ports)
+		var resultingInferredPorts []corev1.ContainerPort
+		for _, inferred := range ports {
+			if filtered := filterContainerPort(logger, inferred, portNumbers, portNames); filtered != nil {
+				resultingInferredPorts = append(resultingInferredPorts, *filtered)
+			}
+		}
+		specPorts := getSpecPorts(otelcol.Spec.Ports, logger)
+		ports = append(specPorts, resultingInferredPorts...)
+	}
+
+	sort.Slice(ports, func(i, j int) bool {
+		return ports[i].Name < ports[j].Name
+	})
+	return ports
+}
+
+func filterContainerPort(logger logr.Logger, candidate corev1.ContainerPort, portNumbers map[PortNumberKey]bool, portNames map[string]bool) *corev1.ContainerPort {
path: internal/manifests/collector/container.go
user: login: swiatekm
body: Allright, let's keep it this way for consistency, then.
created_at: 2025-05-02T11:35:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -213,13 +209,13 @@ service:
 			},
 			expectedPorts: []corev1.ContainerPort{
 				{
-					Name:          "examplereceiver",
-					ContainerPort: 12345,
+					Name:          "port-11111",
path: internal/manifests/collector/container_test.go
user: login: jaronoff97
body: do we have tests to cover both of the cases mentioned above?
created_at: 2025-05-02T14:28:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
+change_type: bug_fix
+
+# The name of the component, or a single word describing the area of concern, (e.g. collector, target allocator, auto-instrumentation, opamp, github action)
+component: collector
+
+# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
+note: Ports are being duplicated for ContainerSpec when user provides them.
path: .chloggen/3950.yaml
user: login: pavolloffay
body: `Fixes container port duplication for user-provided and operator inferred ports `
created_at: 2025-05-02T15:33:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,21 @@
+# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
+change_type: bug_fix
+
+# The name of the component, or a single word describing the area of concern, (e.g. collector, target allocator, auto-instrumentation, opamp, github action)
+component: collector
+
+# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
+note: Ports are being duplicated for ContainerSpec when user provides them.
+
+# One or more tracking issues related to the change
+issues: [3950]
+
+# (Optional) One or more lines of additional information to render under the primary note.
+# These lines will be padded with 2 spaces and then inserted directly into the document.
+# Use pipe (|) for multiline entries.
+subtext: |
+  This fixes a bug where ports are being duplicated for ContainerSpec when user provides them.
path: .chloggen/3950.yaml
user: login: pavolloffay
body: This patch improves container port collisions when the user defines additional ports in the CR.
created_at: 2025-05-02T15:34:57Z
author_association: MEMBER
diff_hunk: @@ -213,13 +209,13 @@ service:
 			},
 			expectedPorts: []corev1.ContainerPort{
 				{
-					Name:          "examplereceiver",
-					ContainerPort: 12345,
+					Name:          "port-11111",
path: internal/manifests/collector/container_test.go
user: login: iblancasa
body: The case was there but we were asserting that it was ok to duplicate the port. I fixed the test removing that assertion so now, the test will fail if the port is duplicated.
created_at: 2025-05-02T17:46:31Z
author_association: CONTRIBUTOR

PR comments:
user: login: jaronoff97
created_at: 2025-04-29T16:48:33Z
body: @iblancasa let's add a changelog for this bug fix if you don't mind :) 
author_association: CONTRIBUTOR
user: login: iblancasa
created_at: 2025-04-29T16:55:15Z
body: > @iblancasa let's add a changelog for this bug fix if you don't mind :)

I always forget on the first push. Thanks!
author_association: CONTRIBUTOR
user: login: iblancasa
created_at: 2025-05-07T08:15:05Z
body: @open-telemetry/operator-maintainers, can we merge this? It seems there are no more comments to address.
author_association: CONTRIBUTOR
user: login: swiatekm
created_at: 2025-05-07T09:53:05Z
body: > @open-telemetry/operator-maintainers, can we merge this? It seems there are no more comments to address.

@pavolloffay reviewed this earlier, so I wanted to wait for his approval. Are we in a hurry to merge this before the release?
author_association: CONTRIBUTOR
user: login: iblancasa
created_at: 2025-05-07T11:01:21Z
body: > > @open-telemetry/operator-maintainers, can we merge this? It seems there are no more comments to address.
> 
> @pavolloffay reviewed this earlier, so I wanted to wait for his approval. Are we in a hurry to merge this before the release?

Yes. It's a bug and is producing some headaches on my side 😓 
author_association: CONTRIBUTOR