diff --git a/staging/src/k8s.io/endpointslice/reconciler_test.go b/staging/src/k8s.io/endpointslice/reconciler_test.go index 22f3cde24f9..ea4291c65a7 100644 --- a/staging/src/k8s.io/endpointslice/reconciler_test.go +++ b/staging/src/k8s.io/endpointslice/reconciler_test.go @@ -2209,6 +2209,49 @@ func TestReconcile_TrafficDistribution(t *testing.T) { } } +// TestReconcileHeadlessServiceNoPorts verifies that headless services with no ports +// don't cause EndpointSlice churn. Validates fix for https://github.com/kubernetes/kubernetes/issues/133474 +func TestReconcileHeadlessServiceNoPorts(t *testing.T) { + namespace := "test" + client := newClientset() + setupMetrics() + + svc := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "headless-no-ports", + Namespace: namespace, + UID: "test-uid", + }, + Spec: corev1.ServiceSpec{ + ClusterIP: corev1.ClusterIPNone, + Selector: map[string]string{"foo": "bar"}, + IPFamilies: []corev1.IPFamily{corev1.IPv4Protocol}, + }, + } + + pod := newPod(1, namespace, true, 1, false) + + r := newReconciler(client, []*corev1.Node{{ObjectMeta: metav1.ObjectMeta{Name: pod.Spec.NodeName}}}, defaultMaxEndpointsPerSlice) + + reconcileHelper(t, r, &svc, []*corev1.Pod{pod}, []*discovery.EndpointSlice{}, time.Now()) + assert.Len(t, client.Actions(), 1, "Expected 1 additional clientset action") + expectActions(t, client.Actions(), 1, "create", "endpointslices") + + var existingSlices []*discovery.EndpointSlice + for _, slice := range fetchEndpointSlices(t, client, namespace) { + copy := slice.DeepCopy() + // replicate API server behavior which serializes this empty slice as nil + copy.Ports = nil + existingSlices = append(existingSlices, copy) + } + assert.Len(t, existingSlices, 1, "Expected 1 endpoint slices") + + reconcileHelper(t, r, &svc, []*corev1.Pod{pod}, existingSlices, time.Now()) + + assert.Len(t, client.Actions(), 2, "Expected second reconcile to only list") + expectActions(t, client.Actions(), 1, "list", "endpointslices") +} + // Test Helpers func newReconciler(client *fake.Clientset, nodes []*corev1.Node, maxEndpointsPerSlice int32) *Reconciler { diff --git a/staging/src/k8s.io/endpointslice/util/controller_utils.go b/staging/src/k8s.io/endpointslice/util/controller_utils.go index 2d9a8746a19..170bb7e851b 100644 --- a/staging/src/k8s.io/endpointslice/util/controller_utils.go +++ b/staging/src/k8s.io/endpointslice/util/controller_utils.go @@ -159,6 +159,10 @@ type PortMapKey string // NewPortMapKey generates a PortMapKey from endpoint ports. func NewPortMapKey(endpointPorts []discovery.EndpointPort) PortMapKey { + // Normalize nil to empty slice so they hash the same. + if endpointPorts == nil { + endpointPorts = []discovery.EndpointPort{} + } sort.Sort(portsInOrder(endpointPorts)) return PortMapKey(deepHashObjectToString(endpointPorts)) } diff --git a/staging/src/k8s.io/endpointslice/util/controller_utils_test.go b/staging/src/k8s.io/endpointslice/util/controller_utils_test.go index c7705b66b61..cf9f0122e5a 100644 --- a/staging/src/k8s.io/endpointslice/util/controller_utils_test.go +++ b/staging/src/k8s.io/endpointslice/util/controller_utils_test.go @@ -948,3 +948,15 @@ func TestDeepObjectPointer(t *testing.T) { } } } + +func TestNewPortMapKey_NilVsEmptySlice(t *testing.T) { + var nilPorts []discovery.EndpointPort + emptyPorts := []discovery.EndpointPort{} + + nilKey := NewPortMapKey(nilPorts) + emptyKey := NewPortMapKey(emptyPorts) + + if nilKey != emptyKey { + t.Errorf("NewPortMapKey should return the same key for nil and empty slice, got nil=%q empty=%q", nilKey, emptyKey) + } +}