Skip to content

bug(backend-apisix-standalone): global_rules read path drops plugins → duplicate global_rule on resync → APISIX 400 #489

Description

@geoffreytran

Bug description

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.
  • Sync feat: support part of diff command #2 fails:
    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):

global_rules: Object.fromEntries(
  input.global_rules?.map((gr) => [gr.id, gr.plugins![0]]) ?? [],
),

plugins is a map, not an array — libs/backend-apisix-standalone/src/typing.ts:

const Plugins = z.record(z.string(), z.record(z.string(), z.unknown()));

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:

  1. Current state is read via toADC → global_rules always appear empty.
  2. The differ compares desired vs (empty) current → emits a CREATE for the global rule
    every sync.
  3. fromADC (operator.ts) builds it correctly with a deterministic id
    (generateIdFromEventcd935c2f):
    case GLOBAL_RULE:
      return { id: generateIdFromEvent(event), plugins: { [event.resourceId]: event.newValue } };
  4. 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:

Object.fromEntries(list.flatMap((item) => Object.entries(item.value?.plugins ?? {})))

so the diff sees existing global rules and never re-creates them.

Suggested fix

Make the standalone toADC mirror the traditional backend — flatten each rule's plugins
map into the single ADC plugins map:

global_rules: Object.fromEntries(
  input.global_rules?.flatMap((gr) => Object.entries(gr.plugins ?? {})) ?? [],
),

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions