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")
+}