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

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git


The following commit(s) were added to refs/heads/master by this push:
     new e4888b80 fix: reject an ApisixRoute servicePort that cannot resolve 
(#2860)
e4888b80 is described below

commit e4888b80631bc7e868082d0bd64e3ddb6ec65cf6
Author: AlinsRan <[email protected]>
AuthorDate: Fri Oct 9 14:18:30 2026 +0800

    fix: reject an ApisixRoute servicePort that cannot resolve (#2860)
---
 internal/adc/translator/apisixroute.go             |   5 +
 internal/controller/apisixroute_controller.go      |  24 ++-
 .../controller/apisixroute_serviceport_test.go     | 173 +++++++++++++++++++++
 3 files changed, 196 insertions(+), 6 deletions(-)

diff --git a/internal/adc/translator/apisixroute.go 
b/internal/adc/translator/apisixroute.go
index fd9e1112..5d03e8c1 100644
--- a/internal/adc/translator/apisixroute.go
+++ b/internal/adc/translator/apisixroute.go
@@ -330,6 +330,11 @@ func getPortFromService(svc *corev1.Service, 
backendSvcPort intstr.IntOrString)
        if backendSvcPort.Type == intstr.Int {
                port = int32(backendSvcPort.IntValue())
        } else {
+               // A Service port may omit its name, so an empty name would 
match it by
+               // accident instead of being reported as the invalid reference 
it is.
+               if backendSvcPort.StrVal == "" {
+                       return 0, errors.Errorf("service port must not be empty 
for service %s", svc.Name)
+               }
                found := false
                for _, servicePort := range svc.Spec.Ports {
                        if servicePort.Name == backendSvcPort.StrVal {
diff --git a/internal/controller/apisixroute_controller.go 
b/internal/controller/apisixroute_controller.go
index 8eed96b6..099e1fdf 100644
--- a/internal/controller/apisixroute_controller.go
+++ b/internal/controller/apisixroute_controller.go
@@ -424,6 +424,16 @@ func (r *ApisixRouteReconciler) validateHTTPBackend(tctx 
*provider.TranslateCont
                }
        )
 
+       // An empty port never resolves, and an empty name would otherwise 
match a
+       // Service port that omits its name, which is allowed for a single-port 
Service.
+       // Reject it before the reference is resolved: no ordering makes it 
valid.
+       if backend.ServicePort.Type == intstr.String && 
backend.ServicePort.StrVal == "" {
+               return types.ReasonError{
+                       Reason:  string(apiv2.ConditionReasonInvalidSpec),
+                       Message: fmt.Sprintf("servicePort must not be empty, 
Service: %s", serviceNN),
+               }
+       }
+
        if err := r.Get(tctx, serviceNN, &service); err != nil {
                if k8serrors.IsNotFound(err) {
                        r.Log.Info("service not found", "Service", serviceNN)
@@ -471,12 +481,14 @@ func (r *ApisixRouteReconciler) validateHTTPBackend(tctx 
*provider.TranslateCont
                }
                return false
        }) {
-               r.Log.Error(errors.New("service port not found"),
-                       "failed to match service port",
-                       "Service", serviceNN,
-                       "ServicePort", backend.ServicePort,
-               )
-               return nil
+               // The Service resolves but has no such port. Reporting this as 
accepted
+               // publishes a route with no upstream node, which answers 503 
while the
+               // status claims the spec is fine.
+               return types.ReasonError{
+                       Reason: string(apiv2.ConditionReasonInvalidSpec),
+                       Message: fmt.Sprintf("service port not found: Service 
%s has no port %s",
+                               serviceNN, backend.ServicePort.String()),
+               }
        }
        tctx.Services[serviceNN] = &service
 
diff --git a/internal/controller/apisixroute_serviceport_test.go 
b/internal/controller/apisixroute_serviceport_test.go
new file mode 100644
index 00000000..6a308a8f
--- /dev/null
+++ b/internal/controller/apisixroute_serviceport_test.go
@@ -0,0 +1,173 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package controller
+
+import (
+       "context"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       corev1 "k8s.io/api/core/v1"
+       networkingv1 "k8s.io/api/networking/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime"
+       k8stypes "k8s.io/apimachinery/pkg/types"
+       "k8s.io/apimachinery/pkg/util/intstr"
+       clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+       ctrl "sigs.k8s.io/controller-runtime"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+       "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+       apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
+       "github.com/apache/apisix-ingress-controller/internal/controller/config"
+       "github.com/apache/apisix-ingress-controller/internal/manager/readiness"
+)
+
+const servicePortTestNamespace = "default"
+
+var servicePortRouteKey = k8stypes.NamespacedName{Namespace: 
servicePortTestNamespace, Name: "route"}
+
+// newServicePortFixture wires an ApisixRoute whose single backend names port
+// against a Service exposing servicePorts.
+func newServicePortFixture(
+       t *testing.T,
+       port intstr.IntOrString,
+       servicePorts []corev1.ServicePort,
+) (*ApisixRouteReconciler, *recordingProvider, *recordingUpdater) {
+       t.Helper()
+
+       scheme := runtime.NewScheme()
+       require.NoError(t, clientgoscheme.AddToScheme(scheme))
+       require.NoError(t, apiv2.AddToScheme(scheme))
+
+       ingressClass := &networkingv1.IngressClass{
+               ObjectMeta: metav1.ObjectMeta{Name: "apisix"},
+               Spec:       networkingv1.IngressClassSpec{Controller: 
config.GetControllerName()},
+       }
+       service := &corev1.Service{
+               ObjectMeta: metav1.ObjectMeta{Namespace: 
servicePortTestNamespace, Name: "backend"},
+               Spec:       corev1.ServiceSpec{ClusterIP: "10.0.0.1", Ports: 
servicePorts},
+       }
+       route := &apiv2.ApisixRoute{
+               ObjectMeta: metav1.ObjectMeta{Namespace: 
servicePortTestNamespace, Name: "route"},
+               Spec: apiv2.ApisixRouteSpec{
+                       IngressClassName: "apisix",
+                       HTTP: []apiv2.ApisixRouteHTTP{{
+                               Name:     "rule",
+                               Match:    apiv2.ApisixRouteHTTPMatch{Hosts: 
[]string{"crd.test"}, Paths: []string{"/*"}},
+                               Backends: 
[]apiv2.ApisixRouteHTTPBackend{{ServiceName: "backend", ServicePort: port}},
+                       }},
+               },
+       }
+
+       cli := fake.NewClientBuilder().WithScheme(scheme).
+               WithObjects([]client.Object{ingressClass, service, route}...).
+               WithStatusSubresource(route).
+               Build()
+
+       readier := readiness.NewReadinessManager(cli, logr.Discard())
+       require.NoError(t, readier.Start(context.Background()))
+
+       prov := &recordingProvider{}
+       updater := &recordingUpdater{}
+       return &ApisixRouteReconciler{
+               Client:   cli,
+               Scheme:   scheme,
+               Log:      logr.Discard(),
+               Provider: prov,
+               Updater:  updater,
+               Readier:  readier,
+       }, prov, updater
+}
+
+// acceptedCondition applies the recorded status update and returns the 
Accepted
+// condition it would have written.
+func acceptedCondition(t *testing.T, updater *recordingUpdater) 
metav1.Condition {
+       t.Helper()
+       require.Len(t, updater.updates, 1, "the reconcile must report a status")
+       mutated, ok := 
updater.updates[0].Mutator.Mutate(&apiv2.ApisixRoute{}).(*apiv2.ApisixRoute)
+       require.True(t, ok)
+       require.Len(t, mutated.Status.Conditions, 1)
+       return mutated.Status.Conditions[0]
+}
+
+// An empty servicePort is compared against Service port names, so it silently
+// matches a single-port Service that omits its port name. Nothing in the CRD
+// schema rejects it, so validateHTTPBackend is the only thing standing between
+// this value and a published route; the admission webhook runs the same check.
+func TestApisixRouteReconcile_EmptyServicePortIsRejected(t *testing.T) {
+       for name, ports := range map[string][]corev1.ServicePort{
+               "named port":   {{Name: "http", Port: 80, TargetPort: 
intstr.FromInt32(8080)}},
+               "unnamed port": {{Port: 80, TargetPort: 
intstr.FromInt32(8080)}},
+       } {
+               t.Run(name, func(t *testing.T) {
+                       r, prov, updater := newServicePortFixture(t, 
intstr.FromString(""), ports)
+
+                       _, err := r.Reconcile(context.Background(), 
ctrl.Request{NamespacedName: servicePortRouteKey})
+
+                       require.Error(t, err)
+                       assert.Contains(t, err.Error(), "servicePort must not 
be empty")
+                       assert.Zero(t, prov.updated, "a route with an 
unresolvable port must not be published")
+
+                       cond := acceptedCondition(t, updater)
+                       assert.Equal(t, metav1.ConditionFalse, cond.Status)
+                       assert.Contains(t, cond.Message, "servicePort must not 
be empty")
+               })
+       }
+}
+
+// A Service that exists but has no such port used to be reported as accepted 
and
+// published with no upstream node, which answers 503. The message also has to 
name
+// the port, not claim the Service is missing.
+func TestApisixRouteReconcile_UnknownServicePortIsRejected(t *testing.T) {
+       r, prov, updater := newServicePortFixture(t, intstr.FromString("https"),
+               []corev1.ServicePort{{Name: "http", Port: 80, TargetPort: 
intstr.FromInt32(8080)}})
+
+       _, err := r.Reconcile(context.Background(), 
ctrl.Request{NamespacedName: servicePortRouteKey})
+
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "service port not found")
+       assert.NotContains(t, err.Error(), "service not found",
+               "the Service resolves; only the port does not")
+       assert.Zero(t, prov.updated)
+
+       cond := acceptedCondition(t, updater)
+       assert.Equal(t, metav1.ConditionFalse, cond.Status)
+       assert.Equal(t, string(apiv2.ConditionReasonInvalidSpec), cond.Reason)
+}
+
+// A port that does resolve must still be published.
+func TestApisixRouteReconcile_ResolvableServicePortIsPublished(t *testing.T) {
+       for name, port := range map[string]intstr.IntOrString{
+               "by number": intstr.FromInt32(80),
+               "by name":   intstr.FromString("http"),
+       } {
+               t.Run(name, func(t *testing.T) {
+                       r, prov, updater := newServicePortFixture(t, port,
+                               []corev1.ServicePort{{Name: "http", Port: 80, 
TargetPort: intstr.FromInt32(8080)}})
+
+                       _, err := r.Reconcile(context.Background(), 
ctrl.Request{NamespacedName: servicePortRouteKey})
+
+                       require.NoError(t, err)
+                       assert.Equal(t, 1, prov.updated)
+                       assert.Equal(t, metav1.ConditionTrue, 
acceptedCondition(t, updater).Status)
+               })
+       }
+}

Reply via email to