This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
The following commit(s) were added to refs/heads/master by this push:
new 6fc49d34 fix: rebuild the ADC baseline on leader acquisition (#2785)
6fc49d34 is described below
commit 6fc49d340cf399cdb3e2b300647b68c7da2bfaa4
Author: AlinsRan <[email protected]>
AuthorDate: Sat Jul 18 00:30:00 2026 +0800
fix: rebuild the ADC baseline on leader acquisition (#2785)
---
Makefile | 2 +-
api/adc/types.go | 5 +
config/manager/kustomization.yaml | 2 +-
docs/en/latest/developer-guide.md | 2 +-
internal/adc/client/client.go | 110 ++++++++++++++-
internal/adc/client/executor.go | 19 ++-
internal/adc/client/executor_test.go | 267 +++++++++++++++++++++++++++++++++++
internal/provider/apisix/provider.go | 6 +
test/e2e/apisix/conf_version.go | 215 ++++++++++++++++++++++++++++
test/e2e/scaffold/apisix_deployer.go | 12 +-
test/e2e/scaffold/deployer.go | 2 +
11 files changed, 633 insertions(+), 9 deletions(-)
diff --git a/Makefile b/Makefile
index 2926d6a7..b2668923 100644
--- a/Makefile
+++ b/Makefile
@@ -27,7 +27,7 @@ IMG ?= apache/apisix-ingress-controller:$(IMAGE_TAG)
ENVTEST_K8S_VERSION = 1.30.0
KIND_NAME ?= apisix-ingress-cluster
-ADC_VERSION ?= 0.23.1
+ADC_VERSION ?= 0.27.1
DIR := $(shell pwd)
diff --git a/api/adc/types.go b/api/adc/types.go
index 130d63d0..d4a04794 100644
--- a/api/adc/types.go
+++ b/api/adc/types.go
@@ -789,6 +789,11 @@ type Config struct {
Token string
TlsVerify bool
BackendType string
+
+ // BypassCache makes the ADC server drop the in-memory baseline it
holds for this
+ // cacheKey and re-derive it from the data plane before computing the
diff. It is a
+ // per-request flag set on the sync path, not part of the translated
configuration.
+ BypassCache bool
}
// MarshalJSON implements custom JSON marshaling for adcConfig
diff --git a/config/manager/kustomization.yaml
b/config/manager/kustomization.yaml
index 83a270e3..e8a9cbf9 100644
--- a/config/manager/kustomization.yaml
+++ b/config/manager/kustomization.yaml
@@ -8,4 +8,4 @@ images:
newTag: dev
- name: sidecar
newName: ghcr.io/api7/adc
- newTag: 0.23.1
+ newTag: 0.27.1
diff --git a/docs/en/latest/developer-guide.md
b/docs/en/latest/developer-guide.md
index ad3b3906..9aa6e105 100644
--- a/docs/en/latest/developer-guide.md
+++ b/docs/en/latest/developer-guide.md
@@ -36,7 +36,7 @@ Before you get started make sure you have:
1. Installed [Go 1.23](https://golang.org/dl/) or later
2. A Kubernetes cluster available. We recommend using
[kind](https://kind.sigs.k8s.io/).
3. Installed APISIX in Kubernetes using
[Helm](https://github.com/apache/apisix-helm-chart).
-4. Installed [ADC v0.20.0+](https://github.com/api7/adc/releases)
+4. Installed [ADC v0.27.0+](https://github.com/api7/adc/releases)
## Fork and clone
diff --git a/internal/adc/client/client.go b/internal/adc/client/client.go
index 203d9e14..9c0b8b73 100644
--- a/internal/adc/client/client.go
+++ b/internal/adc/client/client.go
@@ -48,6 +48,13 @@ type Client struct {
defaultMode string
+ // rebuiltMu guards rebuiltBaselines.
+ rebuiltMu sync.Mutex
+ // rebuiltBaselines holds the cacheKeys whose ADC baseline this
leadership term has
+ // already re-derived from the data plane. A key missing from it is
synced with
+ // bypassCache first. See InvalidateADCCache.
+ rebuiltBaselines map[string]struct{}
+
log logr.Logger
}
@@ -64,6 +71,7 @@ func New(log logr.Logger, defaultMode string, timeout
time.Duration) (*Client, e
return &Client{
Store: store,
+ rebuiltBaselines: make(map[string]struct{}),
executor: NewHTTPADCExecutor(log, serverURL, timeout),
ConfigManager: configManager,
ADCDebugProvider: common.NewADCDebugProvider(store,
configManager),
@@ -72,6 +80,54 @@ func New(log logr.Logger, defaultMode string, timeout
time.Duration) (*Client, e
}, nil
}
+// InvalidateADCCache forgets which ADC baselines are known to be current, so
that the
+// next sync of each cacheKey re-derives its baseline from the data plane.
+//
+// It is called on leader acquisition, which is the one moment a stale
baseline can enter
+// the picture. The ADC server is a sidecar that outlives the controller
process: losing
+// the lease terminates the manager container but not the sidecar, so what ADC
holds for a
+// cacheKey -- the last synced content plus the conf_version it generated --
can still be
+// the snapshot this pod left behind in an earlier term, while the leader in
between kept
+// pushing and moved the data plane's conf_version past it. APISIX standalone
requires
+// those versions to be monotonic and refuses the whole configuration
otherwise.
+func (c *Client) InvalidateADCCache() {
+ c.rebuiltMu.Lock()
+ defer c.rebuiltMu.Unlock()
+ clear(c.rebuiltBaselines)
+}
+
+func (c *Client) baselineIsCurrent(cacheKey string) bool {
+ c.rebuiltMu.Lock()
+ defer c.rebuiltMu.Unlock()
+ _, ok := c.rebuiltBaselines[cacheKey]
+ return ok
+}
+
+func (c *Client) markBaselineCurrent(cacheKey string) {
+ c.rebuiltMu.Lock()
+ defer c.rebuiltMu.Unlock()
+ c.rebuiltBaselines[cacheKey] = struct{}{}
+}
+
+// isConfVersionRejection reports whether the data plane refused the push
because of a
+// conf_version, which is the one rejection re-deriving the baseline can
answer.
+//
+// It matches the field name, not the sentence. conf_version is part of the
standalone
+// admin API -- we send those keys ourselves -- so any rejection that concerns
it names it,
+// whatever prose APISIX wraps it in. Matching the sentence would tie us to
prose APISIX is
+// free to reword; matching the field only breaks if it renames the API.
+//
+// This backs the safety net, not the fix. A baseline is rebuilt on leader
acquisition,
+// which is where staleness comes from, so if this ever stopped firing the
reported bug
+// would not come back with it.
+func isConfVersionRejection(err error) bool {
+ return err != nil && strings.Contains(err.Error(), confVersionField)
+}
+
+// confVersionField names the monotonic version APISIX standalone keeps per
resource type
+// (routes_conf_version, upstreams_conf_version, ...) and refuses a push that
moves back.
+const confVersionField = "conf_version"
+
type Task struct {
Key types.NamespacedNameKind
Name string
@@ -262,6 +318,56 @@ func (c *Client) Sync(ctx context.Context)
(map[string]types.ADCExecutionErrors,
return failedMap, err
}
+// push syncs one config through the ADC server, re-deriving the baseline ADC
diffs against
+// whenever that baseline cannot be trusted. Beside the error to report it
returns the ones
+// to report next to it, which a rebuild that failed leaves behind.
+//
+// The ADC sidecar outlives the controller process, so the baseline it holds
for a cacheKey
+// may be one an earlier leadership term left behind. It is re-derived from
the data plane
+// the first time this term syncs the key, before anything can be pushed from
it, and only
+// a sync ADC accepts settles the question.
+//
+// Rebuilding on leader acquisition covers where staleness comes from. The
safety net covers
+// what it cannot foresee -- another writer on this data plane, a desync no
leadership change
+// explains -- and a conf_version the data plane refuses is the only way any
of that shows
+// itself. Re-read the data plane and push again.
+func (c *Client) push(ctx context.Context, config adctypes.Config, args
[]string) ([]types.ADCExecutionError, error) {
+ standalone := config.BackendType == backendAPISIXStandalone
+ config.BypassCache = standalone && !c.baselineIsCurrent(config.Name)
+
+ err := c.executor.Execute(ctx, config, args)
+
+ var alsoReport []types.ADCExecutionError
+ if standalone && !config.BypassCache && isConfVersionRejection(err) {
+ c.log.Info("data plane rejected a stale conf_version,
rebuilding the ADC baseline",
+ "config", config.Name, "error", err.Error())
+ // Keep the rejection visible even when the sync recovers. The
rebuild is not rate
+ // limited, so a rejection on every sync -- someone else
writing to this data plane --
+ // turns every sync into a full fetch and diff, and this
counter is what says so.
+ pkgmetrics.RecordExecutionError(config.Name,
"conf_version_conflict")
+
+ config.BypassCache = true
+ retryErr := c.executor.Execute(ctx, config, args)
+
+ // Report the rejection as well. On its own a failed rebuild
says nothing about what it
+ // was rebuilding for, and it is the rejection that names the
cause -- an ADC server too
+ // old to know bypassCache, say, answers with a schema error
that points nowhere near
+ // it. Unless the rebuild was rejected the same way, in which
case saying it twice only
+ // pads the status message.
+ var rejected types.ADCExecutionError
+ if retryErr != nil && retryErr.Error() != err.Error() &&
errors.As(err, &rejected) {
+ alsoReport = append(alsoReport, rejected)
+ }
+ err = retryErr
+ }
+
+ // Only a sync ADC accepted proves its baseline is now derived from the
data plane.
+ if err == nil && config.BypassCache {
+ c.markBaselineCurrent(config.Name)
+ }
+ return alsoReport, err
+}
+
func (c *Client) sync(ctx context.Context, task Task) error {
c.log.V(1).Info("syncing resources", "task", task)
@@ -297,7 +403,9 @@ func (c *Client) sync(ctx context.Context, task Task) error
{
config.BackendType = c.defaultMode
}
- err := c.executor.Execute(ctx, config, args)
+ alsoReport, err := c.push(ctx, config, args)
+ errs.Errors = append(errs.Errors, alsoReport...)
+
duration := time.Since(startTime).Seconds()
status := adctypes.StatusSuccess
diff --git a/internal/adc/client/executor.go b/internal/adc/client/executor.go
index 5664b4f2..c9add15b 100644
--- a/internal/adc/client/executor.go
+++ b/internal/adc/client/executor.go
@@ -39,6 +39,11 @@ import (
const (
defaultHTTPADCExecutorAddr = "http://127.0.0.1:3000"
+
+ pathSync = "/sync"
+ pathValidate = "/validate"
+
+ backendAPISIXStandalone = "apisix-standalone"
)
type ADCExecutor interface {
@@ -80,6 +85,11 @@ type ADCServerOpts struct {
IncludeResourceType []string
`json:"includeResourceType,omitempty"`
TlsSkipVerify *bool `json:"tlsSkipVerify,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
+ // what an older ADC server already accepts.
+ BypassCache bool `json:"bypassCache,omitempty"`
}
type ADCValidateResult struct {
@@ -141,7 +151,7 @@ func (e *HTTPADCExecutor) runHTTPSync(ctx context.Context,
config adctypes.Confi
}
serverAddrs := func() []string {
- if config.BackendType == "apisix-standalone" {
+ if config.BackendType == backendAPISIXStandalone {
return []string{strings.Join(config.ServerAddrs, ",")}
}
return config.ServerAddrs
@@ -218,7 +228,7 @@ func (e *HTTPADCExecutor) runHTTPSyncForSingleServer(ctx
context.Context, server
}
// Build HTTP request
- req, err := e.buildHTTPRequest(ctx, serverAddr, config, labels, types,
resources, http.MethodPut, "/sync")
+ req, err := e.buildHTTPRequest(ctx, serverAddr, config, labels, types,
resources, http.MethodPut, pathSync)
if err != nil {
return fmt.Errorf("failed to build HTTP request: %w", err)
}
@@ -252,7 +262,7 @@ func (e *HTTPADCExecutor)
runHTTPValidateForSingleServer(ctx context.Context, se
return fmt.Errorf("failed to load resources from file %s: %w",
filePath, err)
}
- req, err := e.buildHTTPRequest(ctx, serverAddr, config, labels, types,
resources, http.MethodPut, "/validate")
+ req, err := e.buildHTTPRequest(ctx, serverAddr, config, labels, types,
resources, http.MethodPut, pathValidate)
if err != nil {
return fmt.Errorf("failed to build validate request: %w", err)
}
@@ -326,6 +336,7 @@ func (e *HTTPADCExecutor) loadResourcesFromFile(filePath
string) (*adctypes.Reso
func (e *HTTPADCExecutor) buildHTTPRequest(ctx context.Context, serverAddr
string, config adctypes.Config, labels map[string]string, types []string,
resources *adctypes.Resources, method string, path string) (*http.Request,
error) {
// Prepare request body
tlsVerify := config.TlsVerify
+ bypassCache := path == pathSync && config.BypassCache
reqBody := ADCServerRequest{
Task: ADCServerTask{
Opts: ADCServerOpts{
@@ -336,6 +347,7 @@ func (e *HTTPADCExecutor) buildHTTPRequest(ctx
context.Context, serverAddr strin
IncludeResourceType: types,
TlsSkipVerify: ptr.To(!tlsVerify),
CacheKey: config.Name,
+ BypassCache: bypassCache,
},
Config: *resources,
},
@@ -353,6 +365,7 @@ func (e *HTTPADCExecutor) buildHTTPRequest(ctx
context.Context, serverAddr strin
"server", serverAddr,
"mode", config.BackendType,
"cacheKey", config.Name,
+ "bypassCache", bypassCache,
"labelSelector", labels,
"includeResourceType", types,
"tlsSkipVerify", !tlsVerify,
diff --git a/internal/adc/client/executor_test.go
b/internal/adc/client/executor_test.go
new file mode 100644
index 00000000..9e7ee71c
--- /dev/null
+++ b/internal/adc/client/executor_test.go
@@ -0,0 +1,267 @@
+// 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 client
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+ "github.com/apache/apisix-ingress-controller/internal/types"
+)
+
+func TestHTTPADCExecutorBuildHTTPRequestBypassCache(t *testing.T) {
+ e := &HTTPADCExecutor{
+ serverURL: "http://127.0.0.1:3000",
+ log: logr.Discard(),
+ }
+
+ build := func(config adctypes.Config, path string) (ADCServerOpts,
string) {
+ req, err := e.buildHTTPRequest(context.Background(),
"http://apisix:9180", config, nil, nil,
+ &adctypes.Resources{}, http.MethodPut, path)
+ require.NoError(t, err)
+ body, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+ var parsed ADCServerRequest
+ require.NoError(t, json.Unmarshal(body, &parsed))
+ return parsed.Task.Opts, string(body)
+ }
+
+ 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.
+ opts, raw := build(config, pathSync)
+ assert.Equal(t, "GatewayProxy/ns/name", opts.CacheKey)
+ assert.NotContains(t, raw, "bypassCache")
+
+ config.BypassCache = true
+ opts, raw = build(config, pathSync)
+ assert.Equal(t, "GatewayProxy/ns/name", opts.CacheKey, "the cacheKey
itself must stay stable")
+ assert.True(t, opts.BypassCache)
+ assert.Contains(t, raw, "bypassCache")
+
+ // The ADC validate task schema rejects unknown fields too, so
bypassCache must never
+ // reach it, even when the config carries the flag.
+ _, raw = build(config, pathValidate)
+ assert.NotContains(t, raw, "bypassCache")
+}
+
+// confVersionError is what a push carrying a conf_version older than the data
plane's
+// comes back as, once the ADC server has relayed the rejection to us.
+func confVersionError() error {
+ return rejection("upstreams_conf_version must be greater than or equal
to (1779434128737)")
+}
+
+func rejection(reason string) error {
+ return types.ADCExecutionError{
+ Name: "GatewayProxy/ns/name",
+ FailedErrors: []types.ADCExecutionServerAddrError{{
+ ServerAddr: "http://apisix:9180",
+ Err: reason,
+ }},
+ }
+}
+
+// fakeExecutor answers each Execute call with the next error in errs, and
records the
+// BypassCache flag it was called with.
+type fakeExecutor struct {
+ errs []error
+ bypassSeq []bool
+}
+
+func (f *fakeExecutor) Execute(_ context.Context, config adctypes.Config, _
[]string) error {
+ f.bypassSeq = append(f.bypassSeq, config.BypassCache)
+ if len(f.errs) == 0 {
+ return nil
+ }
+ err := f.errs[0]
+ f.errs = f.errs[1:]
+ return err
+}
+
+func (f *fakeExecutor) Validate(context.Context, adctypes.Config, []string)
error { return nil }
+
+// newTestClient starts out as a controller that has just been elected: no ADC
baseline is
+// known to be current, so the first sync of a cacheKey rebuilds it.
+func newTestClient(exec ADCExecutor) *Client {
+ return &Client{
+ executor: exec,
+ rebuiltBaselines: make(map[string]struct{}),
+ log: logr.Discard(),
+ }
+}
+
+// afterFirstSync is the state a controller settles into once the first sync
of its term
+// has landed: the ADC baseline for this cacheKey is known to be derived from
the data
+// plane, so nothing rebuilds it again unless the data plane says otherwise.
+func afterFirstSync(exec ADCExecutor) *Client {
+ c := newTestClient(exec)
+ c.markBaselineCurrent(syncTaskCacheKey)
+ return c
+}
+
+const syncTaskCacheKey = "GatewayProxy/ns/name"
+
+func newSyncTask() Task {
+ return Task{
+ Name: "GatewayProxy/ns/name-sync",
+ Configs: map[types.NamespacedNameKind]adctypes.Config{
+ {}: {Name: "GatewayProxy/ns/name", BackendType:
"apisix-standalone"},
+ },
+ Resources: &adctypes.Resources{},
+ }
+}
+
+func TestClientSyncRebuildsOnceAfterElectionThenReusesTheADCCache(t
*testing.T) {
+ exec := &fakeExecutor{}
+ c := newTestClient(exec)
+
+ // The sidecar may still hold a baseline from an earlier term, so the
first sync of a
+ // cacheKey re-derives it from the data plane. Once ADC has accepted
that sync, its
+ // baseline is current and later syncs diff against it.
+ require.NoError(t, c.sync(context.Background(), newSyncTask()))
+ require.NoError(t, c.sync(context.Background(), newSyncTask()))
+ assert.Equal(t, []bool{true, false}, exec.bypassSeq)
+
+ // Winning the election again puts every baseline back in doubt.
+ c.InvalidateADCCache()
+ require.NoError(t, c.sync(context.Background(), newSyncTask()))
+ assert.Equal(t, []bool{true, false, true}, exec.bypassSeq)
+}
+
+func TestClientSyncRebuildsAgainWhenTheRebuildWasNotAccepted(t *testing.T) {
+ // Nothing proves the baseline is current except ADC accepting the sync
that rebuilt it.
+ exec := &fakeExecutor{errs: []error{types.ADCExecutionError{
+ Name: "GatewayProxy/ns/name",
+ FailedErrors: []types.ADCExecutionServerAddrError{{Err:
"connection refused"}},
+ }}}
+ c := newTestClient(exec)
+
+ require.Error(t, c.sync(context.Background(), newSyncTask()))
+ require.NoError(t, c.sync(context.Background(), newSyncTask()))
+
+ assert.Equal(t, []bool{true, true}, exec.bypassSeq)
+}
+
+func TestClientSyncRebuildsADCBaselineWhenTheDataPlaneRejectsThePush(t
*testing.T) {
+ exec := &fakeExecutor{errs: []error{confVersionError()}}
+ c := afterFirstSync(exec)
+
+ // The data plane holds a conf_version newer than the one the ADC
baseline carries, so
+ // the push is rejected. The retry rebuilds that baseline from the data
plane.
+ task := newSyncTask()
+ require.NoError(t, c.sync(context.Background(), task))
+
+ assert.Equal(t, []bool{false, true}, exec.bypassSeq)
+
+ // BypassCache is scoped to the request that recovers from the
rejection. Were it to
+ // survive in the task, it would reach the config the ConfigManager
holds and turn a
+ // one-off rebuild into a data plane fetch on every later sync.
+ assert.False(t, task.Configs[types.NamespacedNameKind{}].BypassCache,
+ "the rebuild must not write BypassCache back into the task
config")
+}
+
+func TestClientSyncDoesNotRebuildOnUnrelatedFailures(t *testing.T) {
+ // Re-deriving the baseline answers a stale conf_version and nothing
else. A data plane
+ // that cannot be reached, or one that refuses the configuration on its
merits, is not a
+ // question the baseline can answer, and a rebuild would only cost a
fetch.
+ for name, err := range map[string]error{
+ "unreachable": rejection("connection refused"),
+ "invalid plugins": rejection(`failed to check the configuration
of plugin limit-count: value should match only one schema`),
+ } {
+ t.Run(name, func(t *testing.T) {
+ exec := &fakeExecutor{errs: []error{err}}
+ c := afterFirstSync(exec)
+
+ require.Error(t, c.sync(context.Background(),
newSyncTask()))
+
+ assert.Equal(t, []bool{false}, exec.bypassSeq)
+ })
+ }
+}
+
+func TestClientSyncRebuildsHoweverTheRejectionIsWorded(t *testing.T) {
+ // The rejection is recognised by the field it names, not by the
sentence around it:
+ // conf_version is part of the standalone admin API, the wording is
APISIX's to change.
+ exec := &fakeExecutor{errs: []error{rejection("upstreams_conf_version
has moved backwards")}}
+ c := afterFirstSync(exec)
+
+ require.NoError(t, c.sync(context.Background(), newSyncTask()))
+
+ assert.Equal(t, []bool{false, true}, exec.bypassSeq)
+}
+
+func TestClientSyncDoesNotRebuildOutsideStandalone(t *testing.T) {
+ // conf_version, and the whole notion of a version the data plane can
refuse, only
+ // exists in standalone mode.
+ exec := &fakeExecutor{errs: []error{confVersionError()}}
+ c := afterFirstSync(exec)
+
+ task := newSyncTask()
+ task.Configs[types.NamespacedNameKind{}] = adctypes.Config{Name:
"GatewayProxy/ns/name", BackendType: "apisix"}
+ require.Error(t, c.sync(context.Background(), task))
+
+ assert.Equal(t, []bool{false}, exec.bypassSeq)
+}
+
+func TestClientSyncSurfacesErrorWhenRebuildFails(t *testing.T) {
+ // An ADC server older than 0.27.0 answers the rebuild with a schema
error, which on
+ // its own points nowhere near the cause.
+ exec := &fakeExecutor{errs: []error{confVersionError(),
rejection(`unrecognized key "bypassCache"`)}}
+ c := afterFirstSync(exec)
+
+ err := c.sync(context.Background(), newSyncTask())
+
+ require.Error(t, err, "a rebuild that still fails must not be
swallowed")
+ assert.Equal(t, []bool{false, true}, exec.bypassSeq, "the rebuild is
attempted once, not in a loop")
+ assert.Contains(t, err.Error(), "conf_version must be greater than or
equal to",
+ "the rejection that triggered the rebuild must stay in the
reported error")
+ assert.Contains(t, err.Error(), `unrecognized key "bypassCache"`,
+ "so must the reason the rebuild itself failed")
+}
+
+func TestClientSyncDoesNotReportTheSameRejectionTwice(t *testing.T) {
+ // Someone else keeps writing to this data plane, so the rebuilt
baseline is stale again
+ // by the time it is pushed. Reporting that one rejection twice only
pads the status.
+ exec := &fakeExecutor{errs: []error{confVersionError(),
confVersionError()}}
+ c := afterFirstSync(exec)
+
+ err := c.sync(context.Background(), newSyncTask())
+
+ var execErrs types.ADCExecutionErrors
+ require.ErrorAs(t, err, &execErrs)
+ assert.Len(t, execErrs.Errors, 1)
+}
+
+func TestIsConfVersionRejection(t *testing.T) {
+ assert.False(t, isConfVersionRejection(nil))
+ assert.False(t, isConfVersionRejection(errors.New("context deadline
exceeded")))
+ assert.False(t, isConfVersionRejection(rejection("connection refused")))
+ assert.True(t, isConfVersionRejection(confVersionError()))
+ assert.True(t, isConfVersionRejection(rejection("routes_conf_version
has moved backwards")),
+ "the field is what names the rejection, not the sentence")
+}
diff --git a/internal/provider/apisix/provider.go
b/internal/provider/apisix/provider.go
index ef6e3fc8..c34f3c70 100644
--- a/internal/provider/apisix/provider.go
+++ b/internal/provider/apisix/provider.go
@@ -251,6 +251,12 @@ func (d *apisixProvider) buildConfig(tctx
*provider.TranslateContext, nnk types.
}
func (d *apisixProvider) Start(ctx context.Context) error {
+ // Start only runs once this pod has won the election, and a leadership
change is the
+ // one thing that leaves the ADC sidecar holding a baseline from an
earlier term: it
+ // survives the manager container, the configuration it was derived
from does not.
+ // Rebuild every baseline from the data plane before syncing from it.
+ d.client.InvalidateADCCache()
+
d.log.Info("starting provider, waiting for readiness")
d.readier.WaitReady(ctx, 5*time.Minute)
d.log.Info("Ready detected, starting sync loop")
diff --git a/test/e2e/apisix/conf_version.go b/test/e2e/apisix/conf_version.go
new file mode 100644
index 00000000..c28a3148
--- /dev/null
+++ b/test/e2e/apisix/conf_version.go
@@ -0,0 +1,215 @@
+// 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 (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gavv/httpexpect/v2"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/apache/apisix-ingress-controller/test/e2e/framework"
+ "github.com/apache/apisix-ingress-controller/test/e2e/scaffold"
+)
+
+var _ = Describe("Test ADC cache alignment", Label("networking.k8s.io",
"ingress"), func() {
+ // conf_version only exists in APISIX standalone mode. Bail out while
the tree is
+ // built rather than skipping in a BeforeEach: the scaffold registers
its own
+ // BeforeEach first, so a skip would already have paid for a data plane.
+ if framework.ProviderType != framework.ProviderTypeAPISIXStandalone {
+ return
+ }
+
+ s := scaffold.NewDefaultScaffold()
+
+ const gatewayProxyYaml = `
+apiVersion: apisix.apache.org/v1alpha1
+kind: GatewayProxy
+metadata:
+ name: apisix-proxy-config
+spec:
+ provider:
+ type: ControlPlane
+ controlPlane:
+ endpoints:
+ - %s
+ auth:
+ type: AdminKey
+ adminKey:
+ value: "%s"
+`
+
+ const ingressClassYaml = `
+apiVersion: networking.k8s.io/v1
+kind: IngressClass
+metadata:
+ name: %s
+spec:
+ controller: "%s"
+ parameters:
+ apiGroup: "apisix.apache.org"
+ kind: "GatewayProxy"
+ name: "apisix-proxy-config"
+ namespace: "%s"
+ scope: "Namespace"
+`
+
+ const ingressYaml = `
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: httpbin
+spec:
+ ingressClassName: %s
+ rules:
+ - host: conf-version.example
+ http:
+ paths:
+ - path: %s
+ pathType: Exact
+ backend:
+ service:
+ name: httpbin-service-e2e-test
+ port:
+ number: 80
+`
+
+ // confVersions returns every *_conf_version the data plane currently
holds, read
+ // straight from its Admin API rather than through the controller.
+ confVersions := func(admin *httpexpect.Expect) map[string]int64 {
+ body := admin.GET("/apisix/admin/configs").
+ WithHeader("X-API-KEY", s.AdminKey()).
+ Expect().Status(http.StatusOK).Body().Raw()
+
+ var config map[string]any
+ Expect(json.Unmarshal([]byte(body),
&config)).NotTo(HaveOccurred(), "decoding standalone config")
+
+ versions := map[string]int64{}
+ for key, value := range config {
+ if number, ok := value.(float64); ok &&
strings.HasSuffix(key, "_conf_version") {
+ versions[key] = int64(number)
+ }
+ }
+ return versions
+ }
+
+ // bumpConfVersions rewrites every *_conf_version the data plane holds
to a timestamp
+ // an hour ahead and pushes the configuration back, without going
through the
+ // controller. This is what the leader in between would have left
behind: it pushed
+ // while this pod was on standby, so APISIX now holds versions newer
than the ones the
+ // ADC sidecar generated during this pod's previous term -- and the
sidecar survives a
+ // leadership change, so it still diffs against that older baseline.
+ bumpConfVersions := func(admin *httpexpect.Expect) int64 {
+ body := admin.GET("/apisix/admin/configs").
+ WithHeader("X-API-KEY", s.AdminKey()).
+ Expect().Status(http.StatusOK).Body().Raw()
+
+ var config map[string]any
+ Expect(json.Unmarshal([]byte(body),
&config)).NotTo(HaveOccurred(), "decoding standalone config")
+
+ future := time.Now().Add(time.Hour).UnixMilli()
+ bumped := 0
+ for key := range config {
+ // APISIX echoes its own metadata back, but rejects it
on write.
+ if strings.HasPrefix(strings.ToUpper(key), "X-") {
+ delete(config, key)
+ continue
+ }
+ if strings.HasSuffix(key, "_conf_version") {
+ config[key] = future
+ bumped++
+ }
+ }
+ Expect(bumped).NotTo(BeZero(), "the data plane should already
hold conf_versions")
+
+ admin.PUT("/apisix/admin/configs").
+ WithHeader("X-API-KEY", s.AdminKey()).
+ // APISIX requires a digest and compares it with the
stored one to skip an
+ // update that repeats it; it never recomputes it from
the body.
+ WithHeader("X-Digest",
fmt.Sprintf("e2e-conf-version-bump-%d", future)).
+ WithJSON(config).
+ Expect().Status(http.StatusAccepted)
+
+ return future
+ }
+
+ It("recovers when the data plane holds newer conf_versions than the ADC
cache", func() {
+ By("create GatewayProxy, IngressClass and Ingress")
+ Expect(s.CreateResourceFromString(fmt.Sprintf(gatewayProxyYaml,
s.Deployer.GetAdminEndpoint(), s.AdminKey()))).
+ NotTo(HaveOccurred(), "creating GatewayProxy")
+ Expect(s.CreateResourceFromStringWithNamespace(
+ fmt.Sprintf(ingressClassYaml, s.Namespace(),
s.GetControllerName(), s.Namespace()), "")).
+ NotTo(HaveOccurred(), "creating IngressClass")
+ Expect(s.CreateResourceFromString(fmt.Sprintf(ingressYaml,
s.Namespace(), "/get"))).
+ NotTo(HaveOccurred(), "creating Ingress")
+
+ s.RequestAssert(&scaffold.RequestAssert{
+ Method: "GET",
+ Path: "/get",
+ Host: "conf-version.example",
+ Check: scaffold.WithExpectedStatus(http.StatusOK),
+ })
+
+ admin := s.Deployer.AdminAPIClient()
+
+ By("push newer conf_versions straight into the data plane")
+ future := bumpConfVersions(admin)
+
+ By("update the Ingress and expect the change to reach the data
plane")
+ // The ADC sidecar still diffs against its own baseline, whose
conf_versions are
+ // now older than the ones the data plane holds, and APISIX
rejects the whole
+ // configuration with "<resource>_conf_version must be greater
than or equal to".
+ // The controller has to rebuild that baseline from the data
plane before anything
+ // can land again.
+ Expect(s.CreateResourceFromString(fmt.Sprintf(ingressYaml,
s.Namespace(), "/headers"))).
+ NotTo(HaveOccurred(), "updating Ingress")
+
+ s.RequestAssert(&scaffold.RequestAssert{
+ Method: "GET",
+ Path: "/headers",
+ Host: "conf-version.example",
+ Check: scaffold.WithExpectedStatus(http.StatusOK),
+ })
+ s.RequestAssert(&scaffold.RequestAssert{
+ Method: "GET",
+ Path: "/get",
+ Host: "conf-version.example",
+ Check:
scaffold.WithExpectedStatus(http.StatusNotFound),
+ })
+
+ By("the versions the data plane holds moved past the injected
ones")
+ s.RetryAssertion(func() error {
+ versions := confVersions(admin)
+ if len(versions) == 0 {
+ return errors.New("the data plane reported no
conf_version at all")
+ }
+ for key, version := range versions {
+ if version < future {
+ return fmt.Errorf("%s is %d, expected
it to be at least %d", key, version, future)
+ }
+ }
+ return nil
+ }).ShouldNot(HaveOccurred(), "checking conf_versions")
+ })
+})
diff --git a/test/e2e/scaffold/apisix_deployer.go
b/test/e2e/scaffold/apisix_deployer.go
index 7c4b1954..19f30f36 100644
--- a/test/e2e/scaffold/apisix_deployer.go
+++ b/test/e2e/scaffold/apisix_deployer.go
@@ -23,6 +23,7 @@ import (
"os"
"time"
+ "github.com/gavv/httpexpect/v2"
"github.com/gruntwork-io/terratest/modules/k8s"
. "github.com/onsi/ginkgo/v2" //nolint:staticcheck
. "github.com/onsi/gomega" //nolint:staticcheck
@@ -146,9 +147,8 @@ func (s *APISIXDeployer) AfterEach() {
_, _ = fmt.Fprintln(GinkgoWriter, output)
}
if framework.ProviderType ==
framework.ProviderTypeAPISIXStandalone && s.adminTunnel != nil {
- client := NewClient("http", s.adminTunnel.Endpoint())
reporter := &ErrorReporter{}
- body :=
client.GET("/apisix/admin/configs").WithHeader("X-API-KEY",
s.AdminKey()).WithReporter(reporter).Expect().Body().Raw()
+ body :=
s.AdminAPIClient().GET("/apisix/admin/configs").WithHeader("X-API-KEY",
s.AdminKey()).WithReporter(reporter).Expect().Body().Raw()
_, _ = fmt.Fprintln(GinkgoWriter, "Dumping APISIX
configs:")
_, _ = fmt.Fprintln(GinkgoWriter, body)
}
@@ -359,6 +359,14 @@ func (s *APISIXDeployer) createAdminTunnel(svc
*corev1.Service) (*k8s.Tunnel, er
return adminTunnel, nil
}
+// AdminAPIClient returns a client for the data plane Admin API, reachable
through the
+// tunnel the scaffold forwards to it. In standalone mode it is the only way
to read or
+// write the configuration APISIX actually holds, bypassing the controller.
+func (s *APISIXDeployer) AdminAPIClient() *httpexpect.Expect {
+ Expect(s.adminTunnel).NotTo(BeNil(), "admin tunnel should be created")
+ return NewClient("http", s.adminTunnel.Endpoint())
+}
+
func (s *APISIXDeployer) closeAdminTunnel() {
if s.adminTunnel != nil {
s.adminTunnel.Close()
diff --git a/test/e2e/scaffold/deployer.go b/test/e2e/scaffold/deployer.go
index a1b2e9b0..77557828 100644
--- a/test/e2e/scaffold/deployer.go
+++ b/test/e2e/scaffold/deployer.go
@@ -18,6 +18,7 @@
package scaffold
import (
+ "github.com/gavv/httpexpect/v2"
corev1 "k8s.io/api/core/v1"
)
@@ -34,6 +35,7 @@ type Deployer interface {
CleanupAdditionalGateway(identifier string) error
GetAdminEndpoint(...*corev1.Service) string
GetAdminServiceName() string
+ AdminAPIClient() *httpexpect.Expect
DefaultDataplaneResource() DataplaneResource
}