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 f1ad295e fix: fall back to publishService for Gateway status addresses 
(#2846)
f1ad295e is described below

commit f1ad295e4efc312e5fea36b85f90d50f9401c60a
Author: Mohammad Izzraff Janius 
<[email protected]>
AuthorDate: Wed Aug 26 16:39:13 2026 +0900

    fix: fall back to publishService for Gateway status addresses (#2846)
---
 docs/en/latest/reference/example.md                |   3 +-
 internal/controller/gateway_controller.go          |  72 ++++++-
 .../gateway_controller_publishservice_test.go      | 216 +++++++++++++++++++++
 internal/controller/ingress_controller.go          |  35 ++--
 .../ingress_controller_publishservice_test.go      |  89 +++++++++
 internal/controller/utils.go                       |  69 +++++--
 internal/controller/utils_publishservice_test.go   |  41 ++++
 internal/utils/k8s.go                              |  18 ++
 test/e2e/gatewayapi/gateway.go                     | 179 +++++++++++++++++
 9 files changed, 672 insertions(+), 50 deletions(-)

diff --git a/docs/en/latest/reference/example.md 
b/docs/en/latest/reference/example.md
index 792fbddd..1656521d 100644
--- a/docs/en/latest/reference/example.md
+++ b/docs/en/latest/reference/example.md
@@ -1196,7 +1196,8 @@ spec:
   publishService: apisix-gateway
 ```
 
-When using `publishService`, the controller will use the endpoint of this 
Service to update the status information of the Ingress resource. The format 
can be either `namespace/svc-name` or simply `svc-name` if the default 
namespace is correctly set.
+When using `publishService`, the controller will use the endpoint of this 
Service to update the status information of the Ingress resource.
+The format can be either `namespace/svc-name` or simply `svc-name`, in which 
case the name resolves against the namespace of the GatewayProxy.
 
 - If the Service is of `LoadBalancer` type, the controller uses its external 
IP or hostname.
 - If the Service is of `ClusterIP` type, the controller propagates the 
hostname from any Ingress resources that reference that Service.
diff --git a/internal/controller/gateway_controller.go 
b/internal/controller/gateway_controller.go
index 7430e1ca..d6791d1e 100644
--- a/internal/controller/gateway_controller.go
+++ b/internal/controller/gateway_controller.go
@@ -23,6 +23,7 @@ import (
        "fmt"
        "net"
        "reflect"
+       "time"
 
        "github.com/go-logr/logr"
        corev1 "k8s.io/api/core/v1"
@@ -46,6 +47,10 @@ import (
        pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
 )
 
+// publishServiceRetryInterval polls an unresolvable publishService, since
+// Service events are not watched.
+const publishServiceRetryInterval = time.Minute
+
 // GatewayReconciler reconciles a Gateway object.
 type GatewayReconciler struct { //nolint:revive
        client.Client
@@ -161,6 +166,7 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (ct
        }
 
        conditionProgrammedStatus, conditionProgrammedMsg := true, "Programmed"
+       conditionProgrammedReason := gatewayv1.GatewayReasonProgrammed
 
        r.Log.Info("gateway has been accepted", "gateway", gateway.GetName())
        type conditionStatus struct {
@@ -186,7 +192,12 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (ct
                }
        }
 
-       var addrs []gatewayv1.GatewayStatusAddress
+       var (
+               addrs             []gatewayv1.GatewayStatusAddress
+               addrResolveFailed bool
+               addrResolveErr    error
+               addrRetryAfter    time.Duration
+       )
 
        rk := utils.NamespacedNameKind(gateway)
 
@@ -205,10 +216,24 @@ func (r *GatewayReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (ct
                        msg:    "gateway proxy not found",
                }
        } else {
-               for _, addr := range gatewayProxy.Spec.StatusAddress {
-                       if addr == "" {
-                               continue
+               statusAddresses, err := r.resolveStatusAddresses(ctx, 
&gatewayProxy)
+               if err != nil {
+                       addrResolveFailed = true
+                       if internaltypes.IsSomeReasonError(err, 
gatewayv1.GatewayReasonAddressNotAssigned) {
+                               // a config problem, not a controller failure: 
report it on the
+                               // Programmed condition instead of the 
reconcile error metric
+                               r.Log.Info("cannot resolve gateway status 
addresses",
+                                       "gateway", req.NamespacedName, 
"reason", err.Error())
+                               conditionProgrammedStatus = false
+                               conditionProgrammedMsg = err.Error()
+                               conditionProgrammedReason = 
gatewayv1.GatewayReasonAddressNotAssigned
+                               addrRetryAfter = publishServiceRetryInterval
+                       } else {
+                               r.Log.Error(err, "failed to resolve gateway 
status addresses", "gateway", req.NamespacedName)
+                               addrResolveErr = err
                        }
+               }
+               for _, addr := range statusAddresses {
                        addrType := gatewayv1.IPAddressType
                        if net.ParseIP(addr) == nil {
                                addrType = gatewayv1.HostnameAddressType
@@ -252,8 +277,8 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (ct
        }
 
        accepted := SetGatewayConditionAccepted(gateway, acceptStatus.status, 
acceptStatus.reason, acceptStatus.msg)
-       programmed := SetGatewayConditionProgrammed(gateway, 
conditionProgrammedStatus, conditionProgrammedMsg)
-       addressesChanged := !reflect.DeepEqual(gateway.Status.Addresses, addrs)
+       programmed := SetGatewayConditionProgrammed(gateway, 
conditionProgrammedStatus, conditionProgrammedReason, conditionProgrammedMsg)
+       addressesChanged := !addrResolveFailed && 
!reflect.DeepEqual(gateway.Status.Addresses, addrs)
        if accepted || programmed || addressesChanged || len(listenerStatuses) 
> 0 {
                if addressesChanged {
                        gateway.Status.Addresses = addrs
@@ -277,10 +302,41 @@ func (r *GatewayReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (ct
                        }),
                })
 
-               return ctrl.Result{}, nil
+               return ctrl.Result{RequeueAfter: addrRetryAfter}, addrResolveErr
+       }
+
+       return ctrl.Result{RequeueAfter: addrRetryAfter}, addrResolveErr
+}
+
+// resolveStatusAddresses returns the addresses to publish in
+// Gateway.status.addresses: the statically configured statusAddress if set,
+// otherwise the external addresses of the Service named by publishService.
+// A bare Service name resolves against the GatewayProxy's namespace.
+func (r *GatewayReconciler) resolveStatusAddresses(
+       ctx context.Context,
+       gatewayProxy *v1alpha1.GatewayProxy,
+) ([]string, error) {
+       if len(gatewayProxy.Spec.StatusAddress) > 0 {
+               return utils.Filter(gatewayProxy.Spec.StatusAddress, func(addr 
string) bool {
+                       return addr != ""
+               }), nil
+       }
+
+       if gatewayProxy.Spec.PublishService == "" {
+               return nil, nil
        }
 
-       return ctrl.Result{}, nil
+       // a bare name is resolved against the GatewayProxy's namespace
+       svc, err := resolvePublishService(ctx, r.Client, 
gatewayProxy.Spec.PublishService, gatewayProxy.GetNamespace())
+       if err != nil {
+               return nil, err
+       }
+       if svc.Spec.Type != corev1.ServiceTypeLoadBalancer {
+               r.Log.Info("publish service is not a LoadBalancer; no address 
to publish",
+                       "service", gatewayProxy.Spec.PublishService, "type", 
svc.Spec.Type)
+               return nil, nil
+       }
+       return serviceLoadBalancerAddresses(svc), nil
 }
 
 func (r *GatewayReconciler) matchesGatewayClass(obj client.Object) bool {
diff --git a/internal/controller/gateway_controller_publishservice_test.go 
b/internal/controller/gateway_controller_publishservice_test.go
new file mode 100644
index 00000000..116eacf5
--- /dev/null
+++ b/internal/controller/gateway_controller_publishservice_test.go
@@ -0,0 +1,216 @@
+// 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"
+       "net/http"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       corev1 "k8s.io/api/core/v1"
+       apierrors "k8s.io/apimachinery/pkg/api/errors"
+       "k8s.io/apimachinery/pkg/api/meta"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime"
+       k8stypes "k8s.io/apimachinery/pkg/types"
+       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"
+       "sigs.k8s.io/controller-runtime/pkg/client/interceptor"
+       gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       "github.com/apache/apisix-ingress-controller/internal/controller/config"
+       "github.com/apache/apisix-ingress-controller/internal/controller/status"
+       "github.com/apache/apisix-ingress-controller/internal/provider"
+)
+
+// recordingProvider counts data plane pushes so tests can assert whether a
+// reconcile reached Provider.Update.
+type recordingProvider struct {
+       updated int
+}
+
+func (p *recordingProvider) Update(context.Context, 
*provider.TranslateContext, client.Object) error {
+       p.updated++
+       return nil
+}
+func (p *recordingProvider) Delete(context.Context, client.Object) error { 
return nil }
+func (p *recordingProvider) Start(context.Context) error                 { 
return nil }
+func (p *recordingProvider) NeedLeaderElection() bool                    { 
return false }
+func (p *recordingProvider) Register(string, *http.ServeMux)             {}
+
+type recordingUpdater struct {
+       updates []status.Update
+}
+
+func (u *recordingUpdater) Update(update status.Update) { u.updates = 
append(u.updates, update) }
+
+var gatewayPreviousAddrs = func() []gatewayv1.GatewayStatusAddress {
+       addrType := gatewayv1.IPAddressType
+       return []gatewayv1.GatewayStatusAddress{{Type: &addrType, Value: 
"203.0.113.10"}}
+}()
+
+// newGatewayPublishServiceFixture builds a Gateway with previously published
+// addresses, wired to a GatewayProxy with the given publishService.
+func newGatewayPublishServiceFixture(
+       t *testing.T,
+       publishService string,
+       interceptorFuncs interceptor.Funcs,
+       extraObjects ...client.Object,
+) (*GatewayReconciler, *recordingProvider, *recordingUpdater) {
+       t.Helper()
+
+       scheme := runtime.NewScheme()
+       require.NoError(t, clientgoscheme.AddToScheme(scheme))
+       require.NoError(t, gatewayv1.Install(scheme))
+       require.NoError(t, v1alpha1.AddToScheme(scheme))
+
+       gatewayClass := &gatewayv1.GatewayClass{
+               ObjectMeta: metav1.ObjectMeta{Name: "apisix"},
+               Spec: gatewayv1.GatewayClassSpec{
+                       ControllerName: 
gatewayv1.GatewayController(config.ControllerConfig.ControllerName),
+               },
+       }
+       gatewayProxy := &v1alpha1.GatewayProxy{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: 
"proxy"},
+               Spec: v1alpha1.GatewayProxySpec{
+                       PublishService: publishService,
+               },
+       }
+       gateway := &gatewayv1.Gateway{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "gw"},
+               Spec: gatewayv1.GatewaySpec{
+                       GatewayClassName: "apisix",
+                       Infrastructure: &gatewayv1.GatewayInfrastructure{
+                               ParametersRef: 
&gatewayv1.LocalParametersReference{
+                                       Group: 
gatewayv1.Group(v1alpha1.GroupVersion.Group),
+                                       Kind:  KindGatewayProxy,
+                                       Name:  "proxy",
+                               },
+                       },
+               },
+               Status: gatewayv1.GatewayStatus{Addresses: 
gatewayPreviousAddrs},
+       }
+
+       objects := append([]client.Object{gatewayClass, gatewayProxy, gateway}, 
extraObjects...)
+       cli := fake.NewClientBuilder().WithScheme(scheme).
+               WithObjects(objects...).
+               WithStatusSubresource(gateway).
+               WithInterceptorFuncs(interceptorFuncs).
+               Build()
+
+       prov := &recordingProvider{}
+       updater := &recordingUpdater{}
+       return &GatewayReconciler{
+               Client:   cli,
+               Scheme:   scheme,
+               Log:      logr.Discard(),
+               Provider: prov,
+               Updater:  updater,
+       }, prov, updater
+}
+
+func reconcileGateway(t *testing.T, r *GatewayReconciler) (ctrl.Result, error) 
{
+       t.Helper()
+       return r.Reconcile(context.Background(),
+               ctrl.Request{NamespacedName: k8stypes.NamespacedName{Namespace: 
"default", Name: "gw"}})
+}
+
+// mutatedGatewayStatus applies the single recorded status update and returns
+// the Gateway status it would have written.
+func mutatedGatewayStatus(t *testing.T, updater *recordingUpdater) 
gatewayv1.GatewayStatus {
+       t.Helper()
+       require.Len(t, updater.updates, 1, "conditions must still be written")
+       mutated, ok := 
updater.updates[0].Mutator.Mutate(&gatewayv1.Gateway{}).(*gatewayv1.Gateway)
+       require.True(t, ok)
+       return mutated.Status
+}
+
+// A missing publish Service must not block the data plane push or count as a
+// reconcile error: it surfaces as Programmed=False/AddressNotAssigned and a
+// requeue picks the addresses up once the Service exists.
+func TestGatewayReconcilePublishServiceNotFound(t *testing.T) {
+       r, prov, updater := newGatewayPublishServiceFixture(t, "missing-svc", 
interceptor.Funcs{})
+
+       result, err := reconcileGateway(t, r)
+
+       assert.NoError(t, err, "a missing publish Service must not count as a 
reconcile error")
+       assert.Equal(t, publishServiceRetryInterval, result.RequeueAfter,
+               "must poll for the Service until a Service watch makes this 
event-driven")
+       assert.Equal(t, 1, prov.updated, "Provider.Update must run even when 
the publish Service cannot be resolved")
+
+       gotStatus := mutatedGatewayStatus(t, updater)
+       programmed := meta.FindStatusCondition(gotStatus.Conditions, 
string(gatewayv1.GatewayConditionProgrammed))
+       require.NotNil(t, programmed)
+       assert.Equal(t, metav1.ConditionFalse, programmed.Status)
+       assert.Equal(t, string(gatewayv1.GatewayReasonAddressNotAssigned), 
programmed.Reason)
+       assert.Contains(t, programmed.Message, "missing-svc")
+       assert.True(t, meta.IsStatusConditionTrue(gotStatus.Conditions, 
string(gatewayv1.GatewayConditionAccepted)),
+               "an unresolvable publish Service must not flip Accepted to 
False")
+       assert.Equal(t, gatewayPreviousAddrs, gotStatus.Addresses,
+               "previously published addresses must survive a resolve failure")
+}
+
+// An invalid publishService format is handled like NotFound: surfaced on the
+// Programmed condition, not the reconcile error.
+func TestGatewayReconcilePublishServiceBadFormat(t *testing.T) {
+       r, prov, updater := newGatewayPublishServiceFixture(t, "a/b/c", 
interceptor.Funcs{})
+
+       result, err := reconcileGateway(t, r)
+
+       assert.NoError(t, err, "an invalid publishService value must not count 
as a reconcile error")
+       assert.Equal(t, publishServiceRetryInterval, result.RequeueAfter)
+       assert.Equal(t, 1, prov.updated)
+
+       gotStatus := mutatedGatewayStatus(t, updater)
+       programmed := meta.FindStatusCondition(gotStatus.Conditions, 
string(gatewayv1.GatewayConditionProgrammed))
+       require.NotNil(t, programmed)
+       assert.Equal(t, metav1.ConditionFalse, programmed.Status)
+       assert.Equal(t, string(gatewayv1.GatewayReasonAddressNotAssigned), 
programmed.Reason)
+       assert.Contains(t, programmed.Message, "a/b/c")
+}
+
+// A non-NotFound lookup failure is a genuine API failure: returned as a
+// reconcile error, without blaming the user's config on Programmed.
+func TestGatewayReconcilePublishServiceAPIFailure(t *testing.T) {
+       apiDown := apierrors.NewInternalError(context.DeadlineExceeded)
+       r, prov, updater := newGatewayPublishServiceFixture(t, "some-svc", 
interceptor.Funcs{
+               Get: func(ctx context.Context, cli client.WithWatch, key 
client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
+                       if _, isService := obj.(*corev1.Service); isService {
+                               return apiDown
+                       }
+                       return cli.Get(ctx, key, obj, opts...)
+               },
+       })
+
+       result, err := reconcileGateway(t, r)
+
+       assert.ErrorIs(t, err, apiDown, "an API failure must be returned for 
backoff retry")
+       assert.Equal(t, ctrl.Result{}, result)
+       assert.Equal(t, 1, prov.updated, "Provider.Update must run even when 
the address lookup fails")
+
+       gotStatus := mutatedGatewayStatus(t, updater)
+       assert.True(t, meta.IsStatusConditionTrue(gotStatus.Conditions, 
string(gatewayv1.GatewayConditionProgrammed)),
+               "an API failure is not a user configuration problem")
+       assert.Equal(t, gatewayPreviousAddrs, gotStatus.Addresses)
+}
diff --git a/internal/controller/ingress_controller.go 
b/internal/controller/ingress_controller.go
index e3692373..f019076f 100644
--- a/internal/controller/ingress_controller.go
+++ b/internal/controller/ingress_controller.go
@@ -707,35 +707,24 @@ func (r *IngressReconciler) updateStatus(ctx 
context.Context, tctx *provider.Tra
                // 2. if the IngressStatusAddress is not configured, try to use 
the PublishService
                publishService := gatewayProxy.Spec.PublishService
                if publishService != "" {
-                       // parse the namespace/name format
-                       namespace, name, err := 
SplitMetaNamespaceKey(publishService)
+                       // a bare name resolves against the GatewayProxy's 
namespace, where
+                       // the publish Service lives, matching the Gateway API 
path
+                       svc, err := resolvePublishService(ctx, r.Client, 
publishService, gatewayProxy.GetNamespace())
                        if err != nil {
-                               return fmt.Errorf("invalid 
ingress-publish-service format: %s, expected format: namespace/name", 
publishService)
-                       }
-                       // if the namespace is not specified, use the ingress 
namespace
-                       if namespace == "" {
-                               namespace = ingress.Namespace
-                       }
-
-                       svc := &corev1.Service{}
-                       if err := r.Get(ctx, client.ObjectKey{Namespace: 
namespace, Name: name}, svc); err != nil {
-                               return fmt.Errorf("failed to get publish 
service %s: %w", publishService, err)
+                               return err
                        }
+                       namespace, name := svc.Namespace, svc.Name
 
                        switch svc.Spec.Type {
                        case corev1.ServiceTypeLoadBalancer:
-                               // get the LoadBalancer IP and Hostname of the 
service
-                               for _, ip := range 
svc.Status.LoadBalancer.Ingress {
-                                       if ip.IP != "" {
-                                               loadBalancerStatus.Ingress = 
append(loadBalancerStatus.Ingress, networkingv1.IngressLoadBalancerIngress{
-                                                       IP: ip.IP,
-                                               })
-                                       }
-                                       if ip.Hostname != "" {
-                                               loadBalancerStatus.Ingress = 
append(loadBalancerStatus.Ingress, networkingv1.IngressLoadBalancerIngress{
-                                                       Hostname: ip.Hostname,
-                                               })
+                               for _, addr := range 
serviceLoadBalancerAddresses(svc) {
+                                       lbIngress := 
networkingv1.IngressLoadBalancerIngress{}
+                                       if net.ParseIP(addr) != nil {
+                                               lbIngress.IP = addr
+                                       } else {
+                                               lbIngress.Hostname = addr
                                        }
+                                       loadBalancerStatus.Ingress = 
append(loadBalancerStatus.Ingress, lbIngress)
                                }
                        case corev1.ServiceTypeClusterIP:
                                // For ClusterIP services, propagate load 
balancer status from any other
diff --git a/internal/controller/ingress_controller_publishservice_test.go 
b/internal/controller/ingress_controller_publishservice_test.go
new file mode 100644
index 00000000..0794fdbf
--- /dev/null
+++ b/internal/controller/ingress_controller_publishservice_test.go
@@ -0,0 +1,89 @@
+// 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"
+       clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+       "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       "github.com/apache/apisix-ingress-controller/internal/provider"
+       "github.com/apache/apisix-ingress-controller/internal/utils"
+)
+
+// A bare publishService name must resolve against the GatewayProxy's
+// namespace, matching the Gateway API path: the publish Service lives next to
+// the GatewayProxy, not in the namespace of whichever Ingress is being
+// reconciled.
+func TestIngressUpdateStatusBarePublishServiceUsesGatewayProxyNamespace(t 
*testing.T) {
+       scheme := runtime.NewScheme()
+       require.NoError(t, clientgoscheme.AddToScheme(scheme))
+       require.NoError(t, v1alpha1.AddToScheme(scheme))
+
+       gatewayProxy := v1alpha1.GatewayProxy{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "infra", Name: 
"proxy"},
+               Spec: v1alpha1.GatewayProxySpec{
+                       PublishService: "apisix-lb",
+               },
+       }
+       publishSvc := &corev1.Service{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "infra", Name: 
"apisix-lb"},
+               Spec:       corev1.ServiceSpec{Type: 
corev1.ServiceTypeLoadBalancer},
+               Status: corev1.ServiceStatus{
+                       LoadBalancer: corev1.LoadBalancerStatus{
+                               Ingress: []corev1.LoadBalancerIngress{{IP: 
"203.0.113.20"}},
+                       },
+               },
+       }
+       ingressClass := &networkingv1.IngressClass{
+               ObjectMeta: metav1.ObjectMeta{Name: "apisix"},
+       }
+       ingress := &networkingv1.Ingress{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "ing"},
+       }
+
+       cli := fake.NewClientBuilder().WithScheme(scheme).
+               WithObjects(publishSvc, ingressClass, ingress).
+               Build()
+
+       updater := &recordingUpdater{}
+       r := &IngressReconciler{Client: cli, Log: logr.Discard(), Updater: 
updater}
+
+       tctx := provider.NewDefaultTranslateContext(context.Background())
+       tctx.GatewayProxies[utils.NamespacedNameKind(ingressClass)] = 
gatewayProxy
+
+       err := r.updateStatus(context.Background(), tctx, ingress, ingressClass)
+       require.NoError(t, err,
+               "a bare name must resolve in the GatewayProxy's namespace, not 
the Ingress's")
+
+       require.Len(t, updater.updates, 1)
+       mutated, ok := 
updater.updates[0].Mutator.Mutate(ingress).(*networkingv1.Ingress)
+       require.True(t, ok)
+       assert.Equal(t, []networkingv1.IngressLoadBalancerIngress{{IP: 
"203.0.113.20"}},
+               mutated.Status.LoadBalancer.Ingress)
+}
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index 85108fa7..db6894f4 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -231,11 +231,11 @@ func SetGatewayListenerConditionResolvedRefs(gw 
*gatewayv1.Gateway, listenerName
        return
 }
 
-func SetGatewayConditionProgrammed(gw *gatewayv1.Gateway, status bool, message 
string) (ok bool) {
+func SetGatewayConditionProgrammed(gw *gatewayv1.Gateway, status bool, reason 
gatewayv1.GatewayConditionReason, message string) (ok bool) {
        condition := metav1.Condition{
                Type:               
string(gatewayv1.GatewayConditionProgrammed),
                Status:             ConditionStatus(status),
-               Reason:             string(gatewayv1.GatewayReasonProgrammed),
+               Reason:             string(reason),
                ObservedGeneration: gw.GetGeneration(),
                Message:            message,
                LastTransitionTime: metav1.Now(),
@@ -1281,22 +1281,6 @@ func validateListenerFrontendValidation(
        }
 }
 
-// SplitMetaNamespaceKey returns the namespace and name that
-// MetaNamespaceKeyFunc encoded into key.
-func SplitMetaNamespaceKey(key string) (namespace, name string, err error) {
-       parts := strings.Split(key, "/")
-       switch len(parts) {
-       case 1:
-               // name only, no namespace
-               return "", parts[0], nil
-       case 2:
-               // namespace and name
-               return parts[0], parts[1], nil
-       }
-
-       return "", "", fmt.Errorf("unexpected key format: %q", key)
-}
-
 func ProcessGatewayProxy(r client.Client, log logr.Logger, tctx 
*provider.TranslateContext, gateway *gatewayv1.Gateway, rk 
types.NamespacedNameKind) error {
        if gateway == nil {
                return nil
@@ -1999,3 +1983,52 @@ func deduplicateGatewayStatusAddresses(addrs 
[]gatewayv1.GatewayStatusAddress) [
                return a.Value == b.Value
        })
 }
+
+// resolvePublishService looks up the Service named by publishService, given as
+// "namespace/name" or as a bare name resolved against defaultNamespace.
+// A value that cannot work (bad format, no such Service) comes back as a
+// ReasonError with GatewayReasonAddressNotAssigned.
+func resolvePublishService(
+       ctx context.Context,
+       c client.Client,
+       publishService, defaultNamespace string,
+) (*corev1.Service, error) {
+       namespace, name, err := utils.SplitMetaNamespaceKey(publishService)
+       if err != nil {
+               return nil, types.ReasonError{
+                       Reason:  
string(gatewayv1.GatewayReasonAddressNotAssigned),
+                       Message: fmt.Sprintf("invalid publish service format: 
%s, expected format: namespace/name", publishService),
+               }
+       }
+       // if the namespace is not specified, use the caller's namespace
+       if namespace == "" {
+               namespace = defaultNamespace
+       }
+
+       svc := &corev1.Service{}
+       if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: 
name}, svc); err != nil {
+               if k8serrors.IsNotFound(err) {
+                       return nil, types.ReasonError{
+                               Reason:  
string(gatewayv1.GatewayReasonAddressNotAssigned),
+                               Message: fmt.Sprintf("publish service %s/%s not 
found", namespace, name),
+                       }
+               }
+               return nil, fmt.Errorf("failed to get publish service %s: %w", 
publishService, err)
+       }
+       return svc, nil
+}
+
+// serviceLoadBalancerAddresses flattens the Service's LoadBalancer ingress
+// entries into address strings, keeping per-entry order: IP before hostname.
+func serviceLoadBalancerAddresses(svc *corev1.Service) []string {
+       var addrs []string
+       for _, ing := range svc.Status.LoadBalancer.Ingress {
+               if ing.IP != "" {
+                       addrs = append(addrs, ing.IP)
+               }
+               if ing.Hostname != "" {
+                       addrs = append(addrs, ing.Hostname)
+               }
+       }
+       return addrs
+}
diff --git a/internal/controller/utils_publishservice_test.go 
b/internal/controller/utils_publishservice_test.go
new file mode 100644
index 00000000..111da78c
--- /dev/null
+++ b/internal/controller/utils_publishservice_test.go
@@ -0,0 +1,41 @@
+// 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 (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       corev1 "k8s.io/api/core/v1"
+)
+
+func TestServiceLoadBalancerAddresses(t *testing.T) {
+       svc := &corev1.Service{
+               Status: corev1.ServiceStatus{
+                       LoadBalancer: corev1.LoadBalancerStatus{
+                               Ingress: []corev1.LoadBalancerIngress{
+                                       {IP: "10.0.0.1", Hostname: 
"a.example.com"},
+                                       {Hostname: "b.example.com"},
+                                       {},
+                               },
+                       },
+               },
+       }
+       assert.Equal(t, []string{"10.0.0.1", "a.example.com", "b.example.com"}, 
serviceLoadBalancerAddresses(svc))
+       assert.Nil(t, serviceLoadBalancerAddresses(&corev1.Service{}))
+}
diff --git a/internal/utils/k8s.go b/internal/utils/k8s.go
index 0d58f301..0e8ba7c6 100644
--- a/internal/utils/k8s.go
+++ b/internal/utils/k8s.go
@@ -18,8 +18,10 @@
 package utils
 
 import (
+       "fmt"
        "net"
        "regexp"
+       "strings"
 
        networkingv1 "k8s.io/api/networking/v1"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -111,3 +113,19 @@ func GetIngressClassParametersNamespace(ingressClass 
networkingv1.IngressClass)
        }
        return namespace
 }
+
+// SplitMetaNamespaceKey returns the namespace and name that
+// MetaNamespaceKeyFunc encoded into key.
+func SplitMetaNamespaceKey(key string) (namespace, name string, err error) {
+       parts := strings.Split(key, "/")
+       switch len(parts) {
+       case 1:
+               // name only, no namespace
+               return "", parts[0], nil
+       case 2:
+               // namespace and name
+               return parts[0], parts[1], nil
+       }
+
+       return "", "", fmt.Errorf("unexpected key format: %q", key)
+}
diff --git a/test/e2e/gatewayapi/gateway.go b/test/e2e/gatewayapi/gateway.go
index d9370070..5d169704 100644
--- a/test/e2e/gatewayapi/gateway.go
+++ b/test/e2e/gatewayapi/gateway.go
@@ -26,6 +26,7 @@ import (
        . "github.com/onsi/ginkgo/v2"
        . "github.com/onsi/gomega"
        "github.com/stretchr/testify/assert"
+       corev1 "k8s.io/api/core/v1"
        k8stypes "k8s.io/apimachinery/pkg/types"
        "k8s.io/utils/ptr"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
@@ -831,4 +832,182 @@ spec:
                        assertGatewayAddress(gatewayName, updatedAddr, 
gatewayv1.HostnameAddressType)
                })
        })
+
+       Context("Gateway Status Address from publishService", func() {
+               var gatewayProxyWithPublishServiceYaml = `
+apiVersion: apisix.apache.org/v1alpha1
+kind: GatewayProxy
+metadata:
+  name: apisix-proxy-config
+  namespace: %s
+spec:
+  publishService: %s/%s
+  provider:
+    type: ControlPlane
+    controlPlane:
+      endpoints:
+      - %s
+      auth:
+        type: AdminKey
+        adminKey:
+          value: "%s"
+`
+               var defaultGatewayClass = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: GatewayClass
+metadata:
+  name: %s
+spec:
+  controllerName: "%s"
+`
+               var defaultGateway = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+  name: %s
+spec:
+  gatewayClassName: %s
+  listeners:
+  - name: http
+    protocol: HTTP
+    port: 80
+  infrastructure:
+    parametersRef:
+      group: apisix.apache.org
+      kind: GatewayProxy
+      name: apisix-proxy-config
+`
+               const publishServiceName = "apisix-publish-svc"
+
+               // createPublishService creates a LoadBalancer Service and, 
because kind has
+               // no cloud provider to do it, writes the external address into 
its status.
+               createPublishService := func(lbIngress 
...corev1.LoadBalancerIngress) {
+                       svcYaml := fmt.Sprintf(`
+apiVersion: v1
+kind: Service
+metadata:
+  name: %s
+  namespace: %s
+spec:
+  type: LoadBalancer
+  selector:
+    app: httpbin
+  ports:
+  - port: 80
+    targetPort: 80
+`, publishServiceName, s.Namespace())
+                       Expect(s.CreateResourceFromStringWithNamespace(svcYaml, 
s.Namespace())).
+                               NotTo(HaveOccurred(), "creating publish 
Service")
+                       setPublishServiceAddress(s, publishServiceName, 
lbIngress...)
+               }
+
+               createGatewayClassAndGateway := func(gatewayClassName, 
gatewayName string) {
+                       By("create GatewayClass")
+                       Expect(s.CreateResourceFromStringWithNamespace(
+                               fmt.Sprintf(defaultGatewayClass, 
gatewayClassName, s.GetControllerName()), ""),
+                       ).NotTo(HaveOccurred(), "creating GatewayClass")
+
+                       By("create Gateway")
+                       Expect(s.CreateResourceFromStringWithNamespace(
+                               fmt.Sprintf(defaultGateway, gatewayName, 
gatewayClassName), s.Namespace()),
+                       ).NotTo(HaveOccurred(), "creating Gateway")
+               }
+
+               assertGatewayAddresses := func(gatewayName string, expected 
...gatewayv1.GatewayStatusAddress) {
+                       s.RetryAssertion(func() error {
+                               var gateway gatewayv1.Gateway
+                               if err := 
s.GetKubeClient().Get(context.Background(), k8stypes.NamespacedName{
+                                       Name:      gatewayName,
+                                       Namespace: s.Namespace(),
+                               }, &gateway); err != nil {
+                                       return err
+                               }
+                               addrs := gateway.Status.Addresses
+                               if len(addrs) != len(expected) {
+                                       return fmt.Errorf("expected %d status 
addresses, got %d: %+v", len(expected), len(addrs), addrs)
+                               }
+                               for i, want := range expected {
+                                       if addrs[i].Value != want.Value {
+                                               return fmt.Errorf("address %d: 
expected value %s, got %s", i, want.Value, addrs[i].Value)
+                                       }
+                                       if addrs[i].Type == nil {
+                                               return fmt.Errorf("address %d: 
expected type to be set, got nil", i)
+                                       }
+                                       if *addrs[i].Type != *want.Type {
+                                               return fmt.Errorf("address %d: 
expected type %s, got %s", i, *want.Type, *addrs[i].Type)
+                                       }
+                               }
+                               return nil
+                       }).ShouldNot(HaveOccurred(), "check Gateway status 
addresses")
+               }
+
+               It("falls back to publishService when statusAddress is empty", 
func() {
+                       By("create LoadBalancer publish Service with an IP and 
a hostname")
+                       createPublishService(
+                               corev1.LoadBalancerIngress{IP: "10.99.88.77"},
+                               corev1.LoadBalancerIngress{Hostname: 
"lb.example.com"},
+                       )
+
+                       By("create GatewayProxy with publishService and no 
statusAddress")
+                       
Expect(s.CreateResourceFromString(fmt.Sprintf(gatewayProxyWithPublishServiceYaml,
+                               s.Namespace(), s.Namespace(), 
publishServiceName, s.Deployer.GetAdminEndpoint(), s.AdminKey()),
+                       )).NotTo(HaveOccurred(), "creating GatewayProxy")
+
+                       createGatewayClassAndGateway(s.Namespace(), 
s.Namespace())
+
+                       By("check Gateway status addresses come from the 
publish Service")
+                       assertGatewayAddresses(s.Namespace(),
+                               gatewayv1.GatewayStatusAddress{Type: 
ptr.To(gatewayv1.IPAddressType), Value: "10.99.88.77"},
+                               gatewayv1.GatewayStatusAddress{Type: 
ptr.To(gatewayv1.HostnameAddressType), Value: "lb.example.com"},
+                       )
+               })
+
+               It("prefers statusAddress over publishService when both are 
set", func() {
+                       By("create LoadBalancer publish Service")
+                       createPublishService(corev1.LoadBalancerIngress{IP: 
"10.99.88.77"})
+
+                       By("create GatewayProxy with both statusAddress and 
publishService")
+                       Expect(s.CreateResourceFromString(fmt.Sprintf(`
+apiVersion: apisix.apache.org/v1alpha1
+kind: GatewayProxy
+metadata:
+  name: apisix-proxy-config
+  namespace: %s
+spec:
+  statusAddress:
+  - 192.168.1.100
+  publishService: %s/%s
+  provider:
+    type: ControlPlane
+    controlPlane:
+      endpoints:
+      - %s
+      auth:
+        type: AdminKey
+        adminKey:
+          value: "%s"
+`, s.Namespace(), s.Namespace(), publishServiceName, 
s.Deployer.GetAdminEndpoint(), s.AdminKey()),
+                       )).NotTo(HaveOccurred(), "creating GatewayProxy")
+
+                       createGatewayClassAndGateway(s.Namespace(), 
s.Namespace())
+
+                       By("check only the static statusAddress is published")
+                       assertGatewayAddresses(s.Namespace(),
+                               gatewayv1.GatewayStatusAddress{Type: 
ptr.To(gatewayv1.IPAddressType), Value: "192.168.1.100"})
+               })
+       })
 })
+
+// setPublishServiceAddress writes lbIngress into the Service's
+// status.loadBalancer.ingress, standing in for the cloud provider that assigns
+// a LoadBalancer address in a real cluster.
+func setPublishServiceAddress(s *scaffold.Scaffold, name string, lbIngress 
...corev1.LoadBalancerIngress) {
+       var svc corev1.Service
+       Expect(s.GetKubeClient().Get(context.Background(), 
k8stypes.NamespacedName{
+               Name:      name,
+               Namespace: s.Namespace(),
+       }, &svc)).NotTo(HaveOccurred(), "getting publish Service")
+       svc.Status.LoadBalancer.Ingress = lbIngress
+       Expect(s.GetKubeClient().Status().Update(context.Background(), &svc)).
+               NotTo(HaveOccurred(), "updating publish Service status")
+}

Reply via email to