Skip to content

feat(config): file-based resource source (resources.yaml) for standalone mode - #759

Merged
moonming merged 3 commits into
mainfrom
feat/file-resource-source
Jul 13, 2026
Merged

feat(config): file-based resource source (resources.yaml) for standalone mode#759
moonming merged 3 commits into
mainfrom
feat/file-resource-source

Conversation

@moonming

@moonming moonming commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

What

A standalone file-based resource source: a single-container gateway can now load every dynamic resource — provider keys, models, API keys, guardrails, MCP servers, A2A agents, cache policies, observability exporters, rate-limit policies — from one resources.yaml, instead of running etcd and writing resources through the Admin API. This adds a second, fully declarative way to run standalone; the etcd source is completely unchanged.

_format_version: "1"          # mandatory; unknown top-level keys are load errors

provider_keys:
  - display_name: openai-prod
    provider: openai
    api_key: ${OPENAI_API_KEY}          # ${VAR} env interpolation in any string value

models:
  - display_name: gpt-4o
    provider: openai
    model_name: gpt-4o-2024-11-20
    provider_key: openai-prod           # file sugar: name ref → provider_key_id

api_keys:
  - display_name: ci-bot                # file sugar: identity only — stripped from the document
    key_env: CI_BOT_KEY                 # file sugar: env var NAME → SHA-256 → key_hash
    allowed_models: ["gpt-4o"]

rate_limit_policies:
  - name: cap-gpt4o
    scope: model
    scope_ref: gpt-4o                   # file sugar: name → derived id for api_key/model scopes
    window: minute
    max_requests: 300

Enable it in config.yaml:

resources_file: /etc/aisix/resources.yaml   # replaces the etcd section

The file format (v1) was pinned after a review of mainstream declarative gateway-config conventions; the format is versioned (_format_version) so later revisions can evolve it explicitly.

Format rules (all enforced at load)

  • Canonical documents underneath. File sugar desugars before validation; after desugaring every entry must be exactly a canonical resource document (schemas/resources/*.schema.json) and flows through the same JSON-Schema validators and typed serde models as the etcd path. Canonical schemas are untouched by this PR (dump-schema / dump-openapi byte-identical).
  • ${VAR} interpolation in any string value: partial values work (https://${HOST}/v1), $$ escapes a literal $, bare $VAR is not interpolated, and a missing/empty variable is a load error naming file, kind, entry, and field path. Interpolation runs on parsed string scalars, so environment values can never inject YAML structure.
  • File sugar (the only three conveniences):
    • models[].provider_key — provider-key name → derived provider_key_id; mutually exclusive with an explicit provider_key_id; unknown names error and list what is defined.
    • api_keys[].display_name — required file-side identity, stripped before validation (the canonical api_key document has no name field). key_env XOR key_hash; key_env names an environment variable whose value is hashed (SHA-256 lowercase hex, identical to ApiKey::hash_bearer) and dropped — the plaintext never reaches logs, error messages, or the store. A variable whose value itself looks like ${...} (double indirection) is rejected.
    • rate_limit_policies[].scope_ref — for scope: api_key / scope: model, a name resolved to the referenced entry's derived id; team / member / team_member pass through verbatim.
  • Deterministic ids, no id field. Every entry's identity is its display_name (provider_keys, models, api_keys) or name (the other six; mcp_servers/a2a_agents also accept display_name per their schemas). The derived id is UUIDv5 of "<kind>/<identity>" under a fixed namespace (FILE_RESOURCE_NAMESPACE = 63e50ab2-677a-54d3-8d1e-c0cb29ceae94, itself uuid5(NAMESPACE_URL, "https://github.com/api7/aisix#resources-file"), pinned by test) — stable across reloads and processes. An id field in the file is rejected on every kind, including the schema-open ones.
  • Duplicate identity within a kind is a load error, naming both entries. Duplicate credentials are too: two api_keys entries resolving to the same key_hash would silently last-wins in the runtime credential index, so the load rejects them (the same invariant the Admin API enforces on create) — the error names the entries, never the hash or plaintext.
  • Cross-references resolve at load time so a typo can never become a silent runtime failure: every non-glob api_keys[].allowed_models entry, routing.targets[].model, ensemble panel/judge ref, and semantic embedding_model/default/route-target/failure-target must match a defined model name (entries containing * are glob patterns and exempt). scope_ref resolution follows the same rule for api_key/model scopes. An explicit models[].provider_key_id must equal the derived id of a file-defined provider key (ids are deterministic, so generated files can carry them; any other value is guaranteed dangling and is rejected with a pointer to the provider_key name sugar).

Mode wiring

  • resources_file is a new optional top-level config field. When set, the etcd section must be absent or unconfigured; setting both is a boot error, as is combining it with managed.enabled (managed mode is control-plane-driven by definition). Without resources_file, behavior is exactly today's etcd mode.
  • In file mode the admin listener stays up: reads (GET lists/gets, /admin/v1/models/status, /admin/v1/health, OpenAPI, /readyz) serve from the live snapshot, and the Playground works. Every resource write (POST/PUT/DELETE, including key rotation) answers 409 with a message naming the file, enforced at one router-level chokepoint (plus read-only store errors as defense in depth). Auth ordering: the guard only short-circuits for requests carrying a valid admin key — unauthenticated or wrong-key writes get the same 401 as etcd mode, and the 409 body (which names the operator's file path) is only ever shown to authenticated admins.
  • Guardrails in file mode are env-global. The v1 format has no attachment collection, so a file-defined guardrail applies to every request in the gateway (the zero-attachment behavior of the guardrail index; a source comment there now pins the dependency, and an e2e case pins that a file-defined guardrail actually fires).

Fail-fast boot & SIGHUP reload

  • Boot: any load problem fails the boot with a non-zero exit and an aggregated report — every error across the whole file, each with kind/entry/field context — not first-error-only.
  • SIGHUP: re-runs the identical pipeline; success swaps the snapshot atomically (the same swap machinery the etcd watch path uses, with a bumped generation as the entries' revision); failure logs the aggregated report at WARN and keeps serving the last-good snapshot. Nothing from a rejected file is partially applied.
  • aisix validate --resources <file> runs the same pipeline without booting listeners: exit 0 on success, non-zero with the same aggregated report otherwise. ${VAR} references resolve against the validating process's environment.

Deliberately absent

  • No file watcher — reloads are explicit (SIGHUP), so a half-written file save can't race the loader.
  • No id fields in the file — ids are always derived; hand-managed ids would break the determinism that keeps references and rate-limit counters stable across reloads.
  • Duplicates are errors, not last-wins — the file is the whole truth; silent overwrite hides mistakes.
  • No load-time existence check for allowed_tools / allowed_agents entries. These are permission masks (glob patterns matched at request time), not references: an entry granting a tool or agent that is not (yet) defined is a no-op grant, exactly as in etcd mode where an ACL may legitimately predate the resource it names. The load-time reference checks deliberately cover the dispatch-critical refs (models, provider keys, scope_ref), where a dangle means a broken request path rather than an unused grant.

Verification

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo test --workspace (with live etcd + redis integration env): 2274 passed, 0 failed.
  • 53 new unit tests: 39 filesource pipeline (format gate, unknown keys, interpolation incl. $$ / bare-$ / missing-var, sugar resolution, key_env hashing + XOR + no-leak, duplicate identity + duplicate credential, id rejection, cross-refs with glob exemption, explicit provider_key_id resolution, scope_ref per scope, UUID determinism), 5 config wiring, 2 file store, 4 admin file-mode routing (409 coverage incl. rotate, snapshot-backed reads, unauthenticated-write 401 without path disclosure, guard inert in etcd mode), 1 auth helper, 3 server CLI/validate.
  • New e2e (tests/e2e/src/cases/file-resource-source-e2e.test.ts, 8 cases): file-mode smoke (full chain via key_env caller + mock upstream; allowed_models 403 with upstream untouched), Playground in file mode, admin write-guard (authenticated 409 + snapshot reads + unauthenticated 401 with no path leak), file-defined guardrail fires (422, upstream untouched), differential file-mode vs etcd-mode (same logical resources → identical /v1/models listing, chat 200 body, 403 envelope), SIGHUP reload (valid edit applies; invalid edit keeps last-good with nothing partially applied), fail-fast boot (non-zero exit + aggregated named errors on stderr), aisix validate exit codes + stderr report.
  • Full existing e2e suite green three times (etcd mode untouched): runs 1–2 on the initial push (139 files / 295 tests each, exit 0) and run 3 after the review fixes (139 files / 297 tests, exit 0).
  • cargo run -p aisix-core --bin dump-schema and cargo run -p aisix-admin --bin dump-openapi: no diff — the canonical schemas are intentionally untouched.

Review trail

An independent audit (no HIGH findings) and the automated review both ran against the first push; every MEDIUM is addressed in code on this branch: auth-ordering on the write guard (unauthenticated writes now 401 without path disclosure), duplicate-credential rejection, explicit provider_key_id resolution, the guardrail zero-attachment dependency pinned in a source comment + e2e, validate failure-path e2e, reload pipeline moved to a blocking task, and a CI-loud skip for the differential case. The one deliberate non-change (ACL masks, above) is justified in “Deliberately absent”.

…one mode

A single-container gateway can now load every dynamic resource from one
declarative resources.yaml instead of etcd + Admin API writes:

- aisix-core::filesource: read -> ${VAR} interpolation (string scalars,
  $$ escape, partial values, missing/empty var = error) -> file sugar
  (models[].provider_key name ref, api_keys[].display_name identity +
  key_env -> SHA-256 key_hash, rate_limit_policies[].scope_ref name
  resolution) -> the SAME canonical JSON-Schema validators and typed
  serde models as the etcd path -> cross-reference checks (non-glob
  model refs must exist) -> AisixSnapshot. Ids are UUIDv5 of
  "<kind>/<identity>" under a fixed documented namespace, stable
  across reloads; duplicate identities and unknown top-level keys are
  load errors; all errors are aggregated, not first-only.
- config: new optional top-level resources_file; mutually exclusive
  with configured etcd.endpoints and with managed mode; etcd-only
  behavior unchanged when absent.
- server: file-mode boot fails fast with the aggregated report; SIGHUP
  re-runs the pipeline and atomically swaps the snapshot on success,
  keeps last-good and WARNs on failure (no file watcher by design);
  new `aisix validate --resources <file>` runs the identical pipeline
  without booting listeners.
- admin: listener stays up in file mode — reads (lists/gets, status,
  health, openapi, playground) serve from the live snapshot via
  FileManagedStore; every resource write (incl. rotate) answers 409
  naming the file through one router-level guard.
- e2e: harness gains file mode (spawnApp resourcesFile, no etcd
  contact) + fast-fail on early binary exit; new cases cover smoke,
  playground, admin write-guard, file-vs-etcd differential behavior,
  SIGHUP reload (valid + invalid edit), and fail-fast boot.

Canonical schemas are untouched (dump-schema / dump-openapi no-diff).
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a file-based resource source with YAML interpolation, deterministic IDs, validation, SIGHUP reloads, offline validation, read-only admin endpoints, and gateway E2E coverage.

Changes

File resource source

Layer / File(s) Summary
Configuration and resource-file parsing
Cargo.toml, config.example.yaml, crates/aisix-core/Cargo.toml, crates/aisix-core/src/config.rs, crates/aisix-core/src/filesource/*, crates/aisix-core/src/lib.rs
Adds resources_file configuration, YAML interpolation, deterministic UUIDv5 IDs, resource desugaring, and parser dependencies.
Resource loading and aggregated validation
crates/aisix-core/src/filesource/mod.rs, crates/aisix-core/src/filesource/tests.rs
Loads YAML into snapshots with format, identity, schema, cross-reference, revision, and aggregated error validation.
Read-only admin integration
crates/aisix-admin/src/error.rs, crates/aisix-admin/src/file_store.rs, crates/aisix-admin/src/lib.rs, crates/aisix-admin/src/state.rs, crates/aisix-admin/src/store.rs
Adds snapshot-backed file-managed storage and returns HTTP 409 for resource mutations while allowing reads.
CLI, startup, and reload runtime
crates/aisix-server/src/main.rs
Adds validate --resources, file-mode startup, SIGHUP reloads, last-good snapshot retention, and conditional admin wiring.
Gateway and harness coverage
tests/e2e/src/cases/file-resource-source-e2e.test.ts, tests/e2e/src/harness/app.ts
Tests file-mode serving, admin behavior, etcd parity, reloads, malformed boot input, and file-mode process handling.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

  • api7/AISIX-Cloud#1008 — Directly covers the file-based resources.yaml source, validation, reload, and file-managed admin behavior.

Possibly related PRs

  • api7/aisix#280 — Introduces the rate-limit policy structure used by file-source scope_ref desugaring.
  • api7/aisix#664 — Adds MCP server admin operations supported by FileManagedStore.
  • api7/aisix#717 — Adds admin write rejection behavior aligned with file-managed HTTP 409 responses.

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant aisix-server
  participant filesource
  participant SnapshotHandle
  participant AdminRouter
  Operator->>aisix-server: Start with resources_file
  aisix-server->>filesource: Load and validate resources
  filesource-->>aisix-server: AisixSnapshot
  aisix-server->>SnapshotHandle: Seed snapshot
  Operator->>aisix-server: Send SIGHUP
  aisix-server->>filesource: Reload resources file
  filesource->>SnapshotHandle: Replace snapshot on success
  Operator->>AdminRouter: Read or mutate resource
  AdminRouter->>SnapshotHandle: Read current snapshot
  AdminRouter-->>Operator: Resource data or HTTP 409
Loading

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Security Check ❌ Error Router-wide file-managed middleware returns 409 with the resources path before AdminAuth runs, leaking internal file location to unauthenticated callers. Move the file-managed write guard inside the authenticated admin route layer, or gate it on successful auth first so unauthenticated writes still return 401.
E2e Test Quality Review ⚠️ Warning Solid E2E coverage overall, but file mode misses unauthenticated-write coverage; the outer write guard can return 409 before AdminAuth and leak the file path. Move the file-mode guard behind auth (or check auth inside it) and add an E2E asserting unauthenticated mutating /admin/v1 requests still return 401.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a file-based resources.yaml source for standalone mode.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/file-resource-source

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/aisix-core/src/filesource/mod.rs (1)

134-511: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider extracting the pipeline passes into helpers.

load_from_str runs the full pipeline (~380 lines) inline. It reads clearly thanks to the pass banners, but each pass (root/format gate, pass 1 prepare, pass 2 desugar+validate+decode, pass 3 cross-ref, materialize) is a natural function boundary that would shrink this to a readable orchestrator and make each stage independently testable. Optional given the strong test coverage.

As per coding guidelines: "Keep functions small and focused - aim for functions under 20-30 lines".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-core/src/filesource/mod.rs` around lines 134 - 511, Refactor
load_from_str into a short orchestration function by extracting the root/format
validation, preparation, desugar/validation/decoding, cross-reference checks,
and snapshot materialization into focused helper functions. Preserve the
existing pass order, error aggregation, and behavior while passing shared state
explicitly; keep finish and the extracted stages independently testable.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-admin/src/lib.rs`:
- Around line 213-240: The file_managed_write_guard currently runs before
AdminAuth and exposes the file-managed path to unauthenticated mutating
requests. Rework the router composition so file_managed_write_guard is applied
inside the authenticated admin route group or otherwise after the AdminAuth
boundary, while preserving its existing read and non-resource bypass behavior
and returning 401 for unauthenticated writes.

---

Nitpick comments:
In `@crates/aisix-core/src/filesource/mod.rs`:
- Around line 134-511: Refactor load_from_str into a short orchestration
function by extracting the root/format validation, preparation,
desugar/validation/decoding, cross-reference checks, and snapshot
materialization into focused helper functions. Preserve the existing pass order,
error aggregation, and behavior while passing shared state explicitly; keep
finish and the extracted stages independently testable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9a70928b-1b3d-4d90-9110-30c115d0e1ae

📥 Commits

Reviewing files that changed from the base of the PR and between 395f673 and 1c3fb9a.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • Cargo.toml
  • config.example.yaml
  • crates/aisix-admin/src/error.rs
  • crates/aisix-admin/src/file_store.rs
  • crates/aisix-admin/src/lib.rs
  • crates/aisix-admin/src/state.rs
  • crates/aisix-admin/src/store.rs
  • crates/aisix-core/Cargo.toml
  • crates/aisix-core/src/config.rs
  • crates/aisix-core/src/filesource/desugar.rs
  • crates/aisix-core/src/filesource/mod.rs
  • crates/aisix-core/src/filesource/tests.rs
  • crates/aisix-core/src/filesource/yaml.rs
  • crates/aisix-core/src/lib.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/file-resource-source-e2e.test.ts
  • tests/e2e/src/harness/app.ts

Comment thread crates/aisix-admin/src/lib.rs
moonming added 2 commits July 13, 2026 15:30
…equests

The router-level 409 guard runs before per-handler AdminAuth (layers
wrap routes), so an unauthenticated mutating request in file mode got
the 409 — whose body names the resources-file path — instead of 401.
The guard now short-circuits only when the request carries a valid
admin key (shared header check hoisted out of the AdminAuth extractor);
unauthenticated and wrong-key writes fall through to the handler's 401
with no path disclosure. Unit + e2e regression coverage added.
…ng provider_key_id, guardrail-fallback pin

Independent-audit follow-ups (no HIGH; all MEDIUMs addressed):

- reject two api_keys entries resolving to the same key_hash: the
  runtime credential index is hash-keyed, so a duplicate plaintext
  silently last-wins at auth time — now a load error naming both
  entries (never the hash or plaintext), matching the invariant the
  Admin API enforces on create.
- reject an explicit models[].provider_key_id that doesn't equal the
  derived id of a file-defined provider key: in file mode every id is
  derived, so any other value is guaranteed dangling and previously
  only surfaced per-request. Correctly-derived explicit ids still load
  (determinism makes generated files legal).
- pin the guardrail dependency: file-defined guardrails execute through
  the zero-attachment env-global behavior of the guardrail index —
  the removal-slated compat block now carries a NOTE that the file
  source relies on it, and a new e2e case asserts a file-defined
  guardrail actually fires (422, upstream untouched).
- run the SIGHUP reload pipeline (blocking file IO + parse/validate)
  on a blocking task instead of an async worker.
- e2e: validate-subcommand failure path (exit 1 + aggregated report on
  stderr), CI-loud skip for the differential case, doc note that error
  aggregation is per-entry granular.
@moonming

Copy link
Copy Markdown
Collaborator Author

Review triage (automated review + independent audit), all addressed as of 0d4fdc1:

Fixed in code

  • Auth ordering on the file-managed write guard (the one actionable comment + both pre-merge check items): unauthenticated mutating /admin/v1/* requests now return 401 with no resources-path disclosure; the 409 is authenticated-only. Unit + e2e regression tests added (7178a63).
  • Duplicate api-key credentials (same key_hash under two display_names) are now a load error instead of silent last-wins in the auth index (0d4fdc1).
  • Explicit models[].provider_key_id must match a file-defined provider key's derived id — a pasted foreign UUID is guaranteed dangling in file mode and now fails the load with a pointer to the provider_key name sugar (0d4fdc1).
  • File-defined guardrails execute via the zero-attachment env-global behavior of the guardrail index; that dependency is now pinned by a source comment at the fallback and by a new e2e case asserting a file-defined guardrail fires (0d4fdc1).
  • aisix validate failure path e2e (exit 1 + aggregated stderr report), SIGHUP reload pipeline moved to a blocking task, CI-loud skip for the differential case (0d4fdc1).

Declined with rationale

  • Nitpick “extract load_from_str passes into helpers”: keeping the pipeline inline is deliberate — it is a single linear pass sequence sharing one error accumulator, the pass banners keep it readable, and splitting it would add parameter-plumbing without changing behavior or testability (the stages are already covered by 39 pipeline tests through the public entrypoint). Happy to revisit if the pipeline grows another pass.
  • No load-time existence check for allowed_tools / allowed_agents entries (raised by the audit): these are permission masks matched at request time, not dispatch references — an entry granting a not-yet-defined tool/agent is a no-op grant, exactly as in etcd mode where ACLs may predate the resources they name. Documented under “Deliberately absent” in the PR body.

@moonming
moonming merged commit 9b8b407 into main Jul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant