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

bzp2010 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 f14782d3 feat: report dp instance unavailable to gateway proxy (#2856)
f14782d3 is described below

commit f14782d39f1a922241d698319ad67420cd4b2393
Author: Zeping Bai <[email protected]>
AuthorDate: Mon Sep 14 14:03:48 2026 +0800

    feat: report dp instance unavailable to gateway proxy (#2856)
---
 .github/workflows/apisix-e2e-test.yml              |   2 +-
 api/v1alpha1/gatewayproxy_types.go                 |  14 +
 api/v1alpha1/zz_generated.deepcopy.go              |  23 ++
 .../bases/apisix.apache.org_gatewayproxies.yaml    |  71 ++++
 config/manager/kustomization.yaml                  |   2 +-
 config/rbac/role.yaml                              |   1 +
 docs/en/latest/reference/api-reference.md          |   2 +
 docs/en/latest/reference/troubleshoot.md           |  34 ++
 internal/adc/client/client.go                      |   2 +-
 internal/adc/client/executor.go                    |  12 +-
 internal/adc/client/executor_test.go               |  40 ++
 internal/controller/status/updater.go              |   6 +
 internal/controller/status/updater_test.go         | 146 +++++++
 internal/manager/controllers.go                    |   1 +
 internal/manager/run.go                            |   2 +
 internal/provider/apisix/provider.go               |  70 ++--
 internal/provider/apisix/status.go                 | 328 ++++++++++------
 internal/provider/apisix/status_test.go            | 437 +++++++++++++++++++++
 internal/provider/options.go                       |  14 +
 test/e2e/crds/v2/route.go                          |  19 +-
 test/e2e/crds/v2/status.go                         | 130 +++++-
 test/e2e/framework/manifests/ingress.yaml          |   3 +-
 test/e2e/gatewayapi/status.go                      |  11 +-
 23 files changed, 1201 insertions(+), 169 deletions(-)

diff --git a/.github/workflows/apisix-e2e-test.yml 
b/.github/workflows/apisix-e2e-test.yml
index 5c7faf60..1a00f63b 100644
--- a/.github/workflows/apisix-e2e-test.yml
+++ b/.github/workflows/apisix-e2e-test.yml
@@ -30,7 +30,7 @@ concurrency:
   cancel-in-progress: true
 
 env:
-  ADC_RUST_VERSION: "0.30.2"
+  ADC_RUST_VERSION: "0.30.3"
 
 jobs:
   e2e-test:
diff --git a/api/v1alpha1/gatewayproxy_types.go 
b/api/v1alpha1/gatewayproxy_types.go
index f9a5aa8e..ee5d5621 100644
--- a/api/v1alpha1/gatewayproxy_types.go
+++ b/api/v1alpha1/gatewayproxy_types.go
@@ -170,6 +170,7 @@ type ProviderService struct {
 }
 
 // +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
 // GatewayProxy defines configuration for the gateway proxy instances used to 
route traffic to services.
 type GatewayProxy struct {
        metav1.TypeMeta   `json:",inline"`
@@ -178,6 +179,19 @@ type GatewayProxy struct {
        // GatewayProxySpec defines configuration of gateway proxy instances,
        // including networking settings, global plugins, and plugin metadata.
        Spec GatewayProxySpec `json:"spec,omitempty"`
+
+       // Status defines the current state of Gateway Proxy.
+       //
+       // +kubebuilder:default={conditions: {{type: "DataPlaneAvailable", 
status: "Unknown", reason:"Pending", message:"Waiting for controller", 
lastTransitionTime: "1970-01-01T00:00:00Z"}}}
+       // +optional
+       Status GatewayProxyStatus `json:"status,omitempty"`
+}
+
+// GatewayProxyStatus defines the observed state of GatewayProxy.
+type GatewayProxyStatus struct {
+       // Conditions describe the current state of the data plane instances 
this GatewayProxy addresses.
+       // +optional
+       Conditions []metav1.Condition `json:"conditions,omitempty"`
 }
 
 // +kubebuilder:object:root=true
diff --git a/api/v1alpha1/zz_generated.deepcopy.go 
b/api/v1alpha1/zz_generated.deepcopy.go
index 8b9c5438..484e061d 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -462,6 +462,7 @@ func (in *GatewayProxy) DeepCopyInto(out *GatewayProxy) {
        out.TypeMeta = in.TypeMeta
        in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
        in.Spec.DeepCopyInto(&out.Spec)
+       in.Status.DeepCopyInto(&out.Status)
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, 
creating a new GatewayProxy.
@@ -589,6 +590,28 @@ func (in *GatewayProxySpec) DeepCopy() *GatewayProxySpec {
        return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, 
writing into out. in must be non-nil.
+func (in *GatewayProxyStatus) DeepCopyInto(out *GatewayProxyStatus) {
+       *out = *in
+       if in.Conditions != nil {
+               in, out := &in.Conditions, &out.Conditions
+               *out = make([]metav1.Condition, len(*in))
+               for i := range *in {
+                       (*in)[i].DeepCopyInto(&(*out)[i])
+               }
+       }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, 
creating a new GatewayProxyStatus.
+func (in *GatewayProxyStatus) DeepCopy() *GatewayProxyStatus {
+       if in == nil {
+               return nil
+       }
+       out := new(GatewayProxyStatus)
+       in.DeepCopyInto(out)
+       return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, 
writing into out. in must be non-nil.
 func (in *GatewayRef) DeepCopyInto(out *GatewayRef) {
        *out = *in
diff --git a/config/crd/bases/apisix.apache.org_gatewayproxies.yaml 
b/config/crd/bases/apisix.apache.org_gatewayproxies.yaml
index b2865255..0efae1cf 100644
--- a/config/crd/bases/apisix.apache.org_gatewayproxies.yaml
+++ b/config/crd/bases/apisix.apache.org_gatewayproxies.yaml
@@ -203,6 +203,77 @@ spec:
             required:
             - provider
             type: object
+          status:
+            default:
+              conditions:
+              - lastTransitionTime: "1970-01-01T00:00:00Z"
+                message: Waiting for controller
+                reason: Pending
+                status: Unknown
+                type: DataPlaneAvailable
+            description: Status defines the current state of Gateway Proxy.
+            properties:
+              conditions:
+                description: Conditions describe the current state of the data 
plane
+                  instances this GatewayProxy addresses.
+                items:
+                  description: Condition contains details for one aspect of 
the current
+                    state of this API Resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        lastTransitionTime is the last time the condition 
transitioned from one status to another.
+                        This should be when the underlying condition changed.  
If that is not known, then using the time when the API field changed is 
acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        message is a human readable message indicating details 
about the transition.
+                        This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        observedGeneration represents the .metadata.generation 
that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, 
but the .status.conditions[x].observedGeneration is 9, the condition is out of 
date
+                        with respect to the current state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: |-
+                        reason contains a programmatic identifier indicating 
the reason for the condition's last transition.
+                        Producers of specific condition types may define 
expected values and meanings for this field,
+                        and whether the values are considered a guaranteed API.
+                        The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, 
False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in 
foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: 
^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+            type: object
         type: object
     served: true
     storage: true
+    subresources:
+      status: {}
diff --git a/config/manager/kustomization.yaml 
b/config/manager/kustomization.yaml
index 12daa524..7410dcd1 100644
--- a/config/manager/kustomization.yaml
+++ b/config/manager/kustomization.yaml
@@ -17,4 +17,4 @@ images:
   newTag: dev
 - name: sidecar
   newName: ghcr.io/api7/adc
-  newTag: 0.29.0
+  newTag: 0.30.3
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 86b93cc3..b8432ff2 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -53,6 +53,7 @@ rules:
   - apisixupstreams/status
   - backendtrafficpolicies/status
   - consumers/status
+  - gatewayproxies/status
   - httproutepolicies/status
   - l4routepolicies/status
   verbs:
diff --git a/docs/en/latest/reference/api-reference.md 
b/docs/en/latest/reference/api-reference.md
index 84cf404b..a120c1c6 100644
--- a/docs/en/latest/reference/api-reference.md
+++ b/docs/en/latest/reference/api-reference.md
@@ -407,6 +407,8 @@ GatewayProxySpec defines the desired state of GatewayProxy.
 _Appears in:_
 - [GatewayProxy](#gatewayproxy)
 
+
+
 #### GatewayRef
 
 
diff --git a/docs/en/latest/reference/troubleshoot.md 
b/docs/en/latest/reference/troubleshoot.md
index 837ccbe2..26915ada 100644
--- a/docs/en/latest/reference/troubleshoot.md
+++ b/docs/en/latest/reference/troubleshoot.md
@@ -55,6 +55,40 @@ curl "http://127.0.0.1:9180/apisix/admin/routes"; -H 
"X-API-KEY: ${ADMIN_API_KEY}
 
 For reference, see [Admin 
API](https://apisix.apache.org/docs/apisix/admin-api/).
 
+## Check Data Plane Instance Availability
+
+When running APISIX in standalone mode with more than one instance, a route 
can be reachable through some instances but not others, for example if one 
instance is unreachable or rejects the synchronized configuration. This is not 
reflected on the affected route's own status, since the route itself was valid; 
it shows up on the `GatewayProxy` that addresses those instances.
+
+Check the `DataPlaneAvailable` condition:
+
+```shell
+kubectl get gatewayproxy <gateway-proxy-name> -o yaml
+```
+
+```yaml
+status:
+  conditions:
+  - type: DataPlaneAvailable
+    status: "False"
+    reason: DataPlaneInstanceUnavailable
+    message: "1/3 gateway instance(s) failed to apply the last sync: 
http://apisix-2:9180: connection refused"
+```
+
+For the history of which specific instance failed and when, check the 
GatewayProxy's events:
+
+```shell
+kubectl describe gatewayproxy <gateway-proxy-name>
+```
+
+```text
+Events:
+  Type     Reason                        Age   From             Message
+  ----     ------                        ----  ----             -------
+  Warning  DataPlaneInstanceUnavailable  8s    apisix-provider  
http://apisix-2:9180: connection refused
+```
+
+Each unreachable or rejecting instance is reported as its own `Warning` event, 
so instances failing for different reasons, or at different times, don't get 
folded into one message.
+
 ## Gateway API Routes Return 404
 
 Gateway API HTTPRoute or GRPCRoute resources may return `404` when the Gateway 
listener ports do not match the ports that APISIX actually listens on.
diff --git a/internal/adc/client/client.go b/internal/adc/client/client.go
index ae86aae2..76fec1bb 100644
--- a/internal/adc/client/client.go
+++ b/internal/adc/client/client.go
@@ -134,7 +134,7 @@ func (c *Client) Validate(ctx context.Context, task Task) 
error {
 
 // SyncInput is one GatewayProxy's complete sync unit. AIC builds it entirely 
from its own
 // bookkeeping (which resources target this config, their merged translated 
snapshot)
-// before handing it over -- this package never reaches back into AIC's state 
to gather
+// before handing it over: this package never reaches back into AIC's state to 
gather
 // anything itself, it only translates, sends, and interprets the response.
 type SyncInput struct {
        // Name is the cacheKey: the GatewayProxy's own identity.
diff --git a/internal/adc/client/executor.go b/internal/adc/client/executor.go
index a6c6f057..ebe0989c 100644
--- a/internal/adc/client/executor.go
+++ b/internal/adc/client/executor.go
@@ -83,8 +83,8 @@ type ADCServerOpts struct {
        CaCert   string `json:"caCert,omitempty"`
        CacheKey string `json:"cacheKey"`
        // BypassCache is only accepted by the /sync task of ADC >= 0.27.0. 
Both ADC task
-       // schemas reject unknown fields, so omitempty is what keeps every 
other request --
-       // /validate, and every sync that is not recovering from a rejection -- 
byte for byte
+       // schemas reject unknown fields, so omitempty is what keeps every 
other request,
+       // /validate, and every sync that is not recovering from a rejection, 
byte for byte
        // what an older ADC server already accepts.
        BypassCache bool `json:"bypassCache,omitempty"`
 }
@@ -325,7 +325,7 @@ func (e *HTTPADCExecutor) buildHTTPRequest(ctx 
context.Context, serverAddr strin
 }
 
 // distinctReasons joins every distinct, non-empty reason in failed, in the 
order first
-// seen -- several resources failing for the exact same reason (a rejected 
conf_version,
+// seen: several resources failing for the exact same reason (a rejected 
conf_version,
 // say) still reports it once.
 func distinctReasons(failed []adctypes.SyncStatus) string {
        seen := make(map[string]bool, len(failed))
@@ -360,11 +360,11 @@ func (e *HTTPADCExecutor) handleHTTPResponse(resp 
*http.Response, serverAddr str
        //   - 400: malformed request, unrelated to any backend's content 
rejection (see 422).
        //   - 413: request body over the fixed 100 MB limit.
        //   - 422: apisix-standalone, every server ended up success:false on 
the write itself
-       //     (rejected the content, unreachable, or a mix -- concurrent 
writes aren't
+       //     (rejected the content, unreachable, or a mix; concurrent writes 
aren't
        //     cancelled on the first failure).
        //   - 500: ADC Server failed before attempting the write at all (e.g. 
can't reach any
-       //     server to fetch the current remote state) -- never a verdict on 
the write.
-       // Only 200/202/422 carry a SyncResult body -- the other three 
shouldn't be parsed as
+       //     server to fetch the current remote state), never a verdict on 
the write.
+       // Only 200/202/422 carry a SyncResult body; the other three shouldn't 
be parsed as
        // one: most fields are optional, so an unrelated shape can unmarshal 
as an empty,
        // unremarkable "success".
        switch resp.StatusCode {
diff --git a/internal/adc/client/executor_test.go 
b/internal/adc/client/executor_test.go
index 12b0cfb4..d7016bb9 100644
--- a/internal/adc/client/executor_test.go
+++ b/internal/adc/client/executor_test.go
@@ -143,6 +143,46 @@ func TestHandleHTTPResponseParsesStructuredReasonOn422(t 
*testing.T) {
        assert.Equal(t, "unknown plugin foo", addrErr.FailedStatuses[0].Reason)
 }
 
+func TestHandleHTTPResponseParsesTheEventEnvelopeInCamelCase(t *testing.T) {
+       // A literal wire body, not json.Marshal(SyncStatus{...}): marshaling a 
Go value and
+       // unmarshaling it right back can't catch a tag mismatch, since both 
sides would agree
+       // on whatever the tag currently says. This is what ADC actually sends 
(captured from a
+       // real /sync response): 
Event.ResourceType/ResourceID/ResourceName/ParentID must
+       // deserialize from resourceType/resourceId/resourceName/parentId, not
+       // resource_type/resource_id/resource_name/parent_id, or 
classifySyncResult can never
+       // attribute a failure to the resource it names.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body := `{
+               "status": "partial_failure",
+               "total_resources": 2,
+               "success_count": 1,
+               "failed_count": 1,
+               "success": [],
+               "failed": [{
+                       "event": {
+                               "resourceType": "route",
+                               "type": "create",
+                               "parentId": "9bf5441c",
+                               "resourceId": "43428b9e",
+                               "resourceName": "ns_default_rule0"
+                       },
+                       "reason": "unknown plugin [non-existent-plugin]"
+               }]
+       }`
+
+       err := e.handleHTTPResponse(httpResponse(http.StatusAccepted, body), 
"http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       require.Len(t, addrErr.FailedStatuses, 1)
+       event := addrErr.FailedStatuses[0].Event
+       assert.Equal(t, "route", event.ResourceType)
+       assert.Equal(t, "create", event.Type)
+       assert.Equal(t, "9bf5441c", event.ParentID)
+       assert.Equal(t, "43428b9e", event.ResourceID)
+       assert.Equal(t, "ns_default_rule0", event.ResourceName)
+}
+
 func 
TestHandleHTTPResponseErrorsOnAnUnparseableBodyForAStatusThatShouldCarryOne(t 
*testing.T) {
        // Nothing actually sends a 2xx/422 body that isn't a SyncResult, but 
the parse must
        // still fail loudly rather than silently reading as success.
diff --git a/internal/controller/status/updater.go 
b/internal/controller/status/updater.go
index ae1e1c28..3d07fd72 100644
--- a/internal/controller/status/updater.go
+++ b/internal/controller/status/updater.go
@@ -254,6 +254,12 @@ func statusEqual(a, b any, opts ...cmp.Option) bool {
                        return false
                }
                statusA, statusB = a.Status, b.Status
+       case *v1alpha1.GatewayProxy:
+               b, ok := b.(*v1alpha1.GatewayProxy)
+               if !ok {
+                       return false
+               }
+               statusA, statusB = a.Status, b.Status
        default:
                return false
        }
diff --git a/internal/controller/status/updater_test.go 
b/internal/controller/status/updater_test.go
new file mode 100644
index 00000000..0d34442c
--- /dev/null
+++ b/internal/controller/status/updater_test.go
@@ -0,0 +1,146 @@
+// 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 status
+
+import (
+       "context"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/require"
+       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"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+       "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+)
+
+func gatewayProxyWithCondition(condition metav1.Condition) 
*v1alpha1.GatewayProxy {
+       return &v1alpha1.GatewayProxy{
+               Status: v1alpha1.GatewayProxyStatus{
+                       Conditions: []metav1.Condition{condition},
+               },
+       }
+}
+
+func TestStatusEqualHasAGatewayProxyCase(t *testing.T) {
+       condition := metav1.Condition{
+               Type:               "DataPlaneAvailable",
+               Status:             metav1.ConditionTrue,
+               Reason:             "DataPlaneAvailable",
+               LastTransitionTime: metav1.Now(),
+       }
+       a := gatewayProxyWithCondition(condition)
+       b := gatewayProxyWithCondition(condition)
+
+       if !statusEqual(a, b) {
+               t.Fatal("two GatewayProxy objects with an identical condition 
must compare equal, not fall through to the default case")
+       }
+}
+
+func TestStatusEqualIgnoresLastTransitionTimeForGatewayProxy(t *testing.T) {
+       a := gatewayProxyWithCondition(metav1.Condition{
+               Type:               "DataPlaneAvailable",
+               Status:             metav1.ConditionTrue,
+               Reason:             "DataPlaneAvailable",
+               LastTransitionTime: metav1.NewTime(metav1.Now().Add(-1)),
+       })
+       b := gatewayProxyWithCondition(metav1.Condition{
+               Type:               "DataPlaneAvailable",
+               Status:             metav1.ConditionTrue,
+               Reason:             "DataPlaneAvailable",
+               LastTransitionTime: metav1.Now(),
+       })
+
+       if !statusEqual(a, b, cmpIgnoreLastTT) {
+               t.Fatal("a fresh LastTransitionTime alone must not make an 
otherwise-unchanged GatewayProxy condition compare unequal")
+       }
+}
+
+func TestStatusEqualDetectsAChangedGatewayProxyCondition(t *testing.T) {
+       a := gatewayProxyWithCondition(metav1.Condition{Type: 
"DataPlaneAvailable", Status: metav1.ConditionTrue, Reason: 
"DataPlaneAvailable"})
+       b := gatewayProxyWithCondition(metav1.Condition{Type: 
"DataPlaneAvailable", Status: metav1.ConditionFalse, Reason: 
"DataPlaneInstanceUnavailable"})
+
+       if statusEqual(a, b, cmpIgnoreLastTT) {
+               t.Fatal("a real condition change must still compare unequal")
+       }
+}
+
+// reapplySameGatewayProxyCondition mutates a fresh GatewayProxy fixture 
through
+// UpdateHandler.updateStatus twice with a mutator that always writes the same
+// condition values (only LastTransitionTime differs between calls, as a real
+// mutator's `metav1.Now()` would) and returns the object's resourceVersion 
after
+// each call.
+func reapplySameGatewayProxyCondition(t *testing.T) (first, second string) {
+       t.Helper()
+
+       scheme := runtime.NewScheme()
+       require.NoError(t, clientgoscheme.AddToScheme(scheme))
+       require.NoError(t, v1alpha1.AddToScheme(scheme))
+
+       gatewayProxy := &v1alpha1.GatewayProxy{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: 
"proxy"},
+       }
+       cli := fake.NewClientBuilder().WithScheme(scheme).
+               WithObjects(gatewayProxy).
+               WithStatusSubresource(gatewayProxy).
+               Build()
+
+       u := NewStatusUpdateHandler(logr.Discard(), cli)
+       mutate := func(obj client.Object) client.Object {
+               cp := obj.(*v1alpha1.GatewayProxy).DeepCopy()
+               cp.Status.Conditions = []metav1.Condition{{
+                       Type:               "DataPlaneAvailable",
+                       Status:             metav1.ConditionFalse,
+                       Reason:             "DataPlaneInstanceUnavailable",
+                       Message:            "unreachable",
+                       LastTransitionTime: metav1.Now(),
+               }}
+               return cp
+       }
+       nnk := k8stypes.NamespacedName{Namespace: "default", Name: "proxy"}
+       ctx := context.Background()
+
+       require.NoError(t, u.updateStatus(ctx, Update{
+               NamespacedName: nnk,
+               Resource:       &v1alpha1.GatewayProxy{},
+               Mutator:        MutatorFunc(mutate),
+       }))
+       var afterFirst v1alpha1.GatewayProxy
+       require.NoError(t, cli.Get(ctx, nnk, &afterFirst))
+
+       require.NoError(t, u.updateStatus(ctx, Update{
+               NamespacedName: nnk,
+               Resource:       &v1alpha1.GatewayProxy{},
+               Mutator:        MutatorFunc(mutate),
+       }))
+       var afterSecond v1alpha1.GatewayProxy
+       require.NoError(t, cli.Get(ctx, nnk, &afterSecond))
+
+       return afterFirst.ResourceVersion, afterSecond.ResourceVersion
+}
+
+func TestUpdateStatusSkipsTheWriteWhenTheConditionDidNotActuallyChange(t 
*testing.T) {
+       first, second := reapplySameGatewayProxyCondition(t)
+       if first != second {
+               t.Fatalf("reapplying an unchanged condition must not write 
again: resourceVersion went from %q to %q", first, second)
+       }
+}
diff --git a/internal/manager/controllers.go b/internal/manager/controllers.go
index 51ec3f91..5cc55435 100644
--- a/internal/manager/controllers.go
+++ b/internal/manager/controllers.go
@@ -71,6 +71,7 @@ import (
 // CustomResourceDefinition
 // 
+kubebuilder:rbac:groups=apisix.apache.org,resources=pluginconfigs,verbs=get;list;watch
 // 
+kubebuilder:rbac:groups=apisix.apache.org,resources=gatewayproxies,verbs=get;list;watch
+// 
+kubebuilder:rbac:groups=apisix.apache.org,resources=gatewayproxies/status,verbs=get;update
 // 
+kubebuilder:rbac:groups=apisix.apache.org,resources=consumers,verbs=get;list;watch
 // 
+kubebuilder:rbac:groups=apisix.apache.org,resources=consumers/status,verbs=get;update
 // 
+kubebuilder:rbac:groups=apisix.apache.org,resources=backendtrafficpolicies,verbs=get;list;watch
diff --git a/internal/manager/run.go b/internal/manager/run.go
index b08ae92f..ed207457 100644
--- a/internal/manager/run.go
+++ b/internal/manager/run.go
@@ -223,6 +223,8 @@ func Run(ctx context.Context, logger logr.Logger) error {
                SyncPeriod:            
config.ControllerConfig.ProviderConfig.SyncPeriod.Duration,
                InitSyncDelay:         
config.ControllerConfig.ProviderConfig.InitSyncDelay.Duration,
                ListenerPortMatchMode: 
config.ControllerConfig.ListenerPortMatchMode,
+               EventRecorder:         
mgr.GetEventRecorderFor("apisix-provider"), //nolint:staticcheck
+               K8sClient:             mgr.GetClient(),
        }
        provider, err := provider.New(providerType, logger, updater.Writer(), 
readier, providerOptions)
        if err != nil {
diff --git a/internal/provider/apisix/provider.go 
b/internal/provider/apisix/provider.go
index 3b8139b9..f83df0c0 100644
--- a/internal/provider/apisix/provider.go
+++ b/internal/provider/apisix/provider.go
@@ -79,8 +79,11 @@ type apisixProvider struct {
        // record of which cacheKeys it has rebuilt. Unused for every other 
backend type.
        standaloneSyncer *adcclient.StandaloneSyncer
 
-       updater         status.Updater
-       statusUpdateMap map[types.NamespacedNameKind][]string
+       updater status.Updater
+       // resourceFailures holds which non-GatewayProxy resources currently 
have a sync
+       // error recorded, so the next round that stops seeing one can clear 
it. GatewayProxy
+       // keeps no such history: see updateStatusFromSyncResults.
+       resourceFailures map[types.NamespacedNameKind][]string
 
        readier readiness.ReadinessManager
 
@@ -268,7 +271,7 @@ func (d *apisixProvider) Delete(ctx context.Context, obj 
client.Object) error {
 }
 
 // applyResourceState upserts a resource's config associations and its 
contribution to each
-// target config's cached resource snapshot -- the AIC-side bookkeeping the 
adc client
+// target config's cached resource snapshot, the AIC-side bookkeeping the adc 
client
 // package no longer holds itself.
 func (d *apisixProvider) applyResourceState(
        rk types.NamespacedNameKind,
@@ -327,27 +330,32 @@ func (d *apisixProvider) evictFromStore(
 }
 
 // syncConfigNow reads name's current data (via build, called only once this 
cacheKey's
-// lock is actually held) and pushes it -- one atomic read-then-push step per 
cacheKey, so
+// lock is actually held) and pushes it, one atomic read-then-push step per 
cacheKey, so
 // whichever caller is granted the lock decides what to push only once it 
holds it: nothing
 // it sends can already be stale relative to whatever the other caller 
committed to the
 // store before losing the race for the same key. See keyedMutex.
+//
+// A nil result means build itself failed before anything could be dispatched 
to the data
+// plane: this round never actually reached pushConfig for this cacheKey, and 
the caller
+// should leave its status untouched rather than treat the absence of an 
execution error
+// as success. A non-nil result, empty or not, means pushConfig actually ran.
 func (d *apisixProvider) syncConfigNow(
        ctx context.Context,
        name string,
        build func() (adcclient.SyncInput, error),
-) (types.ADCExecutionErrors, error) {
+) (result *types.ADCExecutionErrors, err error) {
        unlock := d.syncLocks.Lock(name)
        defer unlock()
 
        input, err := build()
        if err != nil {
-               return types.ADCExecutionErrors{}, err
+               return nil, err
        }
        execErrs := d.pushConfig(ctx, input)
        if len(execErrs.Errors) > 0 {
-               return execErrs, execErrs
+               return &execErrs, execErrs
        }
-       return execErrs, nil
+       return &execErrs, nil
 }
 
 // pushConfig sends input to its data plane and shapes whatever failed into 
the form
@@ -409,13 +417,13 @@ func toADCExecutionError(name string, err error) 
types.ADCExecutionError {
 }
 
 // syncEvictedConfigsNow pushes an empty resource set for each of the given 
configs
-// immediately, instead of waiting for the next scheduled sync round -- 
through the same
+// immediately, instead of waiting for the next scheduled sync round, through 
the same
 // per-cacheKey lock the periodic sync uses, so it can never race a periodic 
round for the
-// same GatewayProxy. Used only when the deleted resource is a Gateway or 
IngressClass --
-// resourceTypes is empty for those, so the preceding removeResourceState call 
already
+// same GatewayProxy. Used only when the deleted resource is a Gateway or 
IngressClass,
+// where resourceTypes is empty, so the preceding removeResourceState call 
already
 // reset each config's whole cached snapshot via Store.Delete, and that reset 
should reach
-// the data plane promptly. Failures are logged, not surfaced as a status 
update -- this
-// mirrors the deferred path, which only reports through the next scheduled 
sync round.
+// the data plane promptly. Failures are logged, not surfaced as a status 
update, matching
+// the deferred path, which only reports through the next scheduled sync round.
 func (d *apisixProvider) syncEvictedConfigsNow(
        ctx context.Context,
        configs map[types.NamespacedNameKind]adctypes.Config,
@@ -493,34 +501,40 @@ func (d *apisixProvider) Start(ctx context.Context) error 
{
        }
 }
 
-// sync pushes every GatewayProxy AIC currently knows about, config by config 
-- each
-// one's current resource snapshot is only read once syncConfigNow actually 
holds that
-// cacheKey's lock, so a slow round can never push a snapshot that was already 
stale by the
-// time its turn came up. All of this round's results are still collected into 
one
-// statusesMap and handed to handleADCExecutionErrors together, exactly as a 
single batched
-// sync would: that logic diffs against last round's full picture, not 
per-config.
+// sync pushes every GatewayProxy AIC currently knows about, config by config, 
each one's
+// current resource snapshot is only read once syncConfigNow actually holds 
that
+// cacheKey's lock, so a slow round can never push a snapshot that was already 
stale by
+// the time its turn came up. results collects one entry per config this round 
actually
+// reached pushConfig for, success (a zero-value types.ADCExecutionErrors) or 
failure. A
+// config whose build itself failed (a local error, before anything reached 
the data
+// plane) is left out of results entirely and its status goes untouched this 
round,
+// logged here rather than silently treated as either outcome; see
+// updateStatusFromSyncResults for what results feeds into.
 func (d *apisixProvider) sync(ctx context.Context) error {
        configs := d.configManager.List()
 
-       statusesMap := map[string]types.ADCExecutionErrors{}
+       results := map[string]types.ADCExecutionErrors{}
        var errs []error
        for _, config := range configs {
-               execErrs, err := d.syncConfigNow(ctx, config.Name, func() 
(adcclient.SyncInput, error) {
+               result, err := d.syncConfigNow(ctx, config.Name, func() 
(adcclient.SyncInput, error) {
                        resources, err := d.store.GetResources(config.Name)
                        if err != nil {
                                return adcclient.SyncInput{}, 
fmt.Errorf("failed to get resources from store: %w", err)
                        }
                        return adcclient.SyncInput{Name: config.Name, Config: 
config, Resources: resources}, nil
                })
-               if err != nil {
+               if result == nil {
+                       d.log.Error(err, "failed to build sync input, leaving 
this GatewayProxy's status untouched this round", "config", config.Name)
                        errs = append(errs, fmt.Errorf("config %s: %w", 
config.Name, err))
+                       continue
                }
-               if len(execErrs.Errors) > 0 {
-                       statusesMap[config.Name] = execErrs
+               results[config.Name] = *result
+               if err != nil {
+                       errs = append(errs, fmt.Errorf("config %s: %w", 
config.Name, err))
                }
        }
 
-       d.handleADCExecutionErrors(statusesMap)
+       d.updateStatusFromSyncResults(ctx, results)
        return errors.Join(errs...)
 }
 
@@ -531,12 +545,6 @@ func (d *apisixProvider) syncNotify() {
        }
 }
 
-func (d *apisixProvider) handleADCExecutionErrors(statusesMap 
map[string]types.ADCExecutionErrors) {
-       statusUpdateMap := d.resolveADCExecutionErrors(statusesMap)
-       d.handleStatusUpdate(statusUpdateMap)
-       d.log.V(1).Info("handled ADC execution errors", "status_record", 
statusesMap, "status_update", statusUpdateMap)
-}
-
 func (d *apisixProvider) NeedLeaderElection() bool {
        return true
 }
diff --git a/internal/provider/apisix/status.go 
b/internal/provider/apisix/status.go
index e2f82bd0..0aeb58ce 100644
--- a/internal/provider/apisix/status.go
+++ b/internal/provider/apisix/status.go
@@ -18,13 +18,17 @@
 package apisix
 
 import (
+       "context"
        "fmt"
        "strings"
 
+       corev1 "k8s.io/api/core/v1"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        "sigs.k8s.io/controller-runtime/pkg/client"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
 
+       adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+       apiv1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1"
        apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
        "github.com/apache/apisix-ingress-controller/internal/controller/label"
        "github.com/apache/apisix-ingress-controller/internal/controller/status"
@@ -32,42 +36,217 @@ import (
        "github.com/apache/apisix-ingress-controller/internal/types"
 )
 
-// handleStatusUpdate updates resource conditions based on the latest sync 
results.
+// GatewayProxyConditionDataPlaneAvailable reports whether every APISIX 
instance a
+// GatewayProxy addresses took the last sync. It lands on the GatewayProxy 
rather than
+// a Gateway, an IngressClass, or a CRD, since GatewayProxy is what every path 
shares.
+const (
+       GatewayProxyConditionDataPlaneAvailable = "DataPlaneAvailable"
+
+       GatewayProxyReasonDataPlaneAvailable           = "DataPlaneAvailable"
+       GatewayProxyReasonDataPlaneInstanceUnavailable = 
"DataPlaneInstanceUnavailable"
+)
+
+// updateStatusFromSyncResults updates every resource and GatewayProxy status 
this
+// round's sync results call for. results holds one entry per config sync() 
actually
+// reached pushConfig for this round, success (a zero-value 
types.ADCExecutionErrors) or
+// failure; a config sync() could not even build a SyncInput for is absent 
here entirely
+// and its status is left untouched this round, see sync().
 //
-// It maintains a history of failed resources in d.statusUpdateMap.
+// GatewayProxy's DataPlaneAvailable condition is recomputed and written fresh 
every
+// round directly from this round's result, never compared against any 
remembered
+// history: a config with no error this round is written True, one with any 
error is
+// written False. That is what makes a GatewayProxy that has always been 
healthy actually
+// get a True the first time, and what keeps a restart from leaving a stale 
False stuck
+// forever: the write only ever depends on this round's actual outcome.
 //
-// For resources in the current failure map (statusUpdateMap), it marks them 
as failed.
-// For resources that exist only in the previous failure history (i.e. not in 
this sync's failures),
-// it marks them as accepted (success).
-func (d *apisixProvider) handleStatusUpdate(statusUpdateMap 
map[types.NamespacedNameKind][]string) {
-       // Mark all resources in the current failure set as failed.
-       for nnk, msgs := range statusUpdateMap {
-               d.updateStatus(nnk, cutils.NewConditionTypeAccepted(
-                       apiv2.ConditionReasonSyncFailed,
-                       false,
-                       0,
-                       strings.Join(msgs, "; "),
-               ))
+// Resource status can't afford the same full recompute: a config's resource 
set can be
+// large, and rewriting every one of them every round even when nothing 
changed would be
+// wasteful. So resources keep a small persisted delta in d.resourceFailures 
instead:
+// newly (or still) failing resources are written SyncFailed, and any resource 
that was
+// failing last round but isn't failing this one gets its error explicitly 
cleared with
+// an Accepted write.
+func (d *apisixProvider) updateStatusFromSyncResults(ctx context.Context, 
results map[string]types.ADCExecutionErrors) {
+       resourceFailures := map[types.NamespacedNameKind][]string{}
+
+       for configName, execErrs := range results {
+               var gatewayProxy types.NamespacedNameKind
+               if err := gatewayProxy.FromString(configName); err != nil {
+                       d.log.Error(err, "failed to parse config name as a 
GatewayProxy key", "configName", configName)
+                       continue
+               }
+
+               gatewayProxyMsgs, failedEndpoints := 
d.classifySyncResult(configName, execErrs, resourceFailures)
+               if len(gatewayProxyMsgs) > 0 {
+                       d.updateStatus(gatewayProxy, 
failureCondition(gatewayProxy, strings.Join(gatewayProxyMsgs, "; ")))
+                       d.recordFailedEndpointEvents(ctx, gatewayProxy, 
failedEndpoints)
+               } else {
+                       d.updateStatus(gatewayProxy, 
successCondition(gatewayProxy))
+               }
+       }
+
+       d.applyResourceFailures(resourceFailures)
+       d.log.V(1).Info("updated status from sync results", "results", results, 
"resource_failures", resourceFailures)
+}
+
+// classifySyncResult splits one config's this-round result into what belongs 
on the
+// GatewayProxy (returned) and what belongs on specific Kubernetes resources 
(added into
+// resourceFailures). The two are independent, not mutually exclusive: a 
single addrErr
+// can carry both a resource-attributed FailedStatuses entry and a failed
+// EndpointStatuses entry at once (apisix-standalone's own re-validate path 
attaches
+// EndpointStatuses to every addrErr regardless of what FailedStatuses also 
names), so
+// checking one must never suppress reporting the other. A FailedStatuses 
entry that
+// resolves to a resource via its Event goes there; everything else, no 
FailedStatuses at
+// all, or a FailedStatuses entry with no Event to resolve (apisix-standalone 
when the
+// rejection can't be pinned on a specific resource), is a GatewayProxy-level 
signal
+// instead, but only once nothing else already explained this addrErr: 
EndpointStatuses'
+// own message first, the raw error as a last resort.
+func (d *apisixProvider) classifySyncResult(
+       configName string,
+       execErrs types.ADCExecutionErrors,
+       resourceFailures map[types.NamespacedNameKind][]string,
+) (gatewayProxyMsgs []string, failedEndpoints []adctypes.EndpointStatus) {
+       for _, execErr := range execErrs.Errors {
+               for _, addrErr := range execErr.FailedErrors {
+                       endpointMsg := 
unavailableEndpointsMessage(addrErr.EndpointStatuses)
+                       if endpointMsg != "" {
+                               gatewayProxyMsgs = append(gatewayProxyMsgs, 
endpointMsg)
+                               failedEndpoints = append(failedEndpoints, 
addrErr.EndpointStatuses...)
+                       }
+
+                       if len(addrErr.FailedStatuses) == 0 {
+                               if endpointMsg == "" {
+                                       gatewayProxyMsgs = 
append(gatewayProxyMsgs, addrErr.Error())
+                               }
+                               continue
+                       }
+
+                       anyUnattributed := false
+                       for _, syncStatus := range addrErr.FailedStatuses {
+                               if syncStatus.Event.ResourceType == "" {
+                                       anyUnattributed = true
+                                       continue
+                               }
+                               labels, err := 
d.store.GetResourceLabel(configName, syncStatus.Event.ResourceType, 
syncStatus.Event.ResourceID)
+                               if err != nil {
+                                       d.log.Error(err, "failed to get 
resource label",
+                                               "configName", configName, 
"resourceType", syncStatus.Event.ResourceType, "id", 
syncStatus.Event.ResourceID)
+                                       continue
+                               }
+                               resourceKey := types.NamespacedNameKind{
+                                       Name:      labels[label.LabelName],
+                                       Namespace: labels[label.LabelNamespace],
+                                       Kind:      labels[label.LabelKind],
+                               }
+                               msg := fmt.Sprintf("ServerAddr: %s, Error: %s", 
addrErr.ServerAddr, syncStatus.Reason)
+                               resourceFailures[resourceKey] = 
append(resourceFailures[resourceKey], msg)
+                       }
+                       if anyUnattributed && endpointMsg == "" {
+                               gatewayProxyMsgs = append(gatewayProxyMsgs, 
addrErr.Error())
+                       }
+               }
+       }
+       return gatewayProxyMsgs, failedEndpoints
+}
+
+// applyResourceFailures writes this round's newly (or still) failing 
resources, and
+// clears the recorded error from any resource that was failing last round but 
isn't in
+// newFailures now. See updateStatusFromSyncResults for why resources use this 
delta
+// instead of GatewayProxy's full recompute.
+func (d *apisixProvider) applyResourceFailures(newFailures 
map[types.NamespacedNameKind][]string) {
+       for resourceKey, msgs := range newFailures {
+               d.updateStatus(resourceKey, failureCondition(resourceKey, 
strings.Join(msgs, "; ")))
+       }
+       for resourceKey := range d.resourceFailures {
+               if _, stillFailing := newFailures[resourceKey]; !stillFailing {
+                       d.updateStatus(resourceKey, 
successCondition(resourceKey))
+               }
+       }
+       d.resourceFailures = newFailures
+}
+
+// failureCondition and successCondition pick which condition a 
NamespacedNameKind
+// gets: GatewayProxyConditionDataPlaneAvailable for a GatewayProxy, the 
existing
+// Accepted/SyncFailed condition for everything else.
+func failureCondition(nnk types.NamespacedNameKind, msg string) 
metav1.Condition {
+       if nnk.Kind == types.KindGatewayProxy {
+               return newGatewayProxyDataPlaneAvailableCondition(false, 
GatewayProxyReasonDataPlaneInstanceUnavailable, msg)
+       }
+       return cutils.NewConditionTypeAccepted(apiv2.ConditionReasonSyncFailed, 
false, 0, msg)
+}
+
+func successCondition(nnk types.NamespacedNameKind) metav1.Condition {
+       if nnk.Kind == types.KindGatewayProxy {
+               return newGatewayProxyDataPlaneAvailableCondition(true, 
GatewayProxyReasonDataPlaneAvailable, "")
+       }
+       return cutils.NewConditionTypeAccepted(apiv2.ConditionReasonAccepted, 
true, 0, "")
+}
+
+func newGatewayProxyDataPlaneAvailableCondition(available bool, reason, msg 
string) metav1.Condition {
+       conditionStatus := metav1.ConditionFalse
+       if available {
+               conditionStatus = metav1.ConditionTrue
+       }
+       return metav1.Condition{
+               Type:               GatewayProxyConditionDataPlaneAvailable,
+               Status:             conditionStatus,
+               LastTransitionTime: metav1.Now(),
+               Reason:             reason,
+               Message:            cutils.TruncateConditionMessage(msg),
+       }
+}
+
+// recordFailedEndpointEvents fires one Warning event per failed 
EndpointStatus entry,
+// so each instance's own failure history (when it started, how often) is 
visible on
+// its own, not folded into everyone else's. The GatewayProxy is fetched fresh 
from the
+// API server first so the Event's involvedObject carries a real UID: a 
hand-built stub
+// with only Name/Namespace leaves that UID empty, and kubectl describe 
resolves events
+// by matching it, so an event against such a stub never shows up there.
+func (d *apisixProvider) recordFailedEndpointEvents(ctx context.Context, nnk 
types.NamespacedNameKind, endpoints []adctypes.EndpointStatus) {
+       if d.EventRecorder == nil {
+               return
+       }
+       hasFailure := false
+       for _, ep := range endpoints {
+               if !ep.Success {
+                       hasFailure = true
+                       break
+               }
+       }
+       if !hasFailure {
+               return
        }
 
-       // Mark resources that exist only in the previous failure history as 
successful.
-       for nnk := range d.statusUpdateMap {
-               if _, ok := statusUpdateMap[nnk]; !ok {
-                       d.updateStatus(nnk, cutils.NewConditionTypeAccepted(
-                               apiv2.ConditionReasonAccepted,
-                               true,
-                               0,
-                               "",
-                       ))
+       gatewayProxy := &apiv1alpha1.GatewayProxy{}
+       if err := d.K8sClient.Get(ctx, nnk.NamespacedName(), gatewayProxy); err 
!= nil {
+               d.log.Error(err, "failed to get GatewayProxy to record failed 
endpoint events", "name", nnk.Name, "namespace", nnk.Namespace)
+               return
+       }
+
+       for _, ep := range endpoints {
+               if ep.Success {
+                       continue
                }
+               d.EventRecorder.Event(gatewayProxy, corev1.EventTypeWarning, 
GatewayProxyReasonDataPlaneInstanceUnavailable,
+                       fmt.Sprintf("%s: %s", ep.Server, ep.Reason))
        }
-       // Update the failure history with the current failure set.
-       d.statusUpdateMap = statusUpdateMap
 }
 
 //nolint:gocyclo
 func (d *apisixProvider) updateStatus(nnk types.NamespacedNameKind, condition 
metav1.Condition) {
        switch nnk.Kind {
+       case types.KindGatewayProxy:
+               // Unlike the route kinds below, the condition lands on the 
GatewayProxy's own
+               // top-level Status.Conditions, not on a per-parent entry.
+               d.updater.Update(status.Update{
+                       NamespacedName: nnk.NamespacedName(),
+                       Resource:       &apiv1alpha1.GatewayProxy{},
+                       Mutator: status.MutatorFunc(func(obj client.Object) 
client.Object {
+                               cp := obj.(*apiv1alpha1.GatewayProxy).DeepCopy()
+                               condition.ObservedGeneration = 
cp.GetGeneration()
+                               cp.Status.Conditions = 
cutils.MergeCondition(cp.Status.Conditions, condition)
+                               return cp
+                       }),
+               })
        case types.KindApisixRoute:
                d.updater.Update(status.Update{
                        NamespacedName: nnk.NamespacedName(),
@@ -255,96 +434,19 @@ func (d *apisixProvider) updateStatus(nnk 
types.NamespacedNameKind, condition me
        }
 }
 
-func (d *apisixProvider) resolveADCExecutionErrors(
-       statusesMap map[string]types.ADCExecutionErrors,
-) map[types.NamespacedNameKind][]string {
-       statusUpdateMap := map[types.NamespacedNameKind][]string{}
-       for configName, execErrors := range statusesMap {
-               for _, execErr := range execErrors.Errors {
-                       for _, failedStatus := range execErr.FailedErrors {
-                               if len(failedStatus.FailedStatuses) == 0 {
-                                       d.handleEmptyFailedStatuses(configName, 
failedStatus, statusUpdateMap)
-                               } else {
-                                       
d.handleDetailedFailedStatuses(configName, failedStatus, statusUpdateMap)
-                               }
-                       }
-               }
-       }
-
-       return statusUpdateMap
-}
-
-func (d *apisixProvider) handleEmptyFailedStatuses(
-       configName string,
-       failedStatus types.ADCExecutionServerAddrError,
-       statusUpdateMap map[types.NamespacedNameKind][]string,
-) {
-       resource, err := d.store.GetResources(configName)
-       if err != nil {
-               d.log.Error(err, "failed to get resources from store", 
"configName", configName)
-               return
-       }
-
-       for _, obj := range resource.Services {
-               d.addResourceToStatusUpdateMap(obj.GetLabels(), 
failedStatus.Error(), statusUpdateMap)
-       }
-
-       for _, obj := range resource.Consumers {
-               d.addResourceToStatusUpdateMap(obj.GetLabels(), 
failedStatus.Error(), statusUpdateMap)
-       }
-
-       for _, obj := range resource.SSLs {
-               d.addResourceToStatusUpdateMap(obj.GetLabels(), 
failedStatus.Error(), statusUpdateMap)
-       }
-
-       globalRules, err := d.store.ListGlobalRules(configName)
-       if err != nil {
-               d.log.Error(err, "failed to list global rules", "configName", 
configName)
-               return
-       }
-       for _, rule := range globalRules {
-               d.addResourceToStatusUpdateMap(rule.GetLabels(), 
failedStatus.Error(), statusUpdateMap)
-       }
-}
-
-func (d *apisixProvider) handleDetailedFailedStatuses(
-       configName string,
-       failedStatus types.ADCExecutionServerAddrError,
-       statusUpdateMap map[types.NamespacedNameKind][]string,
-) {
-       for _, status := range failedStatus.FailedStatuses {
-               // in the APISIX standalone mode, the related values in the 
sync failure event are empty.
-               if status.Event.ResourceType == "" {
-                       d.handleEmptyFailedStatuses(configName, failedStatus, 
statusUpdateMap)
-                       return
-               }
-               id := status.Event.ResourceID
-               labels, err := d.store.GetResourceLabel(configName, 
status.Event.ResourceType, id)
-               if err != nil {
-                       d.log.Error(err, "failed to get resource label",
-                               "configName", configName,
-                               "resourceType", status.Event.ResourceType,
-                               "id", id,
-                       )
+// unavailableEndpointsMessage summarizes every EndpointStatus entry that 
didn't
+// succeed, in the order given. Empty means none did (or there were none to 
check).
+func unavailableEndpointsMessage(endpoints []adctypes.EndpointStatus) string {
+       failed := make([]string, 0, len(endpoints))
+       for _, ep := range endpoints {
+               if ep.Success {
                        continue
                }
-               d.addResourceToStatusUpdateMap(
-                       labels,
-                       fmt.Sprintf("ServerAddr: %s, Error: %s", 
failedStatus.ServerAddr, status.Reason),
-                       statusUpdateMap,
-               )
+               failed = append(failed, fmt.Sprintf("%s: %s", ep.Server, 
ep.Reason))
        }
-}
-
-func (d *apisixProvider) addResourceToStatusUpdateMap(
-       labels map[string]string,
-       msg string,
-       statusUpdateMap map[types.NamespacedNameKind][]string,
-) {
-       statusKey := types.NamespacedNameKind{
-               Name:      labels[label.LabelName],
-               Namespace: labels[label.LabelNamespace],
-               Kind:      labels[label.LabelKind],
+       if len(failed) == 0 {
+               return ""
        }
-       statusUpdateMap[statusKey] = append(statusUpdateMap[statusKey], msg)
+       return fmt.Sprintf("%d/%d gateway instance(s) failed to apply the last 
sync: %s",
+               len(failed), len(endpoints), strings.Join(failed, "; "))
 }
diff --git a/internal/provider/apisix/status_test.go 
b/internal/provider/apisix/status_test.go
new file mode 100644
index 00000000..81fa567d
--- /dev/null
+++ b/internal/provider/apisix/status_test.go
@@ -0,0 +1,437 @@
+// 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 apisix
+
+import (
+       "context"
+       "strings"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/require"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime"
+       clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+       "k8s.io/client-go/tools/record"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+       "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+       adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+       apiv1alpha1 "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
+       "github.com/apache/apisix-ingress-controller/internal/adc/cache"
+       "github.com/apache/apisix-ingress-controller/internal/controller/label"
+       "github.com/apache/apisix-ingress-controller/internal/controller/status"
+       "github.com/apache/apisix-ingress-controller/internal/types"
+)
+
+// fakeK8sClient builds a controller-runtime fake client seeded with objects, 
for the
+// GatewayProxy lookup recordFailedEndpointEvents does before firing an Event.
+func fakeK8sClient(t *testing.T, objects ...runtime.Object) client.Client {
+       t.Helper()
+       scheme := runtime.NewScheme()
+       require.NoError(t, clientgoscheme.AddToScheme(scheme))
+       require.NoError(t, apiv1alpha1.AddToScheme(scheme))
+       return 
fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objects...).Build()
+}
+
+func TestUnavailableEndpointsMessageEmptyWhenEverythingSucceeded(t *testing.T) 
{
+       msg := unavailableEndpointsMessage([]adctypes.EndpointStatus{
+               {Server: "http://apisix-1:9180";, Success: true},
+               {Server: "http://apisix-2:9180";, Success: true},
+       })
+       if msg != "" {
+               t.Fatalf("expected no message, got %q", msg)
+       }
+}
+
+func TestUnavailableEndpointsMessageEmptyWhenThereAreNoEndpoints(t *testing.T) 
{
+       if msg := unavailableEndpointsMessage(nil); msg != "" {
+               t.Fatalf("expected no message, got %q", msg)
+       }
+}
+
+func TestUnavailableEndpointsMessageSummarizesOnlyTheFailedOnes(t *testing.T) {
+       msg := unavailableEndpointsMessage([]adctypes.EndpointStatus{
+               {Server: "http://apisix-1:9180";, Success: true},
+               {Server: "http://apisix-2:9180";, Success: false, Reason: 
"connection refused"},
+       })
+       want := "1/2 gateway instance(s) failed to apply the last sync: 
http://apisix-2:9180: connection refused"
+       if msg != want {
+               t.Fatalf("got %q, want %q", msg, want)
+       }
+}
+
+func TestFailureConditionMarksGatewayProxyDataPlaneAvailableFalse(t 
*testing.T) {
+       nnk := types.NamespacedNameKind{Kind: types.KindGatewayProxy, 
Namespace: "ns", Name: "gp"}
+       c := failureCondition(nnk, "boom")
+
+       if c.Type != GatewayProxyConditionDataPlaneAvailable {
+               t.Errorf("Type = %q, want %q", c.Type, 
GatewayProxyConditionDataPlaneAvailable)
+       }
+       if c.Status != metav1.ConditionFalse {
+               t.Errorf("Status = %v, want False", c.Status)
+       }
+       if c.Reason != GatewayProxyReasonDataPlaneInstanceUnavailable {
+               t.Errorf("Reason = %q, want %q", c.Reason, 
GatewayProxyReasonDataPlaneInstanceUnavailable)
+       }
+       if c.Message != "boom" {
+               t.Errorf("Message = %q, want %q", c.Message, "boom")
+       }
+}
+
+func TestSuccessConditionMarksGatewayProxyDataPlaneAvailableTrue(t *testing.T) 
{
+       nnk := types.NamespacedNameKind{Kind: types.KindGatewayProxy, 
Namespace: "ns", Name: "gp"}
+       c := successCondition(nnk)
+
+       if c.Type != GatewayProxyConditionDataPlaneAvailable {
+               t.Errorf("Type = %q, want %q", c.Type, 
GatewayProxyConditionDataPlaneAvailable)
+       }
+       if c.Status != metav1.ConditionTrue {
+               t.Errorf("Status = %v, want True", c.Status)
+       }
+       if c.Reason != GatewayProxyReasonDataPlaneAvailable {
+               t.Errorf("Reason = %q, want %q", c.Reason, 
GatewayProxyReasonDataPlaneAvailable)
+       }
+}
+
+func TestFailureAndSuccessConditionKeepUsingAcceptedForNonGatewayProxyKinds(t 
*testing.T) {
+       nnk := types.NamespacedNameKind{Kind: types.KindApisixRoute, Namespace: 
"ns", Name: "route"}
+
+       failed := failureCondition(nnk, "boom")
+       if failed.Type != string(apiv2.ConditionTypeAccepted) {
+               t.Errorf("failure Type = %q, want %q", failed.Type, 
apiv2.ConditionTypeAccepted)
+       }
+       if failed.Reason != string(apiv2.ConditionReasonSyncFailed) {
+               t.Errorf("failure Reason = %q, want %q", failed.Reason, 
apiv2.ConditionReasonSyncFailed)
+       }
+
+       ok := successCondition(nnk)
+       if ok.Type != string(apiv2.ConditionTypeAccepted) {
+               t.Errorf("success Type = %q, want %q", ok.Type, 
apiv2.ConditionTypeAccepted)
+       }
+       if ok.Reason != string(apiv2.ConditionReasonAccepted) {
+               t.Errorf("success Reason = %q, want %q", ok.Reason, 
apiv2.ConditionReasonAccepted)
+       }
+}
+
+func TestRecordFailedEndpointEventsFiresOneWarningPerFailedEndpoint(t 
*testing.T) {
+       recorder := record.NewFakeRecorder(2)
+       d := &apisixProvider{log: logr.Discard()}
+       d.EventRecorder = recorder
+       d.K8sClient = fakeK8sClient(t, &apiv1alpha1.GatewayProxy{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "gp", UID: 
"gp-uid"},
+       })
+       nnk := types.NamespacedNameKind{Kind: types.KindGatewayProxy, 
Namespace: "ns", Name: "gp"}
+
+       d.recordFailedEndpointEvents(context.Background(), nnk, 
[]adctypes.EndpointStatus{
+               {Server: "http://apisix-1:9180";, Success: true},
+               {Server: "http://apisix-2:9180";, Success: false, Reason: 
"connection refused"},
+               {Server: "http://apisix-3:9180";, Success: false, Reason: "TLS 
handshake failed"},
+       })
+
+       first := <-recorder.Events
+       if !strings.Contains(first, "Warning") || !strings.Contains(first, 
"DataPlaneInstanceUnavailable") ||
+               !strings.Contains(first, "http://apisix-2:9180: connection 
refused") {
+               t.Errorf("first event = %q, want the apisix-2 failure", first)
+       }
+
+       second := <-recorder.Events
+       if !strings.Contains(second, "Warning") || !strings.Contains(second, 
"DataPlaneInstanceUnavailable") ||
+               !strings.Contains(second, "http://apisix-3:9180: TLS handshake 
failed") {
+               t.Errorf("second event = %q, want the apisix-3 failure", second)
+       }
+
+       select {
+       case e := <-recorder.Events:
+               t.Errorf("unexpected third event %q, the successful endpoint 
should not fire one", e)
+       default:
+       }
+}
+
+func TestRecordFailedEndpointEventsNoopsWithoutARecorder(t *testing.T) {
+       d := &apisixProvider{log: logr.Discard()}
+       nnk := types.NamespacedNameKind{Kind: types.KindGatewayProxy, 
Namespace: "ns", Name: "gp"}
+
+       // Must not panic when no EventRecorder was configured. No K8sClient 
either: a nil
+       // dereference here would mean this didn't actually return before 
reaching it.
+       d.recordFailedEndpointEvents(context.Background(), nnk, 
[]adctypes.EndpointStatus{{Server: "http://apisix-1:9180";, Success: false}})
+}
+
+func TestRecordFailedEndpointEventsNoopsWhenEveryEndpointSucceeded(t 
*testing.T) {
+       recorder := record.NewFakeRecorder(1)
+       d := &apisixProvider{log: logr.Discard()}
+       d.EventRecorder = recorder
+       nnk := types.NamespacedNameKind{Kind: types.KindGatewayProxy, 
Namespace: "ns", Name: "gp"}
+
+       // No K8sClient configured either: with nothing failed, the 
GatewayProxy lookup this
+       // needs to attribute a failure must never even be attempted.
+       d.recordFailedEndpointEvents(context.Background(), nnk, 
[]adctypes.EndpointStatus{{Server: "http://apisix-1:9180";, Success: true}})
+
+       select {
+       case e := <-recorder.Events:
+               t.Errorf("unexpected event %q, nothing failed", e)
+       default:
+       }
+}
+
+func TestRecordFailedEndpointEventsSkipsWhenTheGatewayProxyCannotBeFetched(t 
*testing.T) {
+       recorder := record.NewFakeRecorder(1)
+       d := &apisixProvider{log: logr.Discard()}
+       d.EventRecorder = recorder
+       d.K8sClient = fakeK8sClient(t) // no GatewayProxy seeded, so Get 
returns NotFound
+       nnk := types.NamespacedNameKind{Kind: types.KindGatewayProxy, 
Namespace: "ns", Name: "gp"}
+
+       // Must not panic, and must not fire an event against a zero-value 
stand-in.
+       d.recordFailedEndpointEvents(context.Background(), nnk, 
[]adctypes.EndpointStatus{{Server: "http://apisix-1:9180";, Success: false}})
+
+       select {
+       case e := <-recorder.Events:
+               t.Errorf("unexpected event %q, the GatewayProxy could not be 
fetched", e)
+       default:
+       }
+}
+
+// fakeUpdater records every status.Update handed to it, and applies each 
Mutator against
+// a caller-supplied base object so a test can inspect the condition it 
actually wrote.
+type fakeUpdater struct {
+       updates []status.Update
+}
+
+func (f *fakeUpdater) Update(u status.Update) {
+       f.updates = append(f.updates, u)
+}
+
+func TestClassifySyncResultHardErrorGoesToGatewayProxy(t *testing.T) {
+       d := &apisixProvider{log: logr.Discard()}
+       execErrs := types.ADCExecutionErrors{Errors: []types.ADCExecutionError{{
+               Name: "GatewayProxy/ns/gp",
+               FailedErrors: []types.ADCExecutionServerAddrError{{
+                       ServerAddr: "http://apisix:9180";,
+                       Err:        "HTTP 500: boom",
+               }},
+       }}}
+
+       resourceFailures := map[types.NamespacedNameKind][]string{}
+       gatewayProxyMsgs, failedEndpoints := 
d.classifySyncResult("GatewayProxy/ns/gp", execErrs, resourceFailures)
+
+       if len(resourceFailures) != 0 {
+               t.Errorf("expected no resource attributed, got %v", 
resourceFailures)
+       }
+       if len(failedEndpoints) != 0 {
+               t.Errorf("expected no endpoints, got %v", failedEndpoints)
+       }
+       if len(gatewayProxyMsgs) != 1 || !strings.Contains(gatewayProxyMsgs[0], 
"HTTP 500: boom") {
+               t.Errorf("gatewayProxyMsgs = %v, want the raw error", 
gatewayProxyMsgs)
+       }
+}
+
+func TestClassifySyncResultEndpointFailuresGoToGatewayProxy(t *testing.T) {
+       d := &apisixProvider{log: logr.Discard()}
+       endpoints := []adctypes.EndpointStatus{
+               {Server: "http://apisix-1:9180";, Success: true},
+               {Server: "http://apisix-2:9180";, Success: false, Reason: 
"connection refused"},
+       }
+       execErrs := types.ADCExecutionErrors{Errors: []types.ADCExecutionError{{
+               Name: "GatewayProxy/ns/gp",
+               FailedErrors: []types.ADCExecutionServerAddrError{{
+                       EndpointStatuses: endpoints,
+               }},
+       }}}
+
+       resourceFailures := map[types.NamespacedNameKind][]string{}
+       gatewayProxyMsgs, failedEndpoints := 
d.classifySyncResult("GatewayProxy/ns/gp", execErrs, resourceFailures)
+
+       if len(resourceFailures) != 0 {
+               t.Errorf("expected no resource attributed, got %v", 
resourceFailures)
+       }
+       if len(gatewayProxyMsgs) != 1 || !strings.Contains(gatewayProxyMsgs[0], 
"http://apisix-2:9180: connection refused") {
+               t.Errorf("gatewayProxyMsgs = %v, want the endpoint summary", 
gatewayProxyMsgs)
+       }
+       if len(failedEndpoints) != 2 {
+               t.Errorf("failedEndpoints = %v, want every EndpointStatus entry 
passed through for event firing", failedEndpoints)
+       }
+}
+
+func TestClassifySyncResultAttributesFailedStatusesToTheirResource(t 
*testing.T) {
+       d := &apisixProvider{log: logr.Discard(), store: 
cache.NewStore(logr.Discard())}
+       const configName = "GatewayProxy/ns/gp"
+       if err := d.store.Insert(configName, []string{adctypes.TypeService}, 
&adctypes.Resources{
+               Services: []*adctypes.Service{{
+                       Metadata: adctypes.Metadata{
+                               ID: "svc1",
+                               Labels: map[string]string{
+                                       label.LabelKind:      "ApisixRoute",
+                                       label.LabelName:      "route1",
+                                       label.LabelNamespace: "ns1",
+                               },
+                       },
+               }},
+       }, nil); err != nil {
+               t.Fatalf("seeding the store: %v", err)
+       }
+
+       execErrs := types.ADCExecutionErrors{Errors: []types.ADCExecutionError{{
+               Name: configName,
+               FailedErrors: []types.ADCExecutionServerAddrError{{
+                       ServerAddr: "http://apisix:9180";,
+                       FailedStatuses: []adctypes.SyncStatus{{
+                               Reason: "unknown plugin foo",
+                               Event:  adctypes.StatusEvent{ResourceType: 
adctypes.TypeService, ResourceID: "svc1"},
+                       }},
+               }},
+       }}}
+
+       resourceFailures := map[types.NamespacedNameKind][]string{}
+       gatewayProxyMsgs, _ := d.classifySyncResult(configName, execErrs, 
resourceFailures)
+
+       if len(gatewayProxyMsgs) != 0 {
+               t.Errorf("expected nothing attributed to the GatewayProxy, got 
%v", gatewayProxyMsgs)
+       }
+       want := types.NamespacedNameKind{Kind: "ApisixRoute", Namespace: "ns1", 
Name: "route1"}
+       if got := resourceFailures[want]; len(got) != 1 || 
!strings.Contains(got[0], "unknown plugin foo") {
+               t.Errorf("resourceFailures[%v] = %v, want the failure reason", 
want, got)
+       }
+}
+
+func 
TestClassifySyncResultReportsEndpointStatusesEvenOnAFullyAttributedAddrErr(t 
*testing.T) {
+       // apisix-standalone's own re-validate path attaches EndpointStatuses 
to every addrErr
+       // regardless of whether FailedStatuses also named specific resources: 
an all-rejected
+       // write leaves every endpoint success:false, and one of those 
endpoints may be
+       // failing for a reason that has nothing to do with the resource 
FailedStatuses names
+       // (e.g. genuinely unreachable, not just rejecting this content). The 
two signals are
+       // independent: attributing the resource failure must never suppress 
reporting the
+       // endpoint failure too.
+       d := &apisixProvider{log: logr.Discard(), store: 
cache.NewStore(logr.Discard())}
+       const configName = "GatewayProxy/ns/gp"
+       if err := d.store.Insert(configName, []string{adctypes.TypeService}, 
&adctypes.Resources{
+               Services: []*adctypes.Service{{
+                       Metadata: adctypes.Metadata{
+                               ID: "svc1",
+                               Labels: map[string]string{
+                                       label.LabelKind:      "ApisixRoute",
+                                       label.LabelName:      "route1",
+                                       label.LabelNamespace: "ns1",
+                               },
+                       },
+               }},
+       }, nil); err != nil {
+               t.Fatalf("seeding the store: %v", err)
+       }
+
+       execErrs := types.ADCExecutionErrors{Errors: []types.ADCExecutionError{{
+               Name: configName,
+               FailedErrors: []types.ADCExecutionServerAddrError{{
+                       ServerAddr: "http://apisix:9180";,
+                       FailedStatuses: []adctypes.SyncStatus{{
+                               Reason: "unknown plugin foo",
+                               Event:  adctypes.StatusEvent{ResourceType: 
adctypes.TypeService, ResourceID: "svc1"},
+                       }},
+                       EndpointStatuses: []adctypes.EndpointStatus{
+                               {Server: "http://apisix-1:9180";, Success: 
false, Reason: "content rejected"},
+                               {Server: "http://apisix-2:9180";, Success: 
false, Reason: "connection refused"},
+                       },
+               }},
+       }}}
+
+       resourceFailures := map[types.NamespacedNameKind][]string{}
+       gatewayProxyMsgs, failedEndpoints := d.classifySyncResult(configName, 
execErrs, resourceFailures)
+
+       if len(gatewayProxyMsgs) != 1 || !strings.Contains(gatewayProxyMsgs[0], 
"http://apisix-2:9180: connection refused") {
+               t.Errorf("gatewayProxyMsgs = %v, want the endpoint summary", 
gatewayProxyMsgs)
+       }
+       if len(failedEndpoints) != 2 {
+               t.Errorf("failedEndpoints = %v, want every EndpointStatus entry 
passed through for event firing", failedEndpoints)
+       }
+       want := types.NamespacedNameKind{Kind: "ApisixRoute", Namespace: "ns1", 
Name: "route1"}
+       if got := resourceFailures[want]; len(got) != 1 || 
!strings.Contains(got[0], "unknown plugin foo") {
+               t.Errorf("resourceFailures[%v] = %v, want the failure reason", 
want, got)
+       }
+}
+
+func 
TestClassifySyncResultFallsBackToGatewayProxyWhenAFailedStatusHasNoResourceAttribution(t
 *testing.T) {
+       // apisix-standalone: FailedStatuses can be non-empty yet carry no 
Event to resolve a
+       // resource from at all, the whole addrErr is then a GatewayProxy-level 
signal.
+       d := &apisixProvider{log: logr.Discard()}
+       execErrs := types.ADCExecutionErrors{Errors: []types.ADCExecutionError{{
+               Name: "GatewayProxy/ns/gp",
+               FailedErrors: []types.ADCExecutionServerAddrError{{
+                       Err:            "all_failed",
+                       FailedStatuses: []adctypes.SyncStatus{{Reason: "schema 
error"}},
+               }},
+       }}}
+
+       resourceFailures := map[types.NamespacedNameKind][]string{}
+       gatewayProxyMsgs, _ := d.classifySyncResult("GatewayProxy/ns/gp", 
execErrs, resourceFailures)
+
+       if len(resourceFailures) != 0 {
+               t.Errorf("expected no resource attributed, got %v", 
resourceFailures)
+       }
+       if len(gatewayProxyMsgs) != 1 {
+               t.Errorf("gatewayProxyMsgs = %v, want exactly one fallback 
message", gatewayProxyMsgs)
+       }
+}
+
+func TestApplyResourceFailuresWritesNewFailuresAndClearsResolvedOnes(t 
*testing.T) {
+       updater := &fakeUpdater{}
+       d := &apisixProvider{
+               log:     logr.Discard(),
+               updater: updater,
+               resourceFailures: map[types.NamespacedNameKind][]string{
+                       {Kind: types.KindApisixRoute, Namespace: "ns", Name: 
"resolved"}:  {"used to fail"},
+                       {Kind: types.KindApisixRoute, Namespace: "ns", Name: 
"still-bad"}: {"still failing"},
+               },
+       }
+
+       newFailures := map[types.NamespacedNameKind][]string{
+               {Kind: types.KindApisixRoute, Namespace: "ns", Name: 
"still-bad"}: {"still failing"},
+               {Kind: types.KindApisixRoute, Namespace: "ns", Name: 
"newly-bad"}: {"new failure"},
+       }
+
+       d.applyResourceFailures(newFailures)
+
+       byName := map[string]bool{} // name -> whether the mutator it was given 
marks success
+       for _, u := range updater.updates {
+               cp := u.Mutator.Mutate(&apiv2.ApisixRoute{})
+               route := cp.(*apiv2.ApisixRoute)
+               accepted := false
+               for _, c := range route.Status.Conditions {
+                       if c.Type == string(apiv2.ConditionTypeAccepted) {
+                               accepted = c.Status == metav1.ConditionTrue
+                       }
+               }
+               byName[u.NamespacedName.Name] = accepted
+       }
+
+       if accepted, ok := byName["resolved"]; !ok || !accepted {
+               t.Errorf("expected \"resolved\" to be written Accepted=true, 
got present=%v accepted=%v", ok, accepted)
+       }
+       if accepted, ok := byName["still-bad"]; !ok || accepted {
+               t.Errorf("expected \"still-bad\" to be written Accepted=false, 
got present=%v accepted=%v", ok, accepted)
+       }
+       if accepted, ok := byName["newly-bad"]; !ok || accepted {
+               t.Errorf("expected \"newly-bad\" to be written Accepted=false, 
got present=%v accepted=%v", ok, accepted)
+       }
+       if _, ok := byName["resolved"]; len(byName) != 3 || !ok {
+               t.Errorf("expected exactly 3 writes (resolved, still-bad, 
newly-bad), got %v", byName)
+       }
+
+       if len(d.resourceFailures) != 2 {
+               t.Errorf("d.resourceFailures should be replaced with 
newFailures, got %v", d.resourceFailures)
+       }
+}
diff --git a/internal/provider/options.go b/internal/provider/options.go
index c47e7ce9..0aaf0e90 100644
--- a/internal/provider/options.go
+++ b/internal/provider/options.go
@@ -20,6 +20,9 @@ package provider
 import (
        "time"
 
+       "k8s.io/client-go/tools/record"
+       "sigs.k8s.io/controller-runtime/pkg/client"
+
        "github.com/apache/apisix-ingress-controller/internal/controller/config"
 )
 
@@ -34,6 +37,11 @@ type Options struct {
        DefaultBackendMode      string
        DefaultResolveEndpoints bool
        ListenerPortMatchMode   config.ListenerPortMatchMode
+       EventRecorder           record.EventRecorder
+       // K8sClient reads live Kubernetes objects, e.g. to fetch a resource's 
real UID
+       // before recording an Event against it. Named to stay unambiguous next 
to any
+       // provider-specific client, such as apisixProvider's own ADC client.
+       K8sClient client.Client
 }
 
 func (o *Options) ApplyToList(lo *Options) {
@@ -55,6 +63,12 @@ func (o *Options) ApplyToList(lo *Options) {
        if o.ListenerPortMatchMode != "" {
                lo.ListenerPortMatchMode = o.ListenerPortMatchMode
        }
+       if o.EventRecorder != nil {
+               lo.EventRecorder = o.EventRecorder
+       }
+       if o.K8sClient != nil {
+               lo.K8sClient = o.K8sClient
+       }
 }
 
 func (o *Options) ApplyOptions(opts []Option) *Options {
diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go
index 67b4a0b7..262550e8 100644
--- a/test/e2e/crds/v2/route.go
+++ b/test/e2e/crds/v2/route.go
@@ -2144,25 +2144,34 @@ spec:
                        err := 
s.CreateResourceFromString(fmt.Sprintf(apisixRouteSpec, s.Namespace()))
                        Expect(err).NotTo(HaveOccurred(), "creating 
ApisixRoute")
 
-                       By("check ApisixRoute status")
+                       // The data plane is entirely unreachable, ADC can't 
even attempt a per-resource
+                       // push, so there's nothing to attribute this to but 
the GatewayProxy: see
+                       // classifySyncResult.
+                       By("check GatewayProxy status")
                        s.RetryAssertion(func() string {
-                               output, _ := s.GetOutputFromString("ar", 
"default", "-o", "yaml", "-n", s.Namespace())
+                               output, _ := 
s.GetOutputFromString("gatewayproxy", "apisix-proxy-config", "-o", "yaml", 
"-n", s.Namespace())
                                return output
                        }).WithTimeout(30 * time.Second).
                                Should(
                                        And(
+                                               ContainSubstring("type: 
DataPlaneAvailable"),
                                                ContainSubstring(`status: 
"False"`),
-                                               ContainSubstring(`reason: 
SyncFailed`),
+                                               ContainSubstring("reason: 
DataPlaneInstanceUnavailable"),
                                        ),
                                )
 
                        s.Deployer.ScaleDataplane(1)
 
                        s.RetryAssertion(func() string {
-                               output, _ := s.GetOutputFromString("ar", 
"default", "-o", "yaml", "-n", s.Namespace())
+                               output, _ := 
s.GetOutputFromString("gatewayproxy", "apisix-proxy-config", "-o", "yaml", 
"-n", s.Namespace())
                                return output
                        }).WithTimeout(60 * time.Second).
-                               Should(ContainSubstring(`status: "True"`))
+                               Should(
+                                       And(
+                                               ContainSubstring("type: 
DataPlaneAvailable"),
+                                               ContainSubstring(`status: 
"True"`),
+                                       ),
+                               )
 
                        By("check route in APISIX")
                        s.RequestAssert(&scaffold.RequestAssert{
diff --git a/test/e2e/crds/v2/status.go b/test/e2e/crds/v2/status.go
index eaccb601..d43ddde7 100644
--- a/test/e2e/crds/v2/status.go
+++ b/test/e2e/crds/v2/status.go
@@ -109,7 +109,7 @@ spec:
       servicePort: 80
 `
                It("unknown plugin", func() {
-                       if os.Getenv("PROVIDER_TYPE") == "apisix-standalone" {
+                       if os.Getenv("PROVIDER_TYPE") == 
framework.ProviderTypeAPISIXStandalone {
                                Skip("apisix standalone does not validate 
unknown plugins")
                        }
                        By("apply ApisixRoute with valid plugin")
@@ -170,15 +170,20 @@ spec:
                        err = s.CreateResourceFromString(string(newServiceYaml))
                        Expect(err).NotTo(HaveOccurred(), "creating service")
 
-                       By("check ApisixRoute status")
+                       // This breaks the GatewayProxy's own admin API 
address, not a route's backend,
+                       // so ADC can't even attempt a per-resource push: 
there's nothing to attribute
+                       // this to but the GatewayProxy itself, for either 
backend type (see
+                       // classifySyncResult).
+                       By("check GatewayProxy status")
                        s.RetryAssertion(func() string {
-                               output, _ := s.GetOutputFromString("ar", 
"default", "-o", "yaml")
+                               output, _ := 
s.GetOutputFromString("gatewayproxy", "apisix-proxy-config", "-o", "yaml")
                                return output
                        }).WithTimeout(60 * time.Second).
                                Should(
                                        And(
+                                               ContainSubstring("type: 
DataPlaneAvailable"),
                                                ContainSubstring(`status: 
"False"`),
-                                               ContainSubstring(`reason: 
SyncFailed`),
+                                               ContainSubstring("reason: 
DataPlaneInstanceUnavailable"),
                                        ),
                                )
 
@@ -199,15 +204,16 @@ spec:
                        err = s.CreateResourceFromString(string(newServiceYaml))
                        Expect(err).NotTo(HaveOccurred(), "creating service")
 
-                       By("check ApisixRoute status after scaling up")
+                       By("check GatewayProxy status after scaling up")
                        s.RetryAssertion(func() string {
-                               output, _ := s.GetOutputFromString("ar", 
"default", "-o", "yaml")
+                               output, _ := 
s.GetOutputFromString("gatewayproxy", "apisix-proxy-config", "-o", "yaml")
                                return output
                        }).WithTimeout(60 * time.Second).
                                Should(
                                        And(
+                                               ContainSubstring("type: 
DataPlaneAvailable"),
                                                ContainSubstring(`status: 
"True"`),
-                                               ContainSubstring(`reason: 
Accepted`),
+                                               ContainSubstring("reason: 
DataPlaneAvailable"),
                                        ),
                                )
 
@@ -220,6 +226,116 @@ spec:
                        })
                })
 
+               It("gateway proxy reports an unreachable data plane instance", 
func() {
+                       if os.Getenv("PROVIDER_TYPE") != 
framework.ProviderTypeAPISIXStandalone {
+                               Skip("EndpointStatus, and the GatewayProxy 
condition derived from it, only exists in apisix-standalone mode")
+                       }
+
+                       const name = "gateway-proxy-partial-instance"
+                       gatewayProxyYaml := fmt.Sprintf(`
+apiVersion: apisix.apache.org/v1alpha1
+kind: GatewayProxy
+metadata:
+  name: %s
+  namespace: %s
+spec:
+  provider:
+    type: ControlPlane
+    controlPlane:
+      mode: apisix-standalone
+      endpoints:
+      - %s
+      - http://unreachable-instance.invalid:9180
+      - http://unreachable-instance-2.invalid:9180
+      auth:
+        type: AdminKey
+        adminKey:
+          value: "%s"
+`, name, s.Namespace(), s.Deployer.GetAdminEndpoint(), s.AdminKey())
+                       By("create GatewayProxy with two unreachable endpoints")
+                       err := s.CreateResourceFromString(gatewayProxyYaml)
+                       Expect(err).NotTo(HaveOccurred(), "creating 
GatewayProxy")
+
+                       ingressClassYaml := fmt.Sprintf(`
+apiVersion: networking.k8s.io/v1
+kind: IngressClass
+metadata:
+  name: %s
+spec:
+  controller: %s
+  parameters:
+    apiGroup: "apisix.apache.org"
+    kind: "GatewayProxy"
+    name: %s
+    namespace: %s
+    scope: Namespace
+`, name, s.GetControllerName(), name, s.Namespace())
+                       By("create IngressClass")
+                       err = 
s.CreateResourceFromStringWithNamespace(ingressClassYaml, "")
+                       Expect(err).NotTo(HaveOccurred(), "creating 
IngressClass")
+
+                       By("apply ApisixRoute through it")
+                       applier.MustApplyAPIv2(types.NamespacedName{Namespace: 
s.Namespace(), Name: "default"}, &apiv2.ApisixRoute{}, fmt.Sprintf(ar, 
s.Namespace(), name))
+
+                       By("check route in APISIX")
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Method:  "GET",
+                               Path:    "/get",
+                               Headers: map[string]string{"Host": "httpbin"},
+                               Check:   scaffold.WithExpectedStatus(200),
+                       })
+
+                       By("check GatewayProxy status")
+                       s.RetryAssertion(func() string {
+                               output, _ := 
s.GetOutputFromString("gatewayproxy", name, "-o", "yaml", "-n", s.Namespace())
+                               return output
+                       }).Should(
+                               And(
+                                       ContainSubstring("type: 
DataPlaneAvailable"),
+                                       ContainSubstring(`status: "False"`),
+                                       ContainSubstring("reason: 
DataPlaneInstanceUnavailable"),
+                                       
ContainSubstring("unreachable-instance.invalid"),
+                               ),
+                       )
+
+                       By("check a separate Warning event was recorded for 
each unreachable endpoint")
+                       s.RetryAssertion(func() []string {
+                               output, err := s.GetOutputFromString("events",
+                                       "--field-selector", 
"involvedObject.kind=GatewayProxy,involvedObject.name="+name,
+                                       "-n", s.Namespace(), "-o", "yaml")
+                               if err != nil {
+                                       return nil
+                               }
+                               var events corev1.EventList
+                               if err := yaml.Unmarshal([]byte(output), 
&events); err != nil {
+                                       return nil
+                               }
+                               messages := make([]string, 0, len(events.Items))
+                               for _, e := range events.Items {
+                                       if e.Type == "Warning" && e.Reason == 
"DataPlaneInstanceUnavailable" {
+                                               messages = append(messages, 
e.Message)
+                                       }
+                               }
+                               return messages
+                       }).Should(
+                               ConsistOf(
+                                       
ContainSubstring("unreachable-instance.invalid"),
+                                       
ContainSubstring("unreachable-instance-2.invalid"),
+                               ),
+                       )
+
+                       By("check the ApisixRoute itself is not marked as 
failed")
+                       s.RetryAssertion(func() string {
+                               output, _ := s.GetOutputFromString("ar", 
"default", "-o", "yaml", "-n", s.Namespace())
+                               return output
+                       }).Should(
+                               And(
+                                       ContainSubstring(`status: "True"`),
+                                       ContainSubstring("reason: Accepted"),
+                               ),
+                       )
+               })
+
                It("update the same status only once", func() {
                        By("apply ApisixRoute")
                        applier.MustApplyAPIv2(types.NamespacedName{Namespace: 
s.Namespace(), Name: "default"}, &apiv2.ApisixRoute{}, fmt.Sprintf(ar, 
s.Namespace(), s.Namespace()))
diff --git a/test/e2e/framework/manifests/ingress.yaml 
b/test/e2e/framework/manifests/ingress.yaml
index 022bb159..99d34ed9 100644
--- a/test/e2e/framework/manifests/ingress.yaml
+++ b/test/e2e/framework/manifests/ingress.yaml
@@ -119,6 +119,7 @@ rules:
   - apisixupstreams/status
   - backendtrafficpolicies/status
   - consumers/status
+  - gatewayproxies/status
   - httproutepolicies/status
   - l4routepolicies/status
   verbs:
@@ -238,7 +239,7 @@ metadata:
 roleRef:
   apiGroup: rbac.authorization.k8s.io
   kind: Role
-  name: {{ .Namespace }}-apisix-ingress-leader-election-role
+  name: apisix-ingress-leader-election-role
 subjects:
 - kind: ServiceAccount
   name: apisix-ingress-controller-manager
diff --git a/test/e2e/gatewayapi/status.go b/test/e2e/gatewayapi/status.go
index a79d5559..9c4e2e3d 100644
--- a/test/e2e/gatewayapi/status.go
+++ b/test/e2e/gatewayapi/status.go
@@ -137,15 +137,20 @@ spec:
                        err = s.CreateResourceFromString(string(newServiceYaml))
                        Expect(err).NotTo(HaveOccurred(), "creating service")
 
-                       By("check ApisixRoute status")
+                       // This breaks the GatewayProxy's own admin API 
address, not a route's backend,
+                       // so ADC can't even attempt a per-resource push: 
there's nothing to attribute
+                       // this to but the GatewayProxy itself, for either 
backend type (see
+                       // classifySyncResult).
+                       By("check GatewayProxy status")
                        s.RetryAssertion(func() string {
-                               output, _ := s.GetOutputFromString("httproute", 
"httpbin", "-o", "yaml")
+                               output, _ := 
s.GetOutputFromString("gatewayproxy", "apisix-proxy-config", "-o", "yaml")
                                return output
                        }).WithTimeout(60 * time.Second).
                                Should(
                                        And(
+                                               ContainSubstring("type: 
DataPlaneAvailable"),
                                                ContainSubstring(`status: 
"False"`),
-                                               ContainSubstring(`reason: 
SyncFailed`),
+                                               ContainSubstring("reason: 
DataPlaneInstanceUnavailable"),
                                        ),
                                )
 

Reply via email to