Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/en/latest/reference/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,8 @@ spec:
- 10.24.87.13
```

Each entry in `statusAddress` can be an IP address or a hostname. The controller automatically sets the address type on the Gateway status — `IPAddress` for valid IPs and `Hostname` for everything else.

</TabItem>

<TabItem value="ingress">
Expand Down Expand Up @@ -1284,6 +1286,8 @@ spec:
- 10.24.87.13
```

Each entry in `statusAddress` can be an IP address or a hostname. The controller automatically sets the `IP` field for valid IPs and the `Hostname` field for everything else in the Ingress load balancer status.

To configure the `publishService`:

```yaml
Expand All @@ -1305,7 +1309,11 @@ spec:
publishService: apisix-ee-3-gateway-gateway
```

When using `publishService`, make sure your gateway Service is of `LoadBalancer` type the address can be populated. 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.

</TabItem>

Expand Down
97 changes: 81 additions & 16 deletions internal/controller/gateway_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import (
"context"
"errors"
"fmt"
"net"
"reflect"
"time"

"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
Expand All @@ -44,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
Expand Down Expand Up @@ -159,6 +166,7 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
}

conditionProgrammedStatus, conditionProgrammedMsg := true, "Programmed"
conditionProgrammedReason := string(gatewayv1.GatewayReasonProgrammed)

r.Log.Info("gateway has been accepted", "gateway", gateway.GetName())
type conditionStatus struct {
Expand All @@ -181,7 +189,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)

Expand All @@ -192,20 +205,40 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
msg: "gateway proxy not found",
}
} else {
if len(gateway.Status.Addresses) != len(gatewayProxy.Spec.StatusAddress) {
for _, addr := range gatewayProxy.Spec.StatusAddress {
if addr == "" {
continue
}
addrs = append(addrs,
gatewayv1.GatewayStatusAddress{
Value: addr,
},
)
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 = string(gatewayv1.GatewayReasonAddressNotAssigned)
addrRetryAfter = publishServiceRetryInterval
} else {
r.Log.Error(err, "failed to resolve gateway status addresses", "gateway", req.NamespacedName)
addrResolveErr = err
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for _, addr := range statusAddresses {
addrType := gatewayv1.IPAddressType
if net.ParseIP(addr) == nil {
addrType = gatewayv1.HostnameAddressType
}
addrs = append(addrs,
gatewayv1.GatewayStatusAddress{
Type: &addrType,
Value: addr,
},
)
}
}

// deduplicate in case statusAddress contains repeated values
addrs = deduplicateGatewayStatusAddresses(addrs)

listenerStatuses, err := getListenerStatus(ctx, r.Client, gateway)
if err != nil {
r.Log.Error(err, "failed to get listener status", "gateway", req.NamespacedName)
Expand All @@ -220,9 +253,10 @@ func (r *GatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
}

accepted := SetGatewayConditionAccepted(gateway, acceptStatus.status, acceptStatus.msg)
programmed := SetGatewayConditionProgrammed(gateway, conditionProgrammedStatus, conditionProgrammedMsg)
if accepted || programmed || len(addrs) > 0 || len(listenerStatuses) > 0 {
if len(addrs) > 0 {
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
}
if len(listenerStatuses) > 0 {
Expand All @@ -244,10 +278,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{}, nil
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
}

// 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 {
Expand Down
216 changes: 216 additions & 0 deletions internal/controller/gateway_controller_publishservice_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading