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 80112024 refactor: let adc client independent of external state (#2865)
80112024 is described below
commit 80112024e0df674d900a1cb1da831f405179729a
Author: Zeping Bai <[email protected]>
AuthorDate: Thu Sep 10 14:37:39 2026 +0800
refactor: let adc client independent of external state (#2865)
---
internal/adc/client/client.go | 308 ++++++++--------------------
internal/adc/client/executor.go | 126 ++----------
internal/adc/client/executor_test.go | 50 ++---
internal/adc/client/redaction_test.go | 1 -
internal/provider/apisix/keyedmutex.go | 52 +++++
internal/provider/apisix/keyedmutex_test.go | 94 +++++++++
internal/provider/apisix/provider.go | 244 ++++++++++++++++++----
internal/provider/apisix/provider_test.go | 128 +++++++++++-
internal/provider/apisix/status.go | 14 +-
internal/webhook/v1/adc_validation.go | 1 -
pkg/metrics/metrics.go | 16 --
test/e2e/crds/v2/route.go | 1 -
12 files changed, 596 insertions(+), 439 deletions(-)
diff --git a/internal/adc/client/client.go b/internal/adc/client/client.go
index e80d1b27..0e9e4249 100644
--- a/internal/adc/client/client.go
+++ b/internal/adc/client/client.go
@@ -15,11 +15,15 @@
// specific language governing permissions and limitations
// under the License.
+// Package client talks to the ADC server: given a fully-prepared sync or
validate
+// request, it translates it to ADC's wire format, sends it, and interprets
the response.
+// It holds no bookkeeping of its own about which Kubernetes resource maps to
which
+// GatewayProxy, or what a GatewayProxy's current resource snapshot is -- that
is AIC's own
+// state, owned by the caller and handed in as input on every call.
package client
import (
"context"
- "encoding/json"
"fmt"
"os"
"strings"
@@ -30,22 +34,13 @@ import (
"github.com/pkg/errors"
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
- "github.com/apache/apisix-ingress-controller/internal/adc/cache"
- "github.com/apache/apisix-ingress-controller/internal/provider/common"
"github.com/apache/apisix-ingress-controller/internal/types"
pkgmetrics "github.com/apache/apisix-ingress-controller/pkg/metrics"
)
type Client struct {
- syncMu sync.RWMutex
- mu sync.Mutex
- *cache.Store
-
executor ADCExecutor
- ConfigManager *common.ConfigManager[types.NamespacedNameKind,
adctypes.Config]
- ADCDebugProvider *common.ADCDebugProvider
-
defaultMode string
// rebuiltMu guards rebuiltBaselines.
@@ -63,18 +58,13 @@ func New(log logr.Logger, defaultMode string, timeout
time.Duration) (*Client, e
if serverURL == "" {
serverURL = defaultHTTPADCExecutorAddr
}
- store := cache.NewStore(log)
- configManager := common.NewConfigManager[types.NamespacedNameKind,
adctypes.Config]()
logger := log.WithName("client")
logger.Info("ADC client initialized")
return &Client{
- Store: store,
rebuiltBaselines: make(map[string]struct{}),
executor: NewHTTPADCExecutor(log, serverURL, timeout),
- ConfigManager: configManager,
- ADCDebugProvider: common.NewADCDebugProvider(store,
configManager),
log: logger,
defaultMode: defaultMode,
}, nil
@@ -128,8 +118,9 @@ func isConfVersionRejection(err error) bool {
// (routes_conf_version, upstreams_conf_version, ...) and refuses a push that
moves back.
const confVersionField = "conf_version"
+// Task is a /validate request: one Kubernetes resource's translated result,
checked
+// against every GatewayProxy config it could target.
type Task struct {
- Key types.NamespacedNameKind
Name string
Labels map[string]string
Configs map[types.NamespacedNameKind]adctypes.Config
@@ -146,7 +137,6 @@ func (t Task) MarshalLog() any {
configNames = append(configNames, cfg.Name)
}
return map[string]any{
- "key": t.Key,
"name": t.Name,
"labels": t.Labels,
"resourceTypes": t.ResourceTypes,
@@ -155,119 +145,17 @@ func (t Task) MarshalLog() any {
}
}
-type StoreDelta struct {
- Deleted map[types.NamespacedNameKind]adctypes.Config
- Applied map[types.NamespacedNameKind]adctypes.Config
-}
-
-func (c *Client) applyStoreChanges(args Task, isDelete bool) (StoreDelta,
error) {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- var delta StoreDelta
-
- if isDelete {
- delta.Deleted = c.ConfigManager.Get(args.Key)
- c.ConfigManager.Delete(args.Key)
- } else {
- deleted := c.ConfigManager.Update(args.Key, args.Configs)
- delta.Deleted = deleted
- delta.Applied = args.Configs
- }
-
- for _, cfg := range delta.Deleted {
- if err := c.Store.Delete(cfg.Name, args.ResourceTypes,
args.Labels); err != nil {
- c.log.Error(err, "store delete failed", "cfg", cfg,
"args", args)
- return StoreDelta{}, errors.Wrap(err,
fmt.Sprintf("store delete failed for config %s", cfg.Name))
- }
- }
-
- for _, cfg := range delta.Applied {
- if err := c.Insert(cfg.Name, args.ResourceTypes,
args.Resources, args.Labels); err != nil {
- c.log.Error(err, "store insert failed", "cfg", cfg,
"args", args)
- return StoreDelta{}, errors.Wrap(err,
fmt.Sprintf("store insert failed for config %s", cfg.Name))
- }
- }
-
- return delta, nil
-}
-
-func (c *Client) applySync(ctx context.Context, args Task, delta StoreDelta)
error {
- c.syncMu.RLock()
- defer c.syncMu.RUnlock()
-
- if len(delta.Deleted) > 0 {
- if err := c.sync(ctx, Task{
- Name: args.Name,
- Labels: args.Labels,
- ResourceTypes: args.ResourceTypes,
- Configs: delta.Deleted,
- }); err != nil {
- c.log.Error(err, "failed to sync deleted configs",
"args", args, "delta", delta)
- }
- }
-
- if len(delta.Applied) > 0 {
- return c.sync(ctx, Task{
- Name: args.Name,
- Labels: args.Labels,
- ResourceTypes: args.ResourceTypes,
- Configs: delta.Applied,
- Resources: args.Resources,
- })
- }
- return nil
-}
-
-func (c *Client) Update(ctx context.Context, args Task) error {
- delta, err := c.applyStoreChanges(args, false)
- if err != nil {
- return err
- }
- return c.applySync(ctx, args, delta)
-}
-
-func (c *Client) UpdateConfig(ctx context.Context, args Task) error {
- _, err := c.applyStoreChanges(args, false)
- return err
-}
-
-func (c *Client) Delete(ctx context.Context, args Task) error {
- delta, err := c.applyStoreChanges(args, true)
- if err != nil {
- return err
- }
- return c.applySync(ctx, args, delta)
-}
-
-// DeleteConfig removes the stored configuration for args.Key and reports what
-// it removed, so callers can skip a data plane sync when the key held nothing.
-func (c *Client) DeleteConfig(ctx context.Context, args Task) (StoreDelta,
error) {
- return c.applyStoreChanges(args, true)
-}
-
func (c *Client) Validate(ctx context.Context, task Task) error {
if len(task.Configs) == 0 || task.Resources == nil {
return nil
}
- fileIOStart := time.Now()
- syncFilePath, cleanup, err := prepareSyncFile(task.Resources)
- if err != nil {
- pkgmetrics.RecordFileIODuration("prepare_sync_file", "failure",
time.Since(fileIOStart).Seconds())
- return err
- }
- pkgmetrics.RecordFileIODuration("prepare_sync_file",
adctypes.StatusSuccess, time.Since(fileIOStart).Seconds())
- defer cleanup()
-
- args2 := BuildADCExecuteArgs(syncFilePath, task.Labels,
task.ResourceTypes)
-
var errs types.ADCValidationErrors
for _, config := range task.Configs {
if config.BackendType == "" {
config.BackendType = c.defaultMode
}
- if err := c.executor.Validate(ctx, config, args2); err != nil {
+ if err := c.executor.Validate(ctx, config, task.Resources,
task.Labels, task.ResourceTypes); err != nil {
var validationErr types.ADCValidationError
if errors.As(err, &validationErr) {
errs.Errors = append(errs.Errors, validationErr)
@@ -283,56 +171,63 @@ func (c *Client) Validate(ctx context.Context, task Task)
error {
return nil
}
-func (c *Client) Sync(ctx context.Context)
(map[string]types.ADCExecutionErrors, error) {
- c.syncMu.Lock()
- defer c.syncMu.Unlock()
- c.log.Info("syncing all resources")
+// SyncInput is one GatewayProxy's complete sync unit. AIC builds it entirely
from its own
+// bookkeeping (which resources target this config, their merged translated
snapshot)
+// before handing it over -- this package never reaches back into AIC's state
to gather
+// anything itself, it only translates, sends, and interprets the response.
+type SyncInput struct {
+ // Name is the cacheKey: the GatewayProxy's own identity.
+ Name string
+ Config adctypes.Config
+ Resources *adctypes.Resources
+ ResourceTypes []string
+ Labels map[string]string
+}
- configs := c.ConfigManager.List()
+// MarshalLog implements logr.Marshaler so logging a SyncInput never dumps the
+// secret-bearing Resources body. Config redacts its own Token via
Config.MarshalJSON.
+func (in SyncInput) MarshalLog() any {
+ return map[string]any{
+ "name": in.Name,
+ "config": in.Config,
+ "labels": in.Labels,
+ "resourceTypes": in.ResourceTypes,
+ "resources": in.Resources.MarshalLog(),
+ }
+}
- if len(configs) == 0 {
- c.log.Info("no GatewayProxy configs provided")
+// Sync pushes every given SyncInput to its data plane in one sweep, and
reports the
+// parsed, typed error for each one that failed, keyed by its Name -- an input
whose name
+// is absent from the returned map genuinely succeeded. It never returns a raw
HTTP status
+// or body; every response ADC can send back is already interpreted by the
time it gets
+// here.
+func (c *Client) Sync(ctx context.Context, inputs []SyncInput)
(map[string]types.ADCExecutionErrors, error) {
+ if len(inputs) == 0 {
return nil, nil
}
-
- c.log.V(1).Info("syncing resources with multiple configs", "configs",
configs)
+ c.log.V(1).Info("syncing resources", "inputs", inputs)
failedMap := map[string]types.ADCExecutionErrors{}
- var failedConfigs []string
- for _, config := range configs {
- name := config.Name
- resources, err := c.GetResources(name)
- if err != nil {
- c.log.Error(err, "failed to get resources from store",
"name", name)
- failedConfigs = append(failedConfigs, name)
+ var failedNames []string
+ for _, in := range inputs {
+ if in.Resources == nil {
continue
}
- if resources == nil {
- continue
- }
- c.log.Info("syncing resources for config", "service_number",
len(resources.Services))
-
- if err := c.sync(ctx, Task{
- Name: name + "-sync",
- Configs: map[types.NamespacedNameKind]adctypes.Config{
- {}: config,
- },
- Resources: resources,
- }); err != nil {
- c.log.Error(err, "failed to sync resources", "name",
name)
- failedConfigs = append(failedConfigs, name)
+ if err := c.syncOne(ctx, in); err != nil {
+ c.log.Error(err, "failed to sync resources", "name",
in.Name)
+ failedNames = append(failedNames, in.Name)
var execErrs types.ADCExecutionErrors
if errors.As(err, &execErrs) {
- failedMap[name] = execErrs
+ failedMap[in.Name] = execErrs
}
}
}
var err error
- if len(failedConfigs) > 0 {
+ if len(failedNames) > 0 {
err = fmt.Errorf("failed to sync %d configs: %s",
- len(failedConfigs),
- strings.Join(failedConfigs, ", "))
+ len(failedNames),
+ strings.Join(failedNames, ", "))
}
return failedMap, err
}
@@ -350,11 +245,11 @@ func (c *Client) Sync(ctx context.Context)
(map[string]types.ADCExecutionErrors,
// 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) {
+func (c *Client) push(ctx context.Context, config adctypes.Config, resources
*adctypes.Resources, labels map[string]string, resourceTypes []string)
([]types.ADCExecutionError, error) {
standalone := config.BackendType == backendAPISIXStandalone
config.BypassCache = standalone && !c.baselineIsCurrent(config.Name)
- err := c.executor.Execute(ctx, config, args)
+ err := c.executor.Execute(ctx, config, resources, labels, resourceTypes)
var alsoReport []types.ADCExecutionError
if standalone && !config.BypassCache && isConfVersionRejection(err) {
@@ -366,7 +261,7 @@ func (c *Client) push(ctx context.Context, config
adctypes.Config, args []string
pkgmetrics.RecordExecutionError(config.Name,
"conf_version_conflict")
config.BypassCache = true
- retryErr := c.executor.Execute(ctx, config, args)
+ retryErr := c.executor.Execute(ctx, config, resources, labels,
resourceTypes)
// 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
@@ -387,88 +282,49 @@ func (c *Client) push(ctx context.Context, config
adctypes.Config, args []string
return alsoReport, err
}
-func (c *Client) sync(ctx context.Context, task Task) error {
- c.log.V(1).Info("syncing resources", "task", task)
-
- if len(task.Configs) == 0 {
- c.log.Info("no adc configs provided")
- return nil
- }
+func (c *Client) syncOne(ctx context.Context, in SyncInput) error {
+ c.log.V(1).Info("syncing resources", "input", in)
var errs types.ADCExecutionErrors
- // Record file I/O duration
- fileIOStart := time.Now()
- // every task resources is the same, so we can use the first config to
prepare the sync file
- syncFilePath, cleanup, err := prepareSyncFile(task.Resources)
- if err != nil {
- pkgmetrics.RecordFileIODuration("prepare_sync_file", "failure",
time.Since(fileIOStart).Seconds())
- return err
+ config := in.Config
+ if config.BackendType == "" {
+ config.BackendType = c.defaultMode
}
- pkgmetrics.RecordFileIODuration("prepare_sync_file",
adctypes.StatusSuccess, time.Since(fileIOStart).Seconds())
- defer cleanup()
- c.log.V(1).Info("prepared sync file", "path", syncFilePath)
-
- args := BuildADCExecuteArgs(syncFilePath, task.Labels,
task.ResourceTypes)
-
- for _, config := range task.Configs {
- // Record sync duration for each config
- startTime := time.Now()
- resourceType := strings.Join(task.ResourceTypes, ",")
- if resourceType == "" {
- resourceType = "all"
- }
- if config.BackendType == "" {
- config.BackendType = c.defaultMode
- }
- alsoReport, err := c.push(ctx, config, args)
- errs.Errors = append(errs.Errors, alsoReport...)
+ startTime := time.Now()
+ resourceType := strings.Join(in.ResourceTypes, ",")
+ if resourceType == "" {
+ resourceType = "all"
+ }
- duration := time.Since(startTime).Seconds()
+ alsoReport, err := c.push(ctx, config, in.Resources, in.Labels,
in.ResourceTypes)
+ errs.Errors = append(errs.Errors, alsoReport...)
- status := adctypes.StatusSuccess
- if err != nil {
- status = "failure"
- c.log.Error(err, "failed to execute adc command",
"config", config)
+ duration := time.Since(startTime).Seconds()
- var execErr types.ADCExecutionError
- if errors.As(err, &execErr) {
- errs.Errors = append(errs.Errors, execErr)
- pkgmetrics.RecordExecutionError(config.Name,
execErr.Name)
- } else {
- pkgmetrics.RecordExecutionError(config.Name,
"unknown")
- }
+ status := adctypes.StatusSuccess
+ if err != nil {
+ status = "failure"
+ c.log.Error(err, "failed to sync with ADC", "config", config)
+
+ var execErr types.ADCExecutionError
+ if errors.As(err, &execErr) {
+ errs.Errors = append(errs.Errors, execErr)
+ pkgmetrics.RecordExecutionError(config.Name,
execErr.Name)
+ } else {
+ errs.Errors = append(errs.Errors,
types.ADCExecutionError{
+ Name: config.Name,
+ FailedErrors:
[]types.ADCExecutionServerAddrError{{Err: err.Error()}},
+ })
+ pkgmetrics.RecordExecutionError(config.Name, "unknown")
}
-
- // Record metrics
- pkgmetrics.RecordSyncDuration(config.Name, resourceType,
status, duration)
}
+ pkgmetrics.RecordSyncDuration(config.Name, resourceType, status,
duration)
+
if len(errs.Errors) > 0 {
return errs
}
return nil
}
-
-func prepareSyncFile(resources any) (string, func(), error) {
- data, err := json.Marshal(resources)
- if err != nil {
- return "", nil, err
- }
-
- tmpFile, err := os.CreateTemp("", "adc-task-*.json")
- if err != nil {
- return "", nil, err
- }
- cleanup := func() {
- _ = tmpFile.Close()
- _ = os.Remove(tmpFile.Name())
- }
- if _, err := tmpFile.Write(data); err != nil {
- cleanup()
- return "", nil, err
- }
-
- return tmpFile.Name(), cleanup, nil
-}
diff --git a/internal/adc/client/executor.go b/internal/adc/client/executor.go
index cfb46a36..6f02433b 100644
--- a/internal/adc/client/executor.go
+++ b/internal/adc/client/executor.go
@@ -26,7 +26,6 @@ import (
"io"
"net"
"net/http"
- "os"
"strings"
"time"
@@ -47,22 +46,8 @@ const (
)
type ADCExecutor interface {
- Execute(ctx context.Context, config adctypes.Config, args []string)
error
- Validate(ctx context.Context, config adctypes.Config, args []string)
error
-}
-
-func BuildADCExecuteArgs(filePath string, labels map[string]string, types
[]string) []string {
- args := []string{
- "sync",
- "-f", filePath,
- }
- for k, v := range labels {
- args = append(args, "--label-selector", k+"="+v)
- }
- for _, t := range types {
- args = append(args, "--include-resource-type", t)
- }
- return args
+ Execute(ctx context.Context, config adctypes.Config, resources
*adctypes.Resources, labels map[string]string, resourceTypes []string) error
+ Validate(ctx context.Context, config adctypes.Config, resources
*adctypes.Resources, labels map[string]string, resourceTypes []string) error
}
// ADCServerRequest represents the request body for ADC Server /sync endpoint
@@ -157,16 +142,16 @@ func NewHTTPADCExecutor(log logr.Logger, serverURL
string, timeout time.Duration
}
// Execute implements the ADCExecutor interface using HTTP calls
-func (e *HTTPADCExecutor) Execute(ctx context.Context, config adctypes.Config,
args []string) error {
- return e.runHTTPSync(ctx, config, args)
+func (e *HTTPADCExecutor) Execute(ctx context.Context, config adctypes.Config,
resources *adctypes.Resources, labels map[string]string, resourceTypes
[]string) error {
+ return e.runHTTPSync(ctx, config, resources, labels, resourceTypes)
}
-func (e *HTTPADCExecutor) Validate(ctx context.Context, config
adctypes.Config, args []string) error {
- return e.runHTTPValidate(ctx, config, args)
+func (e *HTTPADCExecutor) Validate(ctx context.Context, config
adctypes.Config, resources *adctypes.Resources, labels map[string]string,
resourceTypes []string) error {
+ return e.runHTTPValidate(ctx, config, resources, labels, resourceTypes)
}
// runHTTPSync performs HTTP sync to ADC Server for each server address
-func (e *HTTPADCExecutor) runHTTPSync(ctx context.Context, config
adctypes.Config, args []string) error {
+func (e *HTTPADCExecutor) runHTTPSync(ctx context.Context, config
adctypes.Config, resources *adctypes.Resources, labels map[string]string,
resourceTypes []string) error {
var execErrs = types.ADCExecutionError{
Name: config.Name,
}
@@ -180,7 +165,7 @@ func (e *HTTPADCExecutor) runHTTPSync(ctx context.Context,
config adctypes.Confi
e.log.V(1).Info("running http sync", "serverAddrs", serverAddrs)
for _, addr := range serverAddrs {
- if err := e.runHTTPSyncForSingleServer(ctx, addr, config,
args); err != nil {
+ if err := e.runHTTPSyncForSingleServer(ctx, addr, config,
resources, labels, resourceTypes); err != nil {
e.log.Error(err, "failed to run http sync for server",
"server", addr)
var execErr types.ADCExecutionServerAddrError
if errors.As(err, &execErr) {
@@ -199,7 +184,7 @@ func (e *HTTPADCExecutor) runHTTPSync(ctx context.Context,
config adctypes.Confi
return nil
}
-func (e *HTTPADCExecutor) runHTTPValidate(ctx context.Context, config
adctypes.Config, args []string) error {
+func (e *HTTPADCExecutor) runHTTPValidate(ctx context.Context, config
adctypes.Config, resources *adctypes.Resources, labels map[string]string,
resourceTypes []string) error {
var validationErr = types.ADCValidationError{
Name: config.Name,
}
@@ -211,7 +196,7 @@ func (e *HTTPADCExecutor) runHTTPValidate(ctx
context.Context, config adctypes.C
e.log.V(1).Info("running http validate", "serverAddrs", serverAddrs)
for _, addr := range serverAddrs {
- if err := e.runHTTPValidateForSingleServer(ctx, addr, config,
args); err != nil {
+ if err := e.runHTTPValidateForSingleServer(ctx, addr, config,
resources, labels, resourceTypes); err != nil {
e.log.Error(err, "failed to run http validate for
server", "server", addr)
var validationServerErr
types.ADCValidationServerAddrError
if errors.As(err, &validationServerErr) {
@@ -232,29 +217,15 @@ func (e *HTTPADCExecutor) runHTTPValidate(ctx
context.Context, config adctypes.C
}
// runHTTPSyncForSingleServer performs HTTP sync to a single ADC Server
-func (e *HTTPADCExecutor) runHTTPSyncForSingleServer(ctx context.Context,
serverAddr string, config adctypes.Config, args []string) error {
+func (e *HTTPADCExecutor) runHTTPSyncForSingleServer(ctx context.Context,
serverAddr string, config adctypes.Config, resources *adctypes.Resources,
labels map[string]string, resourceTypes []string) error {
ctx, cancel := context.WithTimeout(ctx, e.httpClient.Timeout)
defer cancel()
- // Parse args to extract labels, types, and file path
- labels, types, filePath, err := e.parseArgs(args)
- if err != nil {
- return fmt.Errorf("failed to parse args: %w", err)
- }
-
- // Load resources from file
- resources, err := e.loadResourcesFromFile(filePath)
- if err != nil {
- return fmt.Errorf("failed to load resources from file %s: %w",
filePath, err)
- }
-
- // Build HTTP request
- req, err := e.buildHTTPRequest(ctx, serverAddr, config, labels, types,
resources, pathSync)
+ req, err := e.buildHTTPRequest(ctx, serverAddr, config, labels,
resourceTypes, resources, pathSync)
if err != nil {
return fmt.Errorf("failed to build HTTP request: %w", err)
}
- // Send HTTP request
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send HTTP request: %w", err)
@@ -265,25 +236,14 @@ func (e *HTTPADCExecutor) runHTTPSyncForSingleServer(ctx
context.Context, server
}
}()
- // Handle HTTP response
return e.handleHTTPResponse(resp, serverAddr)
}
-func (e *HTTPADCExecutor) runHTTPValidateForSingleServer(ctx context.Context,
serverAddr string, config adctypes.Config, args []string) error {
+func (e *HTTPADCExecutor) runHTTPValidateForSingleServer(ctx context.Context,
serverAddr string, config adctypes.Config, resources *adctypes.Resources,
labels map[string]string, resourceTypes []string) error {
ctx, cancel := context.WithTimeout(ctx, e.httpClient.Timeout)
defer cancel()
- labels, types, filePath, err := e.parseArgs(args)
- if err != nil {
- return fmt.Errorf("failed to parse args: %w", err)
- }
-
- resources, err := e.loadResourcesFromFile(filePath)
- if err != nil {
- return fmt.Errorf("failed to load resources from file %s: %w",
filePath, err)
- }
-
- req, err := e.buildHTTPRequest(ctx, serverAddr, config, labels, types,
resources, pathValidate)
+ req, err := e.buildHTTPRequest(ctx, serverAddr, config, labels,
resourceTypes, resources, pathValidate)
if err != nil {
return fmt.Errorf("failed to build validate request: %w", err)
}
@@ -301,60 +261,8 @@ func (e *HTTPADCExecutor)
runHTTPValidateForSingleServer(ctx context.Context, se
return e.handleHTTPValidateResponse(resp, serverAddr)
}
-// parseArgs parses the command line arguments to extract labels, types, and
file path
-func (e *HTTPADCExecutor) parseArgs(args []string) (map[string]string,
[]string, string, error) {
- labels := make(map[string]string)
- var types []string
- var filePath string
-
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "-f":
- if i+1 < len(args) {
- filePath = args[i+1]
- i++
- }
- case "--label-selector":
- if i+1 < len(args) {
- labelPair := args[i+1]
- parts := strings.SplitN(labelPair, "=", 2)
- if len(parts) == 2 {
- labels[parts[0]] = parts[1]
- }
- i++
- }
- case "--include-resource-type":
- if i+1 < len(args) {
- types = append(types, args[i+1])
- i++
- }
- }
- }
-
- if filePath == "" {
- return nil, nil, "", errors.New("file path not found in args")
- }
-
- return labels, types, filePath, nil
-}
-
-// loadResourcesFromFile loads ADC resources from the specified file
-func (e *HTTPADCExecutor) loadResourcesFromFile(filePath string)
(*adctypes.Resources, error) {
- data, err := os.ReadFile(filePath)
- if err != nil {
- return nil, fmt.Errorf("failed to read file: %w", err)
- }
-
- var resources adctypes.Resources
- if err := json.Unmarshal(data, &resources); err != nil {
- return nil, fmt.Errorf("failed to unmarshal resources: %w", err)
- }
-
- return &resources, nil
-}
-
// buildHTTPRequest builds the HTTP request for ADC Server
-func (e *HTTPADCExecutor) buildHTTPRequest(ctx context.Context, serverAddr
string, config adctypes.Config, labels map[string]string, types []string,
resources *adctypes.Resources, path string) (*http.Request, error) {
+func (e *HTTPADCExecutor) buildHTTPRequest(ctx context.Context, serverAddr
string, config adctypes.Config, labels map[string]string, resourceTypes
[]string, resources *adctypes.Resources, path string) (*http.Request, error) {
// Prepare request body
tlsVerify := config.TlsVerify
bypassCache := path == pathSync && config.BypassCache
@@ -365,7 +273,7 @@ func (e *HTTPADCExecutor) buildHTTPRequest(ctx
context.Context, serverAddr strin
Server: strings.Split(serverAddr,
","),
Token: config.Token,
LabelSelector: labels,
- IncludeResourceType: types,
+ IncludeResourceType: resourceTypes,
TlsSkipVerify: ptr.To(!tlsVerify),
CaCert: config.CaCert,
CacheKey: config.Name,
@@ -389,7 +297,7 @@ func (e *HTTPADCExecutor) buildHTTPRequest(ctx
context.Context, serverAddr strin
"cacheKey", config.Name,
"bypassCache", bypassCache,
"labelSelector", labels,
- "includeResourceType", types,
+ "includeResourceType", resourceTypes,
"tlsSkipVerify", !tlsVerify,
"hasCaCert", config.CaCert != "",
)
diff --git a/internal/adc/client/executor_test.go
b/internal/adc/client/executor_test.go
index 0d0eb3c8..609e0ee1 100644
--- a/internal/adc/client/executor_test.go
+++ b/internal/adc/client/executor_test.go
@@ -125,7 +125,7 @@ type fakeExecutor struct {
bypassSeq []bool
}
-func (f *fakeExecutor) Execute(_ context.Context, config adctypes.Config, _
[]string) error {
+func (f *fakeExecutor) Execute(_ context.Context, config adctypes.Config, _
*adctypes.Resources, _ map[string]string, _ []string) error {
f.bypassSeq = append(f.bypassSeq, config.BypassCache)
if len(f.errs) == 0 {
return nil
@@ -135,7 +135,9 @@ func (f *fakeExecutor) Execute(_ context.Context, config
adctypes.Config, _ []st
return err
}
-func (f *fakeExecutor) Validate(context.Context, adctypes.Config, []string)
error { return nil }
+func (f *fakeExecutor) Validate(context.Context, adctypes.Config,
*adctypes.Resources, map[string]string, []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.
@@ -158,12 +160,10 @@ func afterFirstSync(exec ADCExecutor) *Client {
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"},
- },
+func newSyncInput() SyncInput {
+ return SyncInput{
+ Name: "GatewayProxy/ns/name-sync",
+ Config: adctypes.Config{Name: "GatewayProxy/ns/name",
BackendType: "apisix-standalone"},
Resources: &adctypes.Resources{},
}
}
@@ -175,13 +175,13 @@ func
TestClientSyncRebuildsOnceAfterElectionThenReusesTheADCCache(t *testing.T)
// 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()))
+ require.NoError(t, c.syncOne(context.Background(), newSyncInput()))
+ require.NoError(t, c.syncOne(context.Background(), newSyncInput()))
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()))
+ require.NoError(t, c.syncOne(context.Background(), newSyncInput()))
assert.Equal(t, []bool{true, false, true}, exec.bypassSeq)
}
@@ -193,8 +193,8 @@ func
TestClientSyncRebuildsAgainWhenTheRebuildWasNotAccepted(t *testing.T) {
}}}
c := newTestClient(exec)
- require.Error(t, c.sync(context.Background(), newSyncTask()))
- require.NoError(t, c.sync(context.Background(), newSyncTask()))
+ require.Error(t, c.syncOne(context.Background(), newSyncInput()))
+ require.NoError(t, c.syncOne(context.Background(), newSyncInput()))
assert.Equal(t, []bool{true, true}, exec.bypassSeq)
}
@@ -205,16 +205,16 @@ func
TestClientSyncRebuildsADCBaselineWhenTheDataPlaneRejectsThePush(t *testing.
// 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))
+ in := newSyncInput()
+ require.NoError(t, c.syncOne(context.Background(), in))
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
+ // survive in the input, it would reach the config 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")
+ assert.False(t, in.Config.BypassCache,
+ "the rebuild must not write BypassCache back into the input's
config")
}
func TestClientSyncDoesNotRebuildOnUnrelatedFailures(t *testing.T) {
@@ -229,7 +229,7 @@ func TestClientSyncDoesNotRebuildOnUnrelatedFailures(t
*testing.T) {
exec := &fakeExecutor{errs: []error{err}}
c := afterFirstSync(exec)
- require.Error(t, c.sync(context.Background(),
newSyncTask()))
+ require.Error(t, c.syncOne(context.Background(),
newSyncInput()))
assert.Equal(t, []bool{false}, exec.bypassSeq)
})
@@ -242,7 +242,7 @@ func TestClientSyncRebuildsHoweverTheRejectionIsWorded(t
*testing.T) {
exec := &fakeExecutor{errs: []error{rejection("upstreams_conf_version
has moved backwards")}}
c := afterFirstSync(exec)
- require.NoError(t, c.sync(context.Background(), newSyncTask()))
+ require.NoError(t, c.syncOne(context.Background(), newSyncInput()))
assert.Equal(t, []bool{false, true}, exec.bypassSeq)
}
@@ -253,9 +253,9 @@ func TestClientSyncDoesNotRebuildOutsideStandalone(t
*testing.T) {
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))
+ in := newSyncInput()
+ in.Config = adctypes.Config{Name: "GatewayProxy/ns/name", BackendType:
"apisix"}
+ require.Error(t, c.syncOne(context.Background(), in))
assert.Equal(t, []bool{false}, exec.bypassSeq)
}
@@ -266,7 +266,7 @@ func TestClientSyncSurfacesErrorWhenRebuildFails(t
*testing.T) {
exec := &fakeExecutor{errs: []error{confVersionError(),
rejection(`unrecognized key "bypassCache"`)}}
c := afterFirstSync(exec)
- err := c.sync(context.Background(), newSyncTask())
+ err := c.syncOne(context.Background(), newSyncInput())
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")
@@ -282,7 +282,7 @@ func TestClientSyncDoesNotReportTheSameRejectionTwice(t
*testing.T) {
exec := &fakeExecutor{errs: []error{confVersionError(),
confVersionError()}}
c := afterFirstSync(exec)
- err := c.sync(context.Background(), newSyncTask())
+ err := c.syncOne(context.Background(), newSyncInput())
var execErrs types.ADCExecutionErrors
require.ErrorAs(t, err, &execErrs)
diff --git a/internal/adc/client/redaction_test.go
b/internal/adc/client/redaction_test.go
index 0cbb1783..b76a6683 100644
--- a/internal/adc/client/redaction_test.go
+++ b/internal/adc/client/redaction_test.go
@@ -69,7 +69,6 @@ func TestTaskMarshalLogRedactsSecrets(t *testing.T) {
log := bufferLogger(&buf)
task := Task{
- Key: types.NamespacedNameKind{Namespace: "ns", Name:
"route-1", Kind: "ApisixRoute"},
Name: "ns/route-1",
Configs: map[types.NamespacedNameKind]adctypes.Config{
{}: {Name: "gw", Token: secretAdminKey, ServerAddrs:
[]string{"http://x"}},
diff --git a/internal/provider/apisix/keyedmutex.go
b/internal/provider/apisix/keyedmutex.go
new file mode 100644
index 00000000..b564dbe9
--- /dev/null
+++ b/internal/provider/apisix/keyedmutex.go
@@ -0,0 +1,52 @@
+// 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 "sync"
+
+// keyedMutex is a registry of per-key locks. It exists so that reading a
GatewayProxy's
+// current resource snapshot and pushing it can be one atomic step per
cacheKey: whichever
+// caller is granted a key's lock decides what to push only once it actually
holds the lock,
+// so nothing it sends can already be stale relative to whatever the other
caller committed
+// to the store before losing the race for the same key.
+//
+// The registry only grows -- entries are never evicted. Harmless in practice:
cacheKey
+// tracks a small, effectively fixed set of GatewayProxies for the life of the
process. This
+// mirrors ADC server's own per-cacheKey sync_lock.
+type keyedMutex struct {
+ mu sync.Mutex
+ locks map[string]*sync.Mutex
+}
+
+func newKeyedMutex() *keyedMutex {
+ return &keyedMutex{locks: make(map[string]*sync.Mutex)}
+}
+
+// Lock blocks until key's lock is held, and returns the func that releases it.
+func (k *keyedMutex) Lock(key string) func() {
+ k.mu.Lock()
+ l, ok := k.locks[key]
+ if !ok {
+ l = &sync.Mutex{}
+ k.locks[key] = l
+ }
+ k.mu.Unlock()
+
+ l.Lock()
+ return l.Unlock
+}
diff --git a/internal/provider/apisix/keyedmutex_test.go
b/internal/provider/apisix/keyedmutex_test.go
new file mode 100644
index 00000000..2c6f024c
--- /dev/null
+++ b/internal/provider/apisix/keyedmutex_test.go
@@ -0,0 +1,94 @@
+// 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 (
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// TestKeyedMutexSerializesTheSameKey covers what makes syncConfigNow correct:
two holders
+// of the same key must never be inside their critical section at the same
time.
+func TestKeyedMutexSerializesTheSameKey(t *testing.T) {
+ k := newKeyedMutex()
+ var busy atomic.Bool
+
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ unlock := k.Lock("shared")
+ defer unlock()
+ if !busy.CompareAndSwap(false, true) {
+ t.Error("another holder was already in the
critical section")
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ busy.Store(false)
+ }()
+ }
+ wg.Wait()
+}
+
+// TestKeyedMutexDoesNotBlockDifferentKeys covers the other half: a busy key
must not stall
+// callers working on an unrelated one, or an immediate delete push for one
GatewayProxy
+// would wait behind a slow periodic sync of a completely different one.
+func TestKeyedMutexDoesNotBlockDifferentKeys(t *testing.T) {
+ k := newKeyedMutex()
+
+ unlockOne := k.Lock("one")
+ defer unlockOne()
+
+ done := make(chan struct{})
+ go func() {
+ unlockTwo := k.Lock("two")
+ defer unlockTwo()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("locking a different key blocked behind an unrelated
key's holder")
+ }
+}
+
+// TestKeyedMutexUnlockReleasesTheKey covers that the returned func actually
frees the key
+// for the next caller, not just for the same goroutine that locked it.
+func TestKeyedMutexUnlockReleasesTheKey(t *testing.T) {
+ k := newKeyedMutex()
+
+ unlock := k.Lock("x")
+ unlock()
+
+ done := make(chan struct{})
+ go func() {
+ unlockAgain := k.Lock("x")
+ defer unlockAgain()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("unlock did not release the key for the next caller")
+ }
+}
diff --git a/internal/provider/apisix/provider.go
b/internal/provider/apisix/provider.go
index 039e3d75..45a0179a 100644
--- a/internal/provider/apisix/provider.go
+++ b/internal/provider/apisix/provider.go
@@ -19,6 +19,8 @@ package apisix
import (
"context"
+ "errors"
+ "fmt"
"net/http"
"sync"
"time"
@@ -31,6 +33,7 @@ import (
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
"github.com/apache/apisix-ingress-controller/api/v1alpha1"
apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
+ "github.com/apache/apisix-ingress-controller/internal/adc/cache"
adcclient
"github.com/apache/apisix-ingress-controller/internal/adc/client"
"github.com/apache/apisix-ingress-controller/internal/adc/translator"
"github.com/apache/apisix-ingress-controller/internal/controller/label"
@@ -51,12 +54,24 @@ const (
MinSyncPeriod = 1 * time.Second
)
+// apisixProvider owns AIC's own view of what should be live: which Kubernetes
resource
+// targets which GatewayProxy config (configManager) and the merged,
translated resource
+// snapshot per config (store). It builds the input the adc client package
needs and hands
+// it over on every call; the client package holds none of this state itself.
type apisixProvider struct {
provider.Options
sync.Mutex
translator *translator.Translator
+ store *cache.Store
+ configManager *common.ConfigManager[types.NamespacedNameKind,
adctypes.Config]
+ debugProvider *common.ADCDebugProvider
+
+ // syncLocks serializes, per cacheKey, reading that GatewayProxy's
current resource
+ // snapshot together with pushing it
+ syncLocks *keyedMutex
+
updater status.Updater
statusUpdateMap map[types.NamespacedNameKind][]string
@@ -82,19 +97,26 @@ func New(log logr.Logger, updater status.Updater, readier
readiness.ReadinessMan
return nil, err
}
+ store := cache.NewStore(logger)
+ configManager := common.NewConfigManager[types.NamespacedNameKind,
adctypes.Config]()
+
return &apisixProvider{
- client: cli,
- Options: o,
- translator: translator.NewTranslator(log,
o.ListenerPortMatchMode),
- updater: updater,
- readier: readier,
- syncCh: make(chan struct{}, 1),
- log: logger,
+ client: cli,
+ store: store,
+ configManager: configManager,
+ debugProvider: common.NewADCDebugProvider(store, configManager),
+ syncLocks: newKeyedMutex(),
+ Options: o,
+ translator: translator.NewTranslator(log,
o.ListenerPortMatchMode),
+ updater: updater,
+ readier: readier,
+ syncCh: make(chan struct{}, 1),
+ log: logger,
}, nil
}
func (d *apisixProvider) Register(pathPrefix string, mux *http.ServeMux) {
- d.client.ADCDebugProvider.SetupHandler(pathPrefix, mux)
+ d.debugProvider.SetupHandler(pathPrefix, mux)
}
func (d *apisixProvider) Update(ctx context.Context, tctx
*provider.TranslateContext, obj client.Object) error {
@@ -168,23 +190,17 @@ func (d *apisixProvider) Update(ctx context.Context, tctx
*provider.TranslateCon
defer d.syncNotify()
- task := adcclient.Task{
- Key: rk,
- Name: rk.String(),
- Labels: label.GenLabel(obj),
- Configs: configs,
- ResourceTypes: resourceTypes,
- Resources: &adctypes.Resources{
- GlobalRules: result.GlobalRules,
- PluginMetadata: result.PluginMetadata,
- Services: result.Services,
- SSLs: result.SSL,
- Consumers: result.Consumers,
- },
+ resources := &adctypes.Resources{
+ GlobalRules: result.GlobalRules,
+ PluginMetadata: result.PluginMetadata,
+ Services: result.Services,
+ SSLs: result.SSL,
+ Consumers: result.Consumers,
}
- d.log.V(1).Info("updating config", "task", task)
+ labels := label.GenLabel(obj)
+ d.log.V(1).Info("updating config", "resourceKey", rk, "configs",
configs, "resourceTypes", resourceTypes)
- return d.client.UpdateConfig(ctx, task)
+ return d.applyResourceState(rk, configs, resourceTypes, resources,
labels)
}
func (d *apisixProvider) Delete(ctx context.Context, obj client.Object) error {
@@ -222,25 +238,135 @@ func (d *apisixProvider) Delete(ctx context.Context, obj
client.Object) error {
// and it is not possible to perform scheduled synchronization
// on deleted gateway level resources
if len(resourceTypes) == 0 {
- return d.client.Delete(ctx, adcclient.Task{
- Key: nnk,
- Name: nnk.String(),
- Labels: labels,
- })
+ removed, err := d.removeResourceState(nnk, resourceTypes,
labels)
+ if err != nil {
+ return err
+ }
+ d.syncEvictedConfigsNow(ctx, removed, resourceTypes, labels)
+ return nil
+ }
+
+ removed, err := d.removeResourceState(nnk, resourceTypes, labels)
+ if err != nil {
+ return err
}
- delta, err := d.client.DeleteConfig(ctx, adcclient.Task{
- Key: nnk,
- Name: nnk.String(),
- Labels: labels,
- ResourceTypes: resourceTypes,
- })
- // Syncing pushes the whole store to every data plane. Objects this
controller
- // never configured delete nothing, and reconciles for them are
frequent, so
- // notify only when the store actually changed.
- if len(delta.Deleted) > 0 {
+ // Syncing pushes the whole store to every data plane. Objects this
controller never
+ // configured delete nothing, and reconciles for them are frequent, so
notify only
+ // when the store actually changed.
+ if len(removed) > 0 {
d.syncNotify()
}
- return err
+ return nil
+}
+
+// applyResourceState upserts a resource's config associations and its
contribution to each
+// target config's cached resource snapshot -- the AIC-side bookkeeping the
adc client
+// package no longer holds itself.
+func (d *apisixProvider) applyResourceState(
+ rk types.NamespacedNameKind,
+ configs map[types.NamespacedNameKind]adctypes.Config,
+ resourceTypes []string,
+ resources *adctypes.Resources,
+ labels map[string]string,
+) error {
+ d.Lock()
+ defer d.Unlock()
+
+ evicted := d.configManager.Update(rk, configs)
+ if err := d.evictFromStore(evicted, resourceTypes, labels); err != nil {
+ return err
+ }
+ for _, cfg := range configs {
+ if err := d.store.Insert(cfg.Name, resourceTypes, resources,
labels); err != nil {
+ return fmt.Errorf("store insert failed for config %s:
%w", cfg.Name, err)
+ }
+ }
+ return nil
+}
+
+// removeResourceState forgets a resource's config associations and evicts its
contribution
+// from each config it used to reference, returning those configs so an
immediate-push
+// caller (see syncEvictedConfigsNow) knows what to push right away.
+func (d *apisixProvider) removeResourceState(
+ rk types.NamespacedNameKind,
+ resourceTypes []string,
+ labels map[string]string,
+) (map[types.NamespacedNameKind]adctypes.Config, error) {
+ d.Lock()
+ defer d.Unlock()
+
+ evicted := d.configManager.Get(rk)
+ d.configManager.Delete(rk)
+ if err := d.evictFromStore(evicted, resourceTypes, labels); err != nil {
+ return nil, err
+ }
+ return evicted, nil
+}
+
+// evictFromStore deletes a resource's contribution from each of the given
configs' cached
+// snapshots. Callers must already hold d.Lock.
+func (d *apisixProvider) evictFromStore(
+ configs map[types.NamespacedNameKind]adctypes.Config,
+ resourceTypes []string,
+ labels map[string]string,
+) error {
+ for _, cfg := range configs {
+ if err := d.store.Delete(cfg.Name, resourceTypes, labels); err
!= nil {
+ return fmt.Errorf("store delete failed for config %s:
%w", cfg.Name, err)
+ }
+ }
+ return nil
+}
+
+// syncConfigNow reads name's current data (via build, called only once this
cacheKey's
+// lock is actually held) and pushes it -- one atomic read-then-push step per
cacheKey, so
+// whichever caller is granted the lock decides what to push only once it
holds it: nothing
+// it sends can already be stale relative to whatever the other caller
committed to the
+// store before losing the race for the same key. See keyedMutex.
+func (d *apisixProvider) syncConfigNow(
+ ctx context.Context,
+ name string,
+ build func() (adcclient.SyncInput, error),
+) (types.ADCExecutionErrors, error) {
+ unlock := d.syncLocks.Lock(name)
+ defer unlock()
+
+ input, err := build()
+ if err != nil {
+ return types.ADCExecutionErrors{}, err
+ }
+ failedMap, err := d.client.Sync(ctx, []adcclient.SyncInput{input})
+ return failedMap[name], err
+}
+
+// syncEvictedConfigsNow pushes an empty resource set for each of the given
configs
+// immediately, instead of waiting for the next scheduled sync round --
through the same
+// per-cacheKey lock the periodic sync uses, so it can never race a periodic
round for the
+// same GatewayProxy. Used only when the deleted resource is a Gateway or
IngressClass --
+// resourceTypes is empty for those, so the preceding removeResourceState call
already
+// reset each config's whole cached snapshot via Store.Delete, and that reset
should reach
+// the data plane promptly. Failures are logged, not surfaced as a status
update -- this
+// mirrors the deferred path, which only reports through the next scheduled
sync round.
+func (d *apisixProvider) syncEvictedConfigsNow(
+ ctx context.Context,
+ configs map[types.NamespacedNameKind]adctypes.Config,
+ resourceTypes []string,
+ labels map[string]string,
+) {
+ for _, cfg := range configs {
+ _, err := d.syncConfigNow(ctx, cfg.Name, func()
(adcclient.SyncInput, error) {
+ return adcclient.SyncInput{
+ Name: cfg.Name,
+ Config: cfg,
+ Resources: &adctypes.Resources{},
+ ResourceTypes: resourceTypes,
+ Labels: labels,
+ }, nil
+ })
+ if err != nil {
+ d.log.Error(err, "failed to sync deleted config",
"config", cfg)
+ }
+ }
}
func (d *apisixProvider) buildConfig(tctx *provider.TranslateContext, nnk
types.NamespacedNameKind) (map[types.NamespacedNameKind]adctypes.Config, error)
{
@@ -298,10 +424,35 @@ func (d *apisixProvider) Start(ctx context.Context) error
{
}
}
+// sync pushes every GatewayProxy AIC currently knows about, config by config
-- each
+// one's current resource snapshot is only read once syncConfigNow actually
holds that
+// cacheKey's lock, so a slow round can never push a snapshot that was already
stale by the
+// time its turn came up. All of this round's results are still collected into
one
+// statusesMap and handed to handleADCExecutionErrors together, exactly as a
single batched
+// sync would: that logic diffs against last round's full picture, not
per-config.
func (d *apisixProvider) sync(ctx context.Context) error {
- statusesMap, err := d.client.Sync(ctx)
+ configs := d.configManager.List()
+
+ statusesMap := map[string]types.ADCExecutionErrors{}
+ var errs []error
+ for _, config := range configs {
+ execErrs, err := d.syncConfigNow(ctx, config.Name, func()
(adcclient.SyncInput, error) {
+ resources, err := d.store.GetResources(config.Name)
+ if err != nil {
+ return adcclient.SyncInput{},
fmt.Errorf("failed to get resources from store: %w", err)
+ }
+ return adcclient.SyncInput{Name: config.Name, Config:
config, Resources: resources}, nil
+ })
+ if err != nil {
+ errs = append(errs, fmt.Errorf("config %s: %w",
config.Name, err))
+ }
+ if len(execErrs.Errors) > 0 {
+ statusesMap[config.Name] = execErrs
+ }
+ }
+
d.handleADCExecutionErrors(statusesMap)
- return err
+ return errors.Join(errs...)
}
func (d *apisixProvider) syncNotify() {
@@ -330,12 +481,17 @@ func (d *apisixProvider) updateConfigForGatewayProxy(tctx
*provider.TranslateCon
nnk := utils.NamespacedNameKind(gp)
if config == nil {
- d.client.ConfigManager.DeleteConfig(nnk)
+ d.Lock()
+ d.configManager.DeleteConfig(nnk)
+ d.Unlock()
return nil
}
+
referrers := tctx.GatewayProxyReferrers[utils.NamespacedName(gp)]
- d.client.ConfigManager.SetConfigRefs(nnk, referrers)
- d.client.ConfigManager.UpdateConfig(nnk, *config)
+ d.Lock()
+ d.configManager.SetConfigRefs(nnk, referrers)
+ d.configManager.UpdateConfig(nnk, *config)
+ d.Unlock()
d.syncNotify()
return nil
}
diff --git a/internal/provider/apisix/provider_test.go
b/internal/provider/apisix/provider_test.go
index e3be9b13..edd182a8 100644
--- a/internal/provider/apisix/provider_test.go
+++ b/internal/provider/apisix/provider_test.go
@@ -19,33 +19,58 @@ package apisix
import (
"context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
"testing"
"time"
"github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+ "github.com/apache/apisix-ingress-controller/internal/adc/cache"
adcclient
"github.com/apache/apisix-ingress-controller/internal/adc/client"
+ "github.com/apache/apisix-ingress-controller/internal/provider/common"
"github.com/apache/apisix-ingress-controller/internal/types"
"github.com/apache/apisix-ingress-controller/internal/utils"
)
+// withMockADCServer starts an ADC server stub and points ADC_SERVER_URL at it
for the
+// duration of the test. The handler itself is how a test inspects what it
received.
+func withMockADCServer(t *testing.T, handler http.HandlerFunc) {
+ t.Helper()
+ server := httptest.NewServer(handler)
+ t.Setenv("ADC_SERVER_URL", server.URL)
+ t.Cleanup(server.Close)
+}
+
+// newTestProvider builds a minimally-wired apisixProvider against the given
mock ADC
+// server -- every field Client/Delete/sync touch, none of the
manager/controller ones.
+func newTestProvider(t *testing.T) *apisixProvider {
+ t.Helper()
+ cli, err := adcclient.New(logr.Discard(), ProviderTypeAPISIX,
time.Second)
+ require.NoError(t, err)
+ return &apisixProvider{
+ client: cli,
+ store: cache.NewStore(logr.Discard()),
+ configManager:
common.NewConfigManager[types.NamespacedNameKind, adctypes.Config](),
+ syncLocks: newKeyedMutex(),
+ syncCh: make(chan struct{}, 1),
+ log: logr.Discard(),
+ }
+}
+
// TestDeleteNotifiesSyncOnlyWhenConfigWasRemoved covers the cost side of route
// ownership: a sync pushes the whole store to every data plane, and reconciles
// for routes this controller never configured are frequent (any EndpointSlice
// event on a shared backend enqueues them), so those must not notify.
func TestDeleteNotifiesSyncOnlyWhenConfigWasRemoved(t *testing.T) {
- cli, err := adcclient.New(logr.Discard(), ProviderTypeAPISIX,
time.Second)
- require.NoError(t, err)
-
- d := &apisixProvider{
- client: cli,
- syncCh: make(chan struct{}, 1),
- log: logr.Discard(),
- }
+ d := newTestProvider(t)
route := &gatewayv1.HTTPRoute{
TypeMeta: metav1.TypeMeta{
@@ -58,10 +83,95 @@ func TestDeleteNotifiesSyncOnlyWhenConfigWasRemoved(t
*testing.T) {
require.NoError(t, d.Delete(context.Background(), route))
require.Empty(t, d.syncCh, "a route this controller never configured
must not trigger a sync")
- cli.ConfigManager.Update(utils.NamespacedNameKind(route),
map[types.NamespacedNameKind]adctypes.Config{
+ d.configManager.Update(utils.NamespacedNameKind(route),
map[types.NamespacedNameKind]adctypes.Config{
{Namespace: "default", Name: "proxy", Kind: "GatewayProxy"}:
{Name: "proxy"},
})
require.NoError(t, d.Delete(context.Background(), route))
require.Len(t, d.syncCh, 1, "removing configuration this controller
pushed must trigger a sync")
}
+
+// TestDeleteTriggersImmediateSyncForEvictedConfigs covers the immediate-push
branch of
+// Delete: a Gateway going away must reach the data plane right away -- an
empty resource
+// set for the config it referenced -- not wait for the next scheduled sync
round.
+func TestDeleteTriggersImmediateSyncForEvictedConfigs(t *testing.T) {
+ var mu sync.Mutex
+ var received []adcclient.ADCServerRequest
+
+ withMockADCServer(t, func(w http.ResponseWriter, r *http.Request) {
+ var req adcclient.ADCServerRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
+ mu.Lock()
+ received = append(received, req)
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(adctypes.SyncResult{Status:
adctypes.StatusSuccess})
+ })
+
+ d := newTestProvider(t)
+
+ gw := &gatewayv1.Gateway{
+ TypeMeta: metav1.TypeMeta{
+ Kind: "Gateway",
+ APIVersion: gatewayv1.GroupVersion.String(),
+ },
+ ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "gw"},
+ }
+ d.configManager.Update(utils.NamespacedNameKind(gw),
map[types.NamespacedNameKind]adctypes.Config{
+ {Namespace: "default", Name: "proxy", Kind: "GatewayProxy"}: {
+ Name: "proxy",
+ BackendType: "apisix",
+ ServerAddrs: []string{"http://apisix:9080"},
+ },
+ })
+
+ require.NoError(t, d.Delete(context.Background(), gw))
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Len(t, received, 1, "deleting a Gateway must push immediately,
not wait for the next scheduled round")
+ assert.Equal(t, "proxy", received[0].Task.Opts.CacheKey)
+ assert.Empty(t, received[0].Task.Config.Services, "the evicted config's
push must carry an empty resource set")
+}
+
+// TestSyncStillPushesHealthyConfigsWhenAnotherFails covers sync's error
aggregation: one
+// GatewayProxy's push failing must not stop the others in the same round from
being
+// attempted, and the failure must still be reported.
+func TestSyncStillPushesHealthyConfigsWhenAnotherFails(t *testing.T) {
+ var mu sync.Mutex
+ seen := map[string]bool{}
+
+ withMockADCServer(t, func(w http.ResponseWriter, r *http.Request) {
+ var req adcclient.ADCServerRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
+ mu.Lock()
+ seen[req.Task.Opts.CacheKey] = true
+ mu.Unlock()
+ if req.Task.Opts.CacheKey == "bad" {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"message": "boom"}`))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(adctypes.SyncResult{Status:
adctypes.StatusSuccess})
+ })
+
+ d := newTestProvider(t)
+ for _, name := range []string{"bad", "good"} {
+ key := types.NamespacedNameKind{Namespace: "default", Name:
name, Kind: "GatewayProxy"}
+ d.configManager.UpdateConfig(key, adctypes.Config{
+ Name: name,
+ BackendType: "apisix",
+ ServerAddrs: []string{"http://apisix:9080"},
+ })
+ }
+
+ err := d.sync(context.Background())
+ require.Error(t, err, "one config failing must still be reported")
+ assert.Contains(t, err.Error(), "bad")
+
+ mu.Lock()
+ defer mu.Unlock()
+ assert.True(t, seen["bad"], "the failing config must still have been
attempted")
+ assert.True(t, seen["good"], "a config failing must not stop the others
from being pushed")
+}
diff --git a/internal/provider/apisix/status.go
b/internal/provider/apisix/status.go
index a1d857eb..e2f82bd0 100644
--- a/internal/provider/apisix/status.go
+++ b/internal/provider/apisix/status.go
@@ -109,7 +109,7 @@ func (d *apisixProvider) updateStatus(nnk
types.NamespacedNameKind, condition me
}),
})
case types.KindHTTPRoute:
- parentRefs :=
d.client.ConfigManager.GetConfigRefsByResourceKey(nnk)
+ parentRefs := d.configManager.GetConfigRefsByResourceKey(nnk)
d.log.V(1).Info("updating HTTPRoute status", "parentRefs",
parentRefs)
gatewayRefs := map[types.NamespacedNameKind]struct{}{}
for _, parentRef := range parentRefs {
@@ -145,7 +145,7 @@ func (d *apisixProvider) updateStatus(nnk
types.NamespacedNameKind, condition me
}),
})
case types.KindUDPRoute:
- parentRefs :=
d.client.ConfigManager.GetConfigRefsByResourceKey(nnk)
+ parentRefs := d.configManager.GetConfigRefsByResourceKey(nnk)
d.log.V(1).Info("updating UDPRoute status", "parentRefs",
parentRefs)
gatewayRefs := map[types.NamespacedNameKind]struct{}{}
for _, parentRef := range parentRefs {
@@ -181,7 +181,7 @@ func (d *apisixProvider) updateStatus(nnk
types.NamespacedNameKind, condition me
}),
})
case types.KindTCPRoute:
- parentRefs :=
d.client.ConfigManager.GetConfigRefsByResourceKey(nnk)
+ parentRefs := d.configManager.GetConfigRefsByResourceKey(nnk)
d.log.V(1).Info("updating TCPRoute status", "parentRefs",
parentRefs)
gatewayRefs := map[types.NamespacedNameKind]struct{}{}
for _, parentRef := range parentRefs {
@@ -217,7 +217,7 @@ func (d *apisixProvider) updateStatus(nnk
types.NamespacedNameKind, condition me
}),
})
case types.KindGRPCRoute:
- parentRefs :=
d.client.ConfigManager.GetConfigRefsByResourceKey(nnk)
+ parentRefs := d.configManager.GetConfigRefsByResourceKey(nnk)
d.log.V(1).Info("updating GRPCRoute status", "parentRefs",
parentRefs)
gatewayRefs := map[types.NamespacedNameKind]struct{}{}
for _, parentRef := range parentRefs {
@@ -279,7 +279,7 @@ func (d *apisixProvider) handleEmptyFailedStatuses(
failedStatus types.ADCExecutionServerAddrError,
statusUpdateMap map[types.NamespacedNameKind][]string,
) {
- resource, err := d.client.GetResources(configName)
+ resource, err := d.store.GetResources(configName)
if err != nil {
d.log.Error(err, "failed to get resources from store",
"configName", configName)
return
@@ -297,7 +297,7 @@ func (d *apisixProvider) handleEmptyFailedStatuses(
d.addResourceToStatusUpdateMap(obj.GetLabels(),
failedStatus.Error(), statusUpdateMap)
}
- globalRules, err := d.client.ListGlobalRules(configName)
+ globalRules, err := d.store.ListGlobalRules(configName)
if err != nil {
d.log.Error(err, "failed to list global rules", "configName",
configName)
return
@@ -319,7 +319,7 @@ func (d *apisixProvider) handleDetailedFailedStatuses(
return
}
id := status.Event.ResourceID
- labels, err := d.client.GetResourceLabel(configName,
status.Event.ResourceType, id)
+ labels, err := d.store.GetResourceLabel(configName,
status.Event.ResourceType, id)
if err != nil {
d.log.Error(err, "failed to get resource label",
"configName", configName,
diff --git a/internal/webhook/v1/adc_validation.go
b/internal/webhook/v1/adc_validation.go
index 2c980c0f..d4c45f9a 100644
--- a/internal/webhook/v1/adc_validation.go
+++ b/internal/webhook/v1/adc_validation.go
@@ -217,7 +217,6 @@ func (v *adcAdmissionValidator)
buildIngressClassConfigs(ctx context.Context, ob
func (v *adcAdmissionValidator) newTask(obj client.Object, configs
map[internaltypes.NamespacedNameKind]adctypes.Config, resourceTypes []string,
result *adctranslator.TranslateResult) *adcclient.Task {
return &adcclient.Task{
- Key: utils.NamespacedNameKind(obj),
Name: utils.NamespacedNameKind(obj).String(),
Labels: label.GenLabel(obj),
Configs: configs,
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
index c9537fe1..4f1b6ff0 100644
--- a/pkg/metrics/metrics.go
+++ b/pkg/metrics/metrics.go
@@ -58,16 +58,6 @@ var (
Help: "Current length of the status update queue",
},
)
-
- // File I/O operation duration histogram
- FileIODuration = prometheus.NewHistogramVec(
- prometheus.HistogramOpts{
- Name: "apisix_ingress_file_io_duration_seconds",
- Help: "Time spent on file I/O operations",
- Buckets: prometheus.DefBuckets,
- },
- []string{"operation", "status"},
- )
)
// init registers all metrics with the global prometheus registry
@@ -78,7 +68,6 @@ func init() {
ADCSyncTotal,
ADCExecutionErrors,
StatusUpdateQueueLength,
- FileIODuration,
)
}
@@ -107,8 +96,3 @@ func IncStatusQueueLength() {
func DecStatusQueueLength() {
StatusUpdateQueueLength.Dec()
}
-
-// RecordFileIODuration records the duration of a file I/O operation
-func RecordFileIODuration(operation, status string, duration float64) {
- FileIODuration.WithLabelValues(operation, status).Observe(duration)
-}
diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go
index 4159a42b..67b4a0b7 100644
--- a/test/e2e/crds/v2/route.go
+++ b/test/e2e/crds/v2/route.go
@@ -170,7 +170,6 @@ spec:
Expect(bodyStr).Should(ContainSubstring("apisix_ingress_adc_sync_duration_seconds"))
Expect(bodyStr).Should(ContainSubstring("apisix_ingress_adc_sync_total"))
Expect(bodyStr).Should(ContainSubstring("apisix_ingress_status_update_queue_length"))
-
Expect(bodyStr).Should(ContainSubstring("apisix_ingress_file_io_duration_seconds"))
}
It("Basic", func() {
test(apisixRouteSpec)