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()),