You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/22 00:47:06 UTC

(superset-kubernetes-operator) branch main updated: fix(controller): preserve auto-allocated Service node ports (#360)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/superset-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new e863c44  fix(controller): preserve auto-allocated Service node ports (#360)
e863c44 is described below

commit e863c44c1967f45bad059bf2a293eee118ba3d57
Author: Ville Brofeldt <33...@users.noreply.github.com>
AuthorDate: Mon Sep 21 17:47:00 2026 -0700

    fix(controller): preserve auto-allocated Service node ports (#360)
    
    * fix(controller): preserve auto-allocated Service node ports
    
    For a component whose `service.type` is `NodePort` or `LoadBalancer` and
    that does not pin `service.nodePort`, the apiserver auto-allocates a node
    port on create. The reconciler rebuilt the Service spec from scratch on
    every reconcile with `nodePort: 0` and did not carry the allocated value
    over, so the apiserver re-allocated a different node port each time —
    churning the Service and intermittently breaking external access (and the
    LoadBalancer target).
    
    `preserveServiceAllocatedFields` already carries over apiserver-allocated
    fields like clusterIP and healthCheckNodePort; extend it to also preserve
    per-port node ports for NodePort/LoadBalancer Services when the user did
    not pin one, mirroring that logic. ClusterIP Services are left untouched
    so no node port is set on a type that rejects it.
    
    * docs(changelog): note Service node port preservation fix
---
 docs/reference/releases.md                       |  1 +
 internal/controller/component_reconciler.go      | 29 +++++++++++++++
 internal/controller/component_reconciler_test.go | 45 ++++++++++++++++++++++++
 3 files changed, 75 insertions(+)

diff --git a/docs/reference/releases.md b/docs/reference/releases.md
index fbfddf2..93cdd40 100644
--- a/docs/reference/releases.md
+++ b/docs/reference/releases.md
@@ -29,6 +29,7 @@ This page tracks notable changes in Apache Superset Kubernetes Operator releases
 
 ### Fixed
 
+- Component Services of type `NodePort` or `LoadBalancer` that do not pin `service.nodePort` no longer churn: the operator now preserves the apiserver-allocated node port across reconciles instead of triggering a re-allocation each time, which previously changed the external port and broke external access ([#360](https://github.com/apache/superset-kubernetes-operator/pull/360), [@villebro](https://github.com/villebro)).
 - The operator now requests `update` on `supersets/finalizers`, so reconciliation no longer fails on clusters with the `OwnerReferencesPermissionEnforcement` admission plugin enabled (e.g. OpenShift), where the API server previously rejected the `blockOwnerDeletion` owner references the operator sets on child resources ([#359](https://github.com/apache/superset-kubernetes-operator/pull/359), [@villebro](https://github.com/villebro)).
 - The chart no longer rejects top-level values it does not declare, so shared values files and deploy tooling that injects release metadata no longer fail schema validation. Nested value blocks are still strictly validated.
 
diff --git a/internal/controller/component_reconciler.go b/internal/controller/component_reconciler.go
index e6821ee..0f34860 100644
--- a/internal/controller/component_reconciler.go
+++ b/internal/controller/component_reconciler.go
@@ -211,6 +211,35 @@ func preserveServiceAllocatedFields(desired *corev1.ServiceSpec, existing corev1
 		desired.IPFamilies = nil
 		desired.IPFamilyPolicy = nil
 	}
+
+	// Preserve auto-allocated node ports. For NodePort/LoadBalancer Services the
+	// apiserver allocates a node port when the user did not pin one; carrying it
+	// over prevents a new port being allocated on every reconcile (which would
+	// churn the Service and break external access). Only meaningful for types
+	// that own node ports — leaving ClusterIP ports untouched avoids setting a
+	// node port the apiserver would reject.
+	if desired.Type == corev1.ServiceTypeNodePort || desired.Type == corev1.ServiceTypeLoadBalancer {
+		for i := range desired.Ports {
+			if desired.Ports[i].NodePort != 0 {
+				continue
+			}
+			if existingPort := matchingServicePort(existing.Ports, desired.Ports[i]); existingPort != nil {
+				desired.Ports[i].NodePort = existingPort.NodePort
+			}
+		}
+	}
+}
+
+// matchingServicePort returns the existing port that corresponds to want,
+// matched by name and protocol (the operator names its single port "http"), or
+// nil if there is no match.
+func matchingServicePort(ports []corev1.ServicePort, want corev1.ServicePort) *corev1.ServicePort {
+	for i := range ports {
+		if ports[i].Name == want.Name && ports[i].Protocol == want.Protocol {
+			return &ports[i]
+		}
+	}
+	return nil
 }
 
 // buildChecksumAnnotations builds pod annotations from checksum fields.
diff --git a/internal/controller/component_reconciler_test.go b/internal/controller/component_reconciler_test.go
index 2262363..1b98ba8 100644
--- a/internal/controller/component_reconciler_test.go
+++ b/internal/controller/component_reconciler_test.go
@@ -97,6 +97,51 @@ func TestPreserveServiceAllocatedFields(t *testing.T) {
 				ExternalName: "superset.example.com",
 			},
 		},
+		{
+			name: "preserves auto-allocated node port when unpinned",
+			desired: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeNodePort,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088}},
+			},
+			existing: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeNodePort,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088, NodePort: 31234}},
+			},
+			want: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeNodePort,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088, NodePort: 31234}},
+			},
+		},
+		{
+			name: "keeps user-pinned node port over existing",
+			desired: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeLoadBalancer,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088, NodePort: 30500}},
+			},
+			existing: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeLoadBalancer,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088, NodePort: 31234}},
+			},
+			want: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeLoadBalancer,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088, NodePort: 30500}},
+			},
+		},
+		{
+			name: "does not carry a node port onto a ClusterIP Service",
+			desired: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeClusterIP,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088}},
+			},
+			existing: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeNodePort,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088, NodePort: 31234}},
+			},
+			want: corev1.ServiceSpec{
+				Type:  corev1.ServiceTypeClusterIP,
+				Ports: []corev1.ServicePort{{Name: "http", Protocol: corev1.ProtocolTCP, Port: 8088}},
+			},
+		},
 	}
 
 	for _, tt := range tests {