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 e8ffcdae test: add adc rust variant for e2e (#2852)
e8ffcdae is described below

commit e8ffcdae3d75d6f8aac9265d2c7d5c7ad62b42b8
Author: Zeping Bai <[email protected]>
AuthorDate: Thu Sep 3 17:02:08 2026 +0800

    test: add adc rust variant for e2e (#2852)
---
 .github/adc-rust/Dockerfile           |  27 ++++
 .github/adc-rust/entrypoint.sh        |  24 ++++
 .github/workflows/apisix-e2e-test.yml |  54 +++++++-
 api/adc/types.go                      |  17 ++-
 internal/adc/client/executor.go       |  89 ++++++++-----
 internal/adc/client/executor_test.go  | 224 ++++++++++++++++++++++++++++++++-
 internal/types/error.go               |   7 +-
 test/e2e/crds/v2/route.go             |   8 +-
 test/e2e/crds/v2/tls.go               |  51 +++++---
 test/e2e/scaffold/adc_standalone.go   | 227 ++++++++++++++++++++++++++++++++++
 test/e2e/scaffold/apisix_deployer.go  |   6 +
 11 files changed, 672 insertions(+), 62 deletions(-)

diff --git a/.github/adc-rust/Dockerfile b/.github/adc-rust/Dockerfile
new file mode 100644
index 00000000..9da6c9ad
--- /dev/null
+++ b/.github/adc-rust/Dockerfile
@@ -0,0 +1,27 @@
+# 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.
+
+FROM ubuntu:24.04
+
+RUN apt-get update && apt-get install -y --no-install-recommends 
ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY adc /usr/local/bin/adc
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /usr/local/bin/adc /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/.github/adc-rust/entrypoint.sh b/.github/adc-rust/entrypoint.sh
new file mode 100644
index 00000000..a66181a6
--- /dev/null
+++ b/.github/adc-rust/entrypoint.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+# 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.
+
+set -e
+if [ "$1" = "server" ]; then
+  shift
+  set -- ingress-server "$@"
+fi
+exec /usr/local/bin/adc "$@"
diff --git a/.github/workflows/apisix-e2e-test.yml 
b/.github/workflows/apisix-e2e-test.yml
index 5ff5a09d..38b9a920 100644
--- a/.github/workflows/apisix-e2e-test.yml
+++ b/.github/workflows/apisix-e2e-test.yml
@@ -31,11 +31,15 @@ concurrency:
 
 env:
   ADC_VERSION: dev
+  ADC_RUST_VERSION: "0.30.2"
 
 jobs:
   e2e-test:
     strategy:
       matrix:
+        adc_impl:
+        - node
+        - rust
         provider_type:
         - apisix-standalone
         - apisix
@@ -91,8 +95,8 @@ jobs:
         run: |
           make install
 
-      - name: Extract adc binary
-        if: ${{ env.ADC_VERSION == 'dev' }}
+      - name: Setup adc (node)
+        if: ${{ env.ADC_VERSION == 'dev' && matrix.adc_impl == 'node' }}
         run: |
           docker create --name adc-temp ghcr.io/api7/adc:dev
           # main.cjs lives in the image's WORKDIR, not at the container root,
@@ -102,6 +106,24 @@ jobs:
           node $(pwd)/adc.js -v
           echo "ADC_BIN=node $(pwd)/adc.js" >> $GITHUB_ENV
 
+      - name: Setup adc (rust)
+        if: ${{ matrix.adc_impl == 'rust' }}
+        run: |
+          curl -fsSL -o adc.tar.gz 
"https://github.com/api7/adc/releases/download/v${{ env.ADC_RUST_VERSION 
}}/adc_rust_${{ env.ADC_RUST_VERSION }}_linux_amd64.tar.gz"
+          tar -xzf adc.tar.gz adc
+          chmod +x adc
+          ./adc --version
+
+          # The pod manifest points ADCImage at a real image, unlike ADC_BIN
+          # (a bare host path the test scaffold execs directly) — the Rust
+          # binary needs to be wrapped in one to stand in as the sidecar.
+          cp .github/adc-rust/Dockerfile .github/adc-rust/entrypoint.sh ./
+          docker build -t adc-rust:dev -f Dockerfile .
+          kind load docker-image adc-rust:dev --name apisix-ingress-cluster
+
+          echo "ADC_BIN=$(pwd)/adc" >> $GITHUB_ENV
+          echo "ADC_IMAGE=adc-rust:dev" >> $GITHUB_ENV
+
       - name: Start OpenLDAP server
         run: make e2e-ldap
 
@@ -120,6 +142,12 @@ jobs:
           fi
 
   disable-gateway-api:
+    strategy:
+      matrix:
+        adc_impl:
+        - node
+        - rust
+      fail-fast: false
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
@@ -163,8 +191,8 @@ jobs:
         run: |
           make kind-load-images
 
-      - name: Extract adc binary
-        if: ${{ env.ADC_VERSION == 'dev' }}
+      - name: Setup adc (node)
+        if: ${{ env.ADC_VERSION == 'dev' && matrix.adc_impl == 'node' }}
         run: |
           docker create --name adc-temp ghcr.io/api7/adc:dev
           # main.cjs lives in the image's WORKDIR, not at the container root,
@@ -174,6 +202,24 @@ jobs:
           node $(pwd)/adc.js -v
           echo "ADC_BIN=node $(pwd)/adc.js" >> $GITHUB_ENV
 
+      - name: Setup adc (rust)
+        if: ${{ matrix.adc_impl == 'rust' }}
+        run: |
+          curl -fsSL -o adc.tar.gz 
"https://github.com/api7/adc/releases/download/v${{ env.ADC_RUST_VERSION 
}}/adc_rust_${{ env.ADC_RUST_VERSION }}_linux_amd64.tar.gz"
+          tar -xzf adc.tar.gz adc
+          chmod +x adc
+          ./adc --version
+
+          # The pod manifest points ADCImage at a real image, unlike ADC_BIN
+          # (a bare host path the test scaffold execs directly) — the Rust
+          # binary needs to be wrapped in one to stand in as the sidecar.
+          cp .github/adc-rust/Dockerfile .github/adc-rust/entrypoint.sh ./
+          docker build -t adc-rust:dev -f Dockerfile .
+          kind load docker-image adc-rust:dev --name apisix-ingress-cluster
+
+          echo "ADC_BIN=$(pwd)/adc" >> $GITHUB_ENV
+          echo "ADC_IMAGE=adc-rust:dev" >> $GITHUB_ENV
+
       - name: Install CRDs
         run: make install-crds
 
diff --git a/api/adc/types.go b/api/adc/types.go
index e4d3a06c..38fb3983 100644
--- a/api/adc/types.go
+++ b/api/adc/types.go
@@ -722,11 +722,22 @@ type SyncResult struct {
 // This is only used in apisix-standalone mode where endpoint-level status is 
reported
 // instead of resource-level status.
 type EndpointStatus struct {
-       Server  string `json:"server"`
-       Success bool   `json:"success"`
-       Reason  string `json:"reason,omitempty"`
+       Server       string       `json:"server"`
+       Success      bool         `json:"success"`
+       Confirmation Confirmation `json:"confirmation,omitempty"`
+       Reason       string       `json:"reason,omitempty"`
 }
 
+type Confirmation string
+
+const (
+       // ConfirmationApplied means configuration has been accepted and 
applied.
+       ConfirmationApplied Confirmation = "applied"
+
+       // ConfirmationAccepted means the configuration has been accepted but 
not yet applied.
+       ConfirmationAccepted Confirmation = "accepted"
+)
+
 type SyncStatus struct {
        Event    StatusEvent     `json:"event"`
        FailedAt time.Time       `json:"failed_at,omitempty"`
diff --git a/internal/adc/client/executor.go b/internal/adc/client/executor.go
index 92feb7d9..cfb46a36 100644
--- a/internal/adc/client/executor.go
+++ b/internal/adc/client/executor.go
@@ -404,6 +404,21 @@ func (e *HTTPADCExecutor) buildHTTPRequest(ctx 
context.Context, serverAddr strin
        return req, nil
 }
 
+// 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,
+// say) still reports it once.
+func distinctReasons(failed []adctypes.SyncStatus) string {
+       seen := make(map[string]bool, len(failed))
+       reasons := make([]string, 0, len(failed))
+       for _, f := range failed {
+               if f.Reason != "" && !seen[f.Reason] {
+                       seen[f.Reason] = true
+                       reasons = append(reasons, f.Reason)
+               }
+       }
+       return strings.Join(reasons, "; ")
+}
+
 // handleHTTPResponse handles the HTTP response from ADC Server
 func (e *HTTPADCExecutor) handleHTTPResponse(resp *http.Response, serverAddr 
string) error {
        body, err := io.ReadAll(resp.Body)
@@ -417,51 +432,59 @@ func (e *HTTPADCExecutor) handleHTTPResponse(resp 
*http.Response, serverAddr str
                "response", string(body),
        )
 
-       // not only 200, HTTP 202 is also accepted
-       if resp.StatusCode/100 != 2 {
+       // ADC Server's /sync status codes:
+       //   - 200: apisix-standalone, every server took and confirmed the 
write.
+       //   - 202: apisix/api7ee always (no confirm concept); 
apisix-standalone when every
+       //     server took the write but not every one confirmed it, or one 
server rejected
+       //     it while others accepted.
+       //   - 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
+       //     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
+       // one: most fields are optional, so an unrelated shape can unmarshal 
as an empty,
+       // unremarkable "success".
+       switch resp.StatusCode {
+       case http.StatusOK, http.StatusAccepted, http.StatusUnprocessableEntity:
+       default:
                return types.ADCExecutionServerAddrError{
                        ServerAddr: serverAddr,
                        Err:        fmt.Sprintf("HTTP %d: %s", resp.StatusCode, 
string(body)),
                }
        }
 
-       // Parse response body
        var result adctypes.SyncResult
        if err := json.Unmarshal(body, &result); err != nil {
                return fmt.Errorf("failed to unmarshal response body: %s, err: 
%w", string(body), err)
        }
 
-       // Check for sync failures
-       // For apisix-standalone mode: Failed is always empty, check 
EndpointStatus instead
-       if result.FailedCount > 0 {
-               if len(result.Failed) > 0 {
-                       reason := result.Failed[0].Reason
-                       e.log.Error(fmt.Errorf("ADC Server sync failed: %s", 
reason), "ADC Server sync failed", "result", result)
-                       return types.ADCExecutionServerAddrError{
-                               ServerAddr:     serverAddr,
-                               Err:            reason,
-                               FailedStatuses: result.Failed,
-                       }
+       if len(result.Failed) > 0 {
+               reason := distinctReasons(result.Failed)
+               e.log.Error(fmt.Errorf("ADC Server sync failed: %s", reason), 
"ADC Server sync failed", "result", result)
+               return types.ADCExecutionServerAddrError{
+                       ServerAddr:       serverAddr,
+                       Err:              reason,
+                       FailedStatuses:   result.Failed,
+                       EndpointStatuses: result.EndpointStatus,
                }
-               if len(result.EndpointStatus) > 0 {
-                       // apisix-standalone mode: use EndpointStatus
-                       var failedEndpoints []string
-                       for _, ep := range result.EndpointStatus {
-                               if !ep.Success {
-                                       failedEndpoints = 
append(failedEndpoints, fmt.Sprintf("%s: %s", ep.Server, ep.Reason))
-                               }
-                       }
-                       if len(failedEndpoints) > 0 {
-                               reason := strings.Join(failedEndpoints, "; ")
-                               e.log.Error(fmt.Errorf("ADC Server sync failed 
(standalone mode): %s", reason), "ADC Server sync failed", "result", result)
-                               return types.ADCExecutionServerAddrError{
-                                       ServerAddr: serverAddr,
-                                       Err:        reason,
-                                       FailedStatuses: []adctypes.SyncStatus{
-                                               {Reason: reason},
-                                       },
-                               }
-                       }
+       }
+
+       var failedEndpoints []string
+       for _, ep := range result.EndpointStatus {
+               if !ep.Success {
+                       failedEndpoints = append(failedEndpoints, 
fmt.Sprintf("%s: %s", ep.Server, ep.Reason))
+               }
+       }
+       if len(failedEndpoints) > 0 {
+               reason := strings.Join(failedEndpoints, "; ")
+               e.log.Error(fmt.Errorf("ADC Server sync failed (standalone 
mode): %s", reason), "ADC Server sync failed", "result", result)
+               return types.ADCExecutionServerAddrError{
+                       ServerAddr:       serverAddr,
+                       Err:              reason,
+                       EndpointStatuses: result.EndpointStatus,
                }
        }
 
diff --git a/internal/adc/client/executor_test.go 
b/internal/adc/client/executor_test.go
index a7f998e6..0d0eb3c8 100644
--- a/internal/adc/client/executor_test.go
+++ b/internal/adc/client/executor_test.go
@@ -22,6 +22,8 @@ import (
        "encoding/json"
        "errors"
        "io"
+       "net/http"
+       "strings"
        "testing"
 
        "github.com/go-logr/logr"
@@ -52,7 +54,7 @@ func TestHTTPADCExecutorBuildHTTPRequestBypassCache(t 
*testing.T) {
        config := adctypes.Config{Name: "GatewayProxy/ns/name"}
 
        // A sync that is not recovering from a rejection must stay byte for 
byte the request an
-       // ADC server older than 0.27.0 -- which rejects unknown fields -- 
already accepts.
+       // ADC server older than 0.27.0 (which rejects unknown fields) already 
accepts.
        opts, raw := build(config, pathSync)
        assert.Equal(t, "GatewayProxy/ns/name", opts.CacheKey)
        assert.NotContains(t, raw, "bypassCache")
@@ -287,6 +289,226 @@ func TestClientSyncDoesNotReportTheSameRejectionTwice(t 
*testing.T) {
        assert.Len(t, execErrs.Errors, 1)
 }
 
+func httpResponse(status int, body string) *http.Response {
+       return &http.Response{StatusCode: status, Body: 
io.NopCloser(strings.NewReader(body))}
+}
+
+func TestHandleHTTPResponseParsesStructuredReasonOn422(t *testing.T) {
+       // apisix-standalone's /sync answers an all-rejected document with a 
422 carrying the same
+       // structured `failed` shape a 2xx partial failure uses. That detail 
must survive, not
+       // collapse into a generic "HTTP 422: <raw body>" indistinguishable 
from a 5xx.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body, err := json.Marshal(adctypes.SyncResult{
+               Status:      "all_failed",
+               FailedCount: 1,
+               Failed:      []adctypes.SyncStatus{{Reason: "unknown plugin 
foo"}},
+       })
+       require.NoError(t, err)
+
+       err = e.handleHTTPResponse(httpResponse(http.StatusUnprocessableEntity, 
string(body)), "http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       assert.Equal(t, "unknown plugin foo", addrErr.Err)
+       require.Len(t, addrErr.FailedStatuses, 1)
+       assert.Equal(t, "unknown plugin foo", addrErr.FailedStatuses[0].Reason)
+}
+
+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.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+
+       err := 
e.handleHTTPResponse(httpResponse(http.StatusUnprocessableEntity, "not json"), 
"http://apisix:9180";)
+
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "failed to unmarshal response body")
+}
+
+func TestHandleHTTPResponse400NeverAttemptsToParseTheBody(t *testing.T) {
+       // 400 never carries a SyncResult body (that's 422's job); must stay a 
raw error even
+       // when the body happens to look like one.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body, err := json.Marshal(adctypes.SyncResult{
+               Status:      "all_failed",
+               FailedCount: 1,
+               Failed:      []adctypes.SyncStatus{{Reason: "unknown plugin 
foo"}},
+       })
+       require.NoError(t, err)
+
+       err = e.handleHTTPResponse(httpResponse(http.StatusBadRequest, 
string(body)), "http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       assert.Contains(t, addrErr.Err, "HTTP 400")
+       assert.Nil(t, addrErr.FailedStatuses)
+}
+
+func TestHandleHTTPResponseTreats5xxAsGenericTransportFailure(t *testing.T) {
+       // A 5xx never carries the /sync response shape at all. Unlike 400, it 
must not be
+       // parsed as one, and stays the plain "HTTP <code>: <body>" it always 
was.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+
+       err := e.handleHTTPResponse(httpResponse(http.StatusBadGateway, 
"upstream connect error"), "http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       assert.Contains(t, addrErr.Err, "HTTP 502")
+       assert.Nil(t, addrErr.FailedStatuses)
+}
+
+func TestHandleHTTPResponseNeverParses500AsASyncResult(t *testing.T) {
+       // A 500's body is {"message": ...}. Most SyncResult fields are 
optional, so this would
+       // unmarshal without error into an empty, unremarkable "success" if it 
were ever tried.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+
+       err := 
e.handleHTTPResponse(httpResponse(http.StatusInternalServerError, `{"message": 
"boom"}`), "http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       assert.Contains(t, addrErr.Err, "HTTP 500")
+       assert.Nil(t, addrErr.FailedStatuses)
+}
+
+func TestHandleHTTPResponse413HasNoBodyToParse(t *testing.T) {
+       e := &HTTPADCExecutor{log: logr.Discard()}
+
+       err := 
e.handleHTTPResponse(httpResponse(http.StatusRequestEntityTooLarge, ""), 
"http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       assert.Contains(t, addrErr.Err, "HTTP 413")
+}
+
+func 
TestHandleHTTPResponse422WithNeitherFailedNorEndpointStatusIsTreatedAsSuccess(t 
*testing.T) {
+       // This combination shouldn't happen; a genuine rejection always shows 
up in one of
+       // them. So it isn't specially guarded against, it just falls through 
as success.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body, err := json.Marshal(adctypes.SyncResult{Status: "all_failed"})
+       require.NoError(t, err)
+
+       err = e.handleHTTPResponse(httpResponse(http.StatusUnprocessableEntity, 
string(body)), "http://apisix:9180";)
+
+       assert.NoError(t, err)
+}
+
+func 
TestHandleHTTPResponse422FallsBackToEndpointStatusWhenNothingWasAttributed(t 
*testing.T) {
+       // An old gateway with no /validate endpoint (or a conf_version race) 
leaves Failed
+       // empty even on a genuine all-server rejection. EndpointStatus is then 
the only place
+       // a reason shows up, and it must not be ignored just because the 
status is 422.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body, err := json.Marshal(adctypes.SyncResult{
+               Status: "all_failed",
+               EndpointStatus: []adctypes.EndpointStatus{
+                       {Server: "http://apisix-1:9180";, Success: false, 
Reason: "unknown plugin foo"},
+                       {Server: "http://apisix-2:9180";, Success: false, 
Reason: "unknown plugin foo"},
+               },
+       })
+       require.NoError(t, err)
+
+       err = e.handleHTTPResponse(httpResponse(http.StatusUnprocessableEntity, 
string(body)), "http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       assert.Contains(t, addrErr.Err, "unknown plugin foo")
+       assert.NotContains(t, addrErr.Err, "HTTP 422", "should report the real 
per-server reason, not the generic fallback")
+       assert.Nil(t, addrErr.FailedStatuses, "nothing was attributed to a 
resource")
+       require.Len(t, addrErr.EndpointStatuses, 2)
+}
+
+func TestHandleHTTPResponseReportsAPerServerFailureOnA2xxWithNoFailedCount(t 
*testing.T) {
+       // apisix-standalone: one server rejecting the write while others 
accept it doesn't fail
+       // the document (every event still lands in Success, FailedCount stays 
0). This must
+       // still surface as an error, not silently succeed because FailedCount 
says nothing failed.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body, err := json.Marshal(adctypes.SyncResult{
+               Status:      "partial_failure",
+               FailedCount: 0,
+               EndpointStatus: []adctypes.EndpointStatus{
+                       {Server: "http://apisix-1:9180";, Success: true, 
Confirmation: adctypes.ConfirmationApplied},
+                       {Server: "http://apisix-2:9180";, Success: false, 
Reason: "connection refused"},
+               },
+       })
+       require.NoError(t, err)
+
+       err = e.handleHTTPResponse(httpResponse(http.StatusAccepted, 
string(body)), "http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       assert.Contains(t, addrErr.Err, "http://apisix-2:9180: connection 
refused")
+       require.Len(t, addrErr.EndpointStatuses, 2)
+}
+
+func 
TestHandleHTTPResponse422CarriesBothFailedAndEndpointStatusesButErrPicksFailed(t
 *testing.T) {
+       // A real 422 always has EndpointStatus populated too, restating the 
same rejection per
+       // server. Err (the human-readable text) picks Failed's specific reason 
rather than
+       // repeating it, but both structured fields still travel: Failed feeds 
bad-resource
+       // exclusion, EndpointStatuses feeds per-instance Gateway/CRD 
availability, and neither
+       // consumer should have to reconstruct its input from the other's.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body, err := json.Marshal(adctypes.SyncResult{
+               Status:      "all_failed",
+               FailedCount: 1,
+               Failed:      []adctypes.SyncStatus{{Reason: "unknown plugin 
foo"}},
+               EndpointStatus: []adctypes.EndpointStatus{
+                       {Server: "http://apisix-1:9180";, Success: false, 
Reason: "unknown plugin foo"},
+                       {Server: "http://apisix-2:9180";, Success: false, 
Reason: "unknown plugin foo"},
+               },
+       })
+       require.NoError(t, err)
+
+       err = e.handleHTTPResponse(httpResponse(http.StatusUnprocessableEntity, 
string(body)), "http://apisix:9180";)
+
+       var addrErr types.ADCExecutionServerAddrError
+       require.ErrorAs(t, err, &addrErr)
+       assert.Equal(t, "unknown plugin foo", addrErr.Err)
+       require.Len(t, addrErr.FailedStatuses, 1)
+       require.Len(t, addrErr.EndpointStatuses, 2)
+}
+
+func TestDistinctReasonsJoinsWithoutDuplicates(t *testing.T) {
+       reasons := distinctReasons([]adctypes.SyncStatus{
+               {Reason: "unknown plugin foo"},
+               {Reason: "connection refused"},
+               {Reason: "unknown plugin foo"},
+               {Reason: ""},
+       })
+       assert.Equal(t, "unknown plugin foo; connection refused", reasons)
+}
+
+func TestHandleHTTPResponseAcceptedIsNotAFailure(t *testing.T) {
+       // "accepted" means apisix-standalone gave up waiting for the data 
plane to confirm the
+       // write, not that the write failed. Turning that into a Gateway/CRD 
condition is a
+       // separate concern from whether this call itself succeeded.
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body, err := json.Marshal(adctypes.SyncResult{
+               Status: "success",
+               EndpointStatus: []adctypes.EndpointStatus{
+                       {Server: "http://apisix:9180";, Success: true, 
Confirmation: adctypes.ConfirmationAccepted},
+               },
+       })
+       require.NoError(t, err)
+
+       err = e.handleHTTPResponse(httpResponse(http.StatusAccepted, 
string(body)), "http://apisix:9180";)
+
+       assert.NoError(t, err)
+}
+
+func TestHandleHTTPResponseAppliedSucceeds(t *testing.T) {
+       e := &HTTPADCExecutor{log: logr.Discard()}
+       body, err := json.Marshal(adctypes.SyncResult{
+               Status: "success",
+               EndpointStatus: []adctypes.EndpointStatus{
+                       {Server: "http://apisix:9180";, Success: true, 
Confirmation: adctypes.ConfirmationApplied},
+               },
+       })
+       require.NoError(t, err)
+
+       err = e.handleHTTPResponse(httpResponse(http.StatusOK, string(body)), 
"http://apisix:9180";)
+
+       assert.NoError(t, err)
+}
+
 func TestIsConfVersionRejection(t *testing.T) {
        assert.False(t, isConfVersionRejection(nil))
        assert.False(t, isConfVersionRejection(errors.New("context deadline 
exceeded")))
diff --git a/internal/types/error.go b/internal/types/error.go
index 1388637d..ace6992b 100644
--- a/internal/types/error.go
+++ b/internal/types/error.go
@@ -84,9 +84,10 @@ func (e ADCExecutionError) Error() string {
 }
 
 type ADCExecutionServerAddrError struct {
-       Err            string
-       ServerAddr     string
-       FailedStatuses []adc.SyncStatus
+       Err              string
+       ServerAddr       string
+       FailedStatuses   []adc.SyncStatus
+       EndpointStatuses []adc.EndpointStatus
 }
 
 func (e ADCExecutionServerAddrError) Error() string {
diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go
index bf7aa967..4159a42b 100644
--- a/test/e2e/crds/v2/route.go
+++ b/test/e2e/crds/v2/route.go
@@ -2358,9 +2358,11 @@ spec:
                                &apiv2.ApisixRoute{}, 
fmt.Sprintf(apisixRouteSpec, s.Namespace()))
 
                        By("check upstreams")
-                       upstreams, err := 
s.DefaultDataplaneResource().Upstream().List(context.Background())
-                       Expect(err).ShouldNot(HaveOccurred())
-                       Expect(upstreams).Should(HaveLen(4))
+                       Eventually(func(g Gomega) {
+                               upstreams, err := 
s.DefaultDataplaneResource().Upstream().List(context.Background())
+                               g.Expect(err).ShouldNot(HaveOccurred())
+                               g.Expect(upstreams).Should(HaveLen(4))
+                       
}).WithTimeout(scaffold.DefaultTimeout).ProbeEvery(scaffold.DefaultInterval).Should(Succeed())
 
                        By("verify ApisixRoute works")
                        s.RequestAssert(&scaffold.RequestAssert{
diff --git a/test/e2e/crds/v2/tls.go b/test/e2e/crds/v2/tls.go
index 314f029d..8a6bc32a 100644
--- a/test/e2e/crds/v2/tls.go
+++ b/test/e2e/crds/v2/tls.go
@@ -19,7 +19,9 @@ package v2
 
 import (
        "context"
+       cryptotls "crypto/tls"
        "fmt"
+       "io"
        "net/http"
        "strings"
        "time"
@@ -323,25 +325,44 @@ spec:
                                        tls[0].Client.SkipMtlsURIRegex[0] == 
skipMtlsUriRegex
                        }).WithTimeout(30 * time.Second).ProbeEvery(1 * 
time.Second).Should(BeTrue())
 
+                       // A raw net/http client rather than 
s.NewAPISIXHttpsClient: its
+                       // httpexpect reporter turns a transient TLS handshake 
error into an
+                       // immediate spec failure, which defeats the Eventually 
retry while
+                       // APISIX is still rebuilding its SNI router right 
after the sync.
+                       transport := &http.Transport{
+                               TLSClientConfig: 
&cryptotls.Config{InsecureSkipVerify: true, ServerName: host},
+                       }
+                       defer transport.CloseIdleConnections()
+                       httpsClient := &http.Client{Timeout: 5 * time.Second, 
Transport: transport}
+                       httpsGet := func(path string) (int, error) {
+                               req, err := http.NewRequest(http.MethodGet, 
"https://"+s.GetAPISIXHTTPSEndpoint()+path, nil)
+                               if err != nil {
+                                       return 0, err
+                               }
+                               req.Host = host
+                               resp, err := httpsClient.Do(req)
+                               if err != nil {
+                                       return 0, err
+                               }
+                               defer func() { _ = resp.Body.Close() }()
+                               _, _ = io.Copy(io.Discard, resp.Body)
+                               return resp.StatusCode, nil
+                       }
+
                        By("test HTTPS request to path matching 
skip_mtls_uri_regex without client cert")
-                       Eventually(func() int {
-                               return s.NewAPISIXHttpsClient(host).
-                                       GET("/ip").
-                                       WithHost(host).
-                                       Expect().
-                                       Raw().StatusCode
+                       Eventually(func() (int, error) {
+                               return httpsGet("/ip")
                        }).WithTimeout(30 * time.Second).ProbeEvery(1 * 
time.Second).Should(Equal(http.StatusOK))
 
                        By("test HTTPS request to non-matching path without 
client cert should fail")
-                       Eventually(func() bool {
-                               resp := s.NewAPISIXHttpsClient(host).
-                                       GET("/get").
-                                       WithHost(host).
-                                       Expect().
-                                       Raw()
-                               return resp.StatusCode == http.StatusBadRequest 
||
-                                       resp.StatusCode == http.StatusForbidden 
||
-                                       resp.StatusCode >= 500
+                       Eventually(func() (bool, error) {
+                               status, err := httpsGet("/get")
+                               if err != nil {
+                                       return false, err
+                               }
+                               return status == http.StatusBadRequest ||
+                                       status == http.StatusForbidden ||
+                                       status >= 500, nil
                        }).WithTimeout(30 * time.Second).ProbeEvery(1 * 
time.Second).Should(BeTrue())
 
                        // Verify the configuration details
diff --git a/test/e2e/scaffold/adc_standalone.go 
b/test/e2e/scaffold/adc_standalone.go
new file mode 100644
index 00000000..d9933df5
--- /dev/null
+++ b/test/e2e/scaffold/adc_standalone.go
@@ -0,0 +1,227 @@
+// 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 scaffold
+
+import (
+       "context"
+       "encoding/json"
+
+       "github.com/gavv/httpexpect/v2"
+
+       adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+)
+
+// standaloneDataplaneResource implements DataplaneResource by reading 
straight off
+// apisix-standalone's own Admin API. adc's plain CLI has no --backend value 
for
+// apisix-standalone -- that mode only exists inside the ingress-server's 
/sync and
+// /validate task bodies -- so `adc dump` can't be used here the way it is for 
apisix
+// and api7ee.
+type standaloneDataplaneResource struct {
+       client   *httpexpect.Expect
+       adminKey string
+}
+
+func newStandaloneDataplaneResource(client *httpexpect.Expect, adminKey 
string) DataplaneResource {
+       return &standaloneDataplaneResource{client: client, adminKey: adminKey}
+}
+
+// standaloneConfig mirrors the document GET /apisix/admin/configs returns: 
every
+// resource type in its own flat top-level array, cross-referenced by 
service_id /
+// upstream_id, rather than nested the way adc's own document model is. Most 
fields
+// already share adc's json tags, so they decode directly into adc's types.
+type standaloneConfig struct {
+       Routes       []*adctypes.Route       `json:"routes"`
+       Services     []*adctypes.Service     `json:"services"`
+       Upstreams    []*adctypes.Upstream    `json:"upstreams"`
+       SSLs         []*standaloneSSL        `json:"ssls"`
+       StreamRoutes []*adctypes.StreamRoute `json:"stream_routes"`
+       Consumers    []*adctypes.Consumer    `json:"consumers"`
+}
+
+// standaloneCrossRefs decodes the service_id / upstream_id links the flat 
document
+// carries instead of nesting. adc's Route/Service/StreamRoute types have no 
field for
+// them, so they're decoded separately from the same bytes and used only to 
rebuild the
+// nesting DataplaneResource callers expect (e.g. 
Service().List()[0].Upstream).
+type standaloneCrossRefs struct {
+       Routes []struct {
+               ServiceID string `json:"service_id"`
+       } `json:"routes"`
+       Services []struct {
+               UpstreamID string `json:"upstream_id"`
+       } `json:"services"`
+       StreamRoutes []struct {
+               ServiceID string `json:"service_id"`
+       } `json:"stream_routes"`
+}
+
+// standaloneSSL mirrors APISIX's native ssl resource, which keeps a single 
cert/key
+// pair (or a parallel certs/keys pair for multiple SNIs) instead of adc's 
Certificates
+// list.
+type standaloneSSL struct {
+       adctypes.SSL
+
+       Cert  string   `json:"cert"`
+       Key   string   `json:"key"`
+       Certs []string `json:"certs"`
+       Keys  []string `json:"keys"`
+}
+
+func (s *standaloneSSL) toADC() *adctypes.SSL {
+       out := s.SSL
+       switch {
+       case s.Cert != "":
+               out.Certificates = []adctypes.Certificate{{Certificate: s.Cert, 
Key: s.Key}}
+       case len(s.Certs) > 0:
+               out.Certificates = make([]adctypes.Certificate, 0, len(s.Certs))
+               for i, cert := range s.Certs {
+                       var key string
+                       if i < len(s.Keys) {
+                               key = s.Keys[i]
+                       }
+                       out.Certificates = append(out.Certificates, 
adctypes.Certificate{Certificate: cert, Key: key})
+               }
+       }
+       return &out
+}
+
+// dump fetches the current config and decodes it twice from the same bytes: 
once into
+// adc's own types, once into the cross-reference IDs those types don't carry. 
Both
+// decodes walk the same arrays in the same order, so entries at the same 
index refer to
+// the same resource.
+func (r *standaloneDataplaneResource) dump(ctx context.Context) 
(*standaloneConfig, *standaloneCrossRefs, error) {
+       reporter := &ErrorReporter{}
+       resp := r.client.GET("/apisix/admin/configs").
+               WithContext(ctx).
+               WithHeader("X-API-KEY", r.adminKey).
+               WithReporter(reporter).
+               Expect()
+       if err := reporter.Err(); err != nil {
+               return nil, nil, err
+       }
+       body := []byte(resp.Body().Raw())
+
+       var cfg standaloneConfig
+       if err := json.Unmarshal(body, &cfg); err != nil {
+               return nil, nil, err
+       }
+       var refs standaloneCrossRefs
+       if err := json.Unmarshal(body, &refs); err != nil {
+               return nil, nil, err
+       }
+       return &cfg, &refs, nil
+}
+
+// withNesting rebuilds the Service -> Upstream/Routes/StreamRoutes nesting 
from the
+// cross-reference IDs, matching what callers of Service().List() expect.
+func withNesting(cfg *standaloneConfig, refs *standaloneCrossRefs) 
[]*adctypes.Service {
+       upstreamByID := make(map[string]*adctypes.Upstream, len(cfg.Upstreams))
+       for _, u := range cfg.Upstreams {
+               upstreamByID[u.ID] = u
+       }
+
+       serviceByID := make(map[string]*adctypes.Service, len(cfg.Services))
+       for i, svc := range cfg.Services {
+               if i < len(refs.Services) {
+                       svc.Upstream = upstreamByID[refs.Services[i].UpstreamID]
+               }
+               serviceByID[svc.ID] = svc
+       }
+
+       for i, route := range cfg.Routes {
+               if i >= len(refs.Routes) {
+                       continue
+               }
+               if svc := serviceByID[refs.Routes[i].ServiceID]; svc != nil {
+                       svc.Routes = append(svc.Routes, route)
+               }
+       }
+       for i, sr := range cfg.StreamRoutes {
+               if i >= len(refs.StreamRoutes) {
+                       continue
+               }
+               if svc := serviceByID[refs.StreamRoutes[i].ServiceID]; svc != 
nil {
+                       svc.StreamRoutes = append(svc.StreamRoutes, sr)
+               }
+       }
+
+       return cfg.Services
+}
+
+func (r *standaloneDataplaneResource) Route() RouteResource     { return 
standaloneRouteResource{r} }
+func (r *standaloneDataplaneResource) Service() ServiceResource { return 
standaloneServiceResource{r} }
+func (r *standaloneDataplaneResource) SSL() SSLResource         { return 
standaloneSSLResource{r} }
+func (r *standaloneDataplaneResource) Upstream() UpstreamResource {
+       return standaloneUpstreamResource{r}
+}
+func (r *standaloneDataplaneResource) Consumer() ConsumerResource {
+       return standaloneConsumerResource{r}
+}
+
+type standaloneRouteResource struct{ *standaloneDataplaneResource }
+
+func (r standaloneRouteResource) List(ctx context.Context) ([]*adctypes.Route, 
error) {
+       cfg, _, err := r.dump(ctx)
+       if err != nil {
+               return nil, err
+       }
+       return cfg.Routes, nil
+}
+
+type standaloneServiceResource struct{ *standaloneDataplaneResource }
+
+func (r standaloneServiceResource) List(ctx context.Context) 
([]*adctypes.Service, error) {
+       cfg, refs, err := r.dump(ctx)
+       if err != nil {
+               return nil, err
+       }
+       return withNesting(cfg, refs), nil
+}
+
+type standaloneUpstreamResource struct{ *standaloneDataplaneResource }
+
+func (r standaloneUpstreamResource) List(ctx context.Context) 
([]*adctypes.Upstream, error) {
+       cfg, _, err := r.dump(ctx)
+       if err != nil {
+               return nil, err
+       }
+       return cfg.Upstreams, nil
+}
+
+type standaloneSSLResource struct{ *standaloneDataplaneResource }
+
+func (r standaloneSSLResource) List(ctx context.Context) ([]*adctypes.SSL, 
error) {
+       cfg, _, err := r.dump(ctx)
+       if err != nil {
+               return nil, err
+       }
+       ssls := make([]*adctypes.SSL, 0, len(cfg.SSLs))
+       for _, s := range cfg.SSLs {
+               ssls = append(ssls, s.toADC())
+       }
+       return ssls, nil
+}
+
+type standaloneConsumerResource struct{ *standaloneDataplaneResource }
+
+func (r standaloneConsumerResource) List(ctx context.Context) 
([]*adctypes.Consumer, error) {
+       cfg, _, err := r.dump(ctx)
+       if err != nil {
+               return nil, err
+       }
+       return cfg.Consumers, nil
+}
diff --git a/test/e2e/scaffold/apisix_deployer.go 
b/test/e2e/scaffold/apisix_deployer.go
index 58f57074..070be9dc 100644
--- a/test/e2e/scaffold/apisix_deployer.go
+++ b/test/e2e/scaffold/apisix_deployer.go
@@ -484,6 +484,12 @@ func (s *APISIXDeployer) GetAdminServiceName() string {
        return s.dataplaneService.Name
 }
 func (s *APISIXDeployer) DefaultDataplaneResource() DataplaneResource {
+       // adc's plain CLI has no --backend value for apisix-standalone (see
+       // standaloneDataplaneResource), so it reads the Admin API directly 
instead of
+       // shelling out to `adc dump`.
+       if framework.ProviderType == framework.ProviderTypeAPISIXStandalone {
+               return newStandaloneDataplaneResource(s.AdminAPIClient(), 
s.AdminKey())
+       }
        return newADCDataplaneResource(
                framework.ProviderType,
                fmt.Sprintf("http://%s";, s.adminTunnel.Endpoint()),

Reply via email to