You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In the apisix-standalone backend, the dump/read transform (toADC) mis-reads global_rules from the data plane, so ADC never "sees" the global rules that already
exist. On every sync it re-issues a CREATE for the same global rule, and on the second
sync APISIX rejects the (now duplicated) config with HTTP 400:
invalid global_rules at index 0, err: plugin '<name>' already exists in global rule with id 'cd935c2f'
Because the standalone PUT /apisix/admin/configs is atomic, the entire config
(routes, services, upstreams, ssls) is rejected — not just the global rule. The same
config syncs fine through the traditional apisix (etcd) backend.
Environment
adc: 0.26.0 (also reproduced on 0.24.2 and 0.27.0 — the buggy line is unchanged)
APISIX: 3.16.0 (listed as "Full" support in the standalone backend README)
Reached via apisix-ingress-controller 2.1.0 in API-driven standalone mode; also
reproducible with adc sync directly.
Reproduction
Minimal ADC config — one global rule, one plugin:
global_rules:
request-id: {}services: []
Sync it to an APISIX 3.16 standalone instance twice (or let the controller's periodic
re-sync run once):
Sync feat: init adc #1 succeeds — one global_rule (id cd935c2f) is created.
responded with status 400 Bad Request, error_msg: invalid global_rules at index 0,
err: plugin 'request-id' already exists in global rule with id 'cd935c2f'
The traditional backend does not exhibit this.
Root cause
The read path drops the plugins. libs/backend-apisix-standalone/src/transformer.ts
(toADC):
so gr.plugins![0] (index "0") is always undefined, and recursiveOmitUndefined
drops the entry. toADC therefore returns empty/garbage global_rules regardless of what
the data plane actually contains.
Consequence on the diff/sync loop:
Current state is read via toADC → global_rules always appear empty.
The differ compares desired vs (empty) current → emits a CREATE for the global rule
every sync.
fromADC (operator.ts) builds it correctly with a deterministic id
(generateIdFromEvent → cd935c2f):
case GLOBAL_RULE:
return{id: generateIdFromEvent(event),plugins: {[event.resourceId]: event.newValue}};
On the second sync the data plane already has cd935c2f; because current was mis-read
as empty, the operator pushes a second rule with the same id/plugin → APISIX
rejects the atomic PUT.
Why the traditional backend is unaffected
Its read path flattens plugins correctly — libs/backend-apisix/src/fetcher.ts:
This is a one-line change in transformer.ts. Happy to open a PR including a backend-apisix-standalone e2e test that syncs a (multi-plugin) global rule twice and
asserts the second sync is a no-op, which would have caught this.
Impact
Global rules are effectively unusable in API-driven standalone mode: the first sync works,
then a re-sync takes the whole data plane out of sync (atomic PUT rejected).
Bug description
In the
apisix-standalonebackend, the dump/read transform (toADC) mis-readsglobal_rulesfrom the data plane, so ADC never "sees" the global rules that alreadyexist. On every sync it re-issues a CREATE for the same global rule, and on the second
sync APISIX rejects the (now duplicated) config with HTTP 400:
Because the standalone
PUT /apisix/admin/configsis atomic, the entire config(routes, services, upstreams, ssls) is rejected — not just the global rule. The same
config syncs fine through the traditional
apisix(etcd) backend.Environment
reproducible with
adc syncdirectly.Reproduction
Minimal ADC config — one global rule, one plugin:
Sync it to an APISIX 3.16 standalone instance twice (or let the controller's periodic
re-sync run once):
cd935c2f) is created.The traditional backend does not exhibit this.
Root cause
The read path drops the plugins.
libs/backend-apisix-standalone/src/transformer.ts(
toADC):pluginsis a map, not an array —libs/backend-apisix-standalone/src/typing.ts:so
gr.plugins is alwaysundefined, andrecursiveOmitUndefineddrops the entry.
toADCtherefore returns empty/garbageglobal_rulesregardless of whatthe data plane actually contains.
Consequence on the diff/sync loop:
toADC→ global_rules always appear empty.every sync.
fromADC(operator.ts) builds it correctly with a deterministic id(
generateIdFromEvent→cd935c2f):cd935c2f; because current was mis-readas empty, the operator pushes a second rule with the same id/plugin → APISIX
rejects the atomic PUT.
Why the traditional backend is unaffected
Its read path flattens plugins correctly —
libs/backend-apisix/src/fetcher.ts:so the diff sees existing global rules and never re-creates them.
Suggested fix
Make the standalone
toADCmirror the traditional backend — flatten each rule'spluginsmap into the single ADC plugins map:
This is a one-line change in
transformer.ts. Happy to open a PR including abackend-apisix-standalonee2e test that syncs a (multi-plugin) global rule twice andasserts the second sync is a no-op, which would have caught this.
Impact
Global rules are effectively unusable in API-driven standalone mode: the first sync works,
then a re-sync takes the whole data plane out of sync (atomic PUT rejected).