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

@moonmingmoonming commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 errorsprovider_keys:
- display_name: openai-prodprovider: openaiapi_key: ${OPENAI_API_KEY} # ${VAR} env interpolation in any string valuemodels:
- display_name: gpt-4oprovider: openaimodel_name: gpt-4o-2024-11-20provider_key: openai-prod # file sugar: name ref → provider_key_idapi_keys:
- display_name: ci-bot # file sugar: identity only — stripped from the documentkey_env: CI_BOT_KEY # file sugar: env var NAME → SHA-256 → key_hashallowed_models: ["gpt-4o"]rate_limit_policies:
- name: cap-gpt4oscope: modelscope_ref: gpt-4o # file sugar: name → derived id for api_key/model scopeswindow: minutemax_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 explicitmodels[].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

coderabbitaiBot 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 nameStatusExplanationResolution
Security Check❌ ErrorRouter-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⚠️ WarningSolid 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 nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

@coderabbitaicoderabbitaiBot 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 threadcrates/aisix-admin/src/lib.rs
…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
MemberAuthor

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 mainJul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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

@moonmingmoonming commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 errorsprovider_keys:
- display_name: openai-prodprovider: openaiapi_key: ${OPENAI_API_KEY} # ${VAR} env interpolation in any string valuemodels:
- display_name: gpt-4oprovider: openaimodel_name: gpt-4o-2024-11-20provider_key: openai-prod # file sugar: name ref → provider_key_idapi_keys:
- display_name: ci-bot # file sugar: identity only — stripped from the documentkey_env: CI_BOT_KEY # file sugar: env var NAME → SHA-256 → key_hashallowed_models: ["gpt-4o"]rate_limit_policies:
- name: cap-gpt4oscope: modelscope_ref: gpt-4o # file sugar: name → derived id for api_key/model scopeswindow: minutemax_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 explicitmodels[].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

coderabbitaiBot 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 nameStatusExplanationResolution
Security Check❌ ErrorRouter-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⚠️ WarningSolid 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 nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

@coderabbitaicoderabbitaiBot 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 threadcrates/aisix-admin/src/lib.rs
…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
MemberAuthor

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 mainJul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

@moonmingmoonming commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 errorsprovider_keys:
- display_name: openai-prodprovider: openaiapi_key: ${OPENAI_API_KEY} # ${VAR} env interpolation in any string valuemodels:
- display_name: gpt-4oprovider: openaimodel_name: gpt-4o-2024-11-20provider_key: openai-prod # file sugar: name ref → provider_key_idapi_keys:
- display_name: ci-bot # file sugar: identity only — stripped from the documentkey_env: CI_BOT_KEY # file sugar: env var NAME → SHA-256 → key_hashallowed_models: ["gpt-4o"]rate_limit_policies:
- name: cap-gpt4oscope: modelscope_ref: gpt-4o # file sugar: name → derived id for api_key/model scopeswindow: minutemax_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 explicitmodels[].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

coderabbitaiBot 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 nameStatusExplanationResolution
Security Check❌ ErrorRouter-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⚠️ WarningSolid 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 nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

@coderabbitaicoderabbitaiBot 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 threadcrates/aisix-admin/src/lib.rs
…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
MemberAuthor

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 mainJul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

@moonmingmoonming commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 errorsprovider_keys:
- display_name: openai-prodprovider: openaiapi_key: ${OPENAI_API_KEY} # ${VAR} env interpolation in any string valuemodels:
- display_name: gpt-4oprovider: openaimodel_name: gpt-4o-2024-11-20provider_key: openai-prod # file sugar: name ref → provider_key_idapi_keys:
- display_name: ci-bot # file sugar: identity only — stripped from the documentkey_env: CI_BOT_KEY # file sugar: env var NAME → SHA-256 → key_hashallowed_models: ["gpt-4o"]rate_limit_policies:
- name: cap-gpt4oscope: modelscope_ref: gpt-4o # file sugar: name → derived id for api_key/model scopeswindow: minutemax_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 explicitmodels[].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

coderabbitaiBot 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 nameStatusExplanationResolution
Security Check❌ ErrorRouter-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⚠️ WarningSolid 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 nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

@coderabbitaicoderabbitaiBot 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 threadcrates/aisix-admin/src/lib.rs
…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
MemberAuthor

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 mainJul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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

@moonmingmoonming commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 errorsprovider_keys:
- display_name: openai-prodprovider: openaiapi_key: ${OPENAI_API_KEY} # ${VAR} env interpolation in any string valuemodels:
- display_name: gpt-4oprovider: openaimodel_name: gpt-4o-2024-11-20provider_key: openai-prod # file sugar: name ref → provider_key_idapi_keys:
- display_name: ci-bot # file sugar: identity only — stripped from the documentkey_env: CI_BOT_KEY # file sugar: env var NAME → SHA-256 → key_hashallowed_models: ["gpt-4o"]rate_limit_policies:
- name: cap-gpt4oscope: modelscope_ref: gpt-4o # file sugar: name → derived id for api_key/model scopeswindow: minutemax_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 explicitmodels[].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

coderabbitaiBot 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 nameStatusExplanationResolution
Security Check❌ ErrorRouter-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⚠️ WarningSolid 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 nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

@coderabbitaicoderabbitaiBot 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 threadcrates/aisix-admin/src/lib.rs
…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
MemberAuthor

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 mainJul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

@moonmingmoonming commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 errorsprovider_keys:
- display_name: openai-prodprovider: openaiapi_key: ${OPENAI_API_KEY} # ${VAR} env interpolation in any string valuemodels:
- display_name: gpt-4oprovider: openaimodel_name: gpt-4o-2024-11-20provider_key: openai-prod # file sugar: name ref → provider_key_idapi_keys:
- display_name: ci-bot # file sugar: identity only — stripped from the documentkey_env: CI_BOT_KEY # file sugar: env var NAME → SHA-256 → key_hashallowed_models: ["gpt-4o"]rate_limit_policies:
- name: cap-gpt4oscope: modelscope_ref: gpt-4o # file sugar: name → derived id for api_key/model scopeswindow: minutemax_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 explicitmodels[].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

coderabbitaiBot 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 nameStatusExplanationResolution
Security Check❌ ErrorRouter-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⚠️ WarningSolid 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 nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

@coderabbitaicoderabbitaiBot 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 threadcrates/aisix-admin/src/lib.rs
…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
MemberAuthor

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 mainJul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

@moonmingmoonming commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 errorsprovider_keys:
- display_name: openai-prodprovider: openaiapi_key: ${OPENAI_API_KEY} # ${VAR} env interpolation in any string valuemodels:
- display_name: gpt-4oprovider: openaimodel_name: gpt-4o-2024-11-20provider_key: openai-prod # file sugar: name ref → provider_key_idapi_keys:
- display_name: ci-bot # file sugar: identity only — stripped from the documentkey_env: CI_BOT_KEY # file sugar: env var NAME → SHA-256 → key_hashallowed_models: ["gpt-4o"]rate_limit_policies:
- name: cap-gpt4oscope: modelscope_ref: gpt-4o # file sugar: name → derived id for api_key/model scopeswindow: minutemax_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 explicitmodels[].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

coderabbitaiBot 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 nameStatusExplanationResolution
Security Check❌ ErrorRouter-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⚠️ WarningSolid 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 nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

@coderabbitaicoderabbitaiBot 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 threadcrates/aisix-admin/src/lib.rs
…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
MemberAuthor

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 mainJul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for freeto 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

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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

@moonmingmoonming commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 errorsprovider_keys:
- display_name: openai-prodprovider: openaiapi_key: ${OPENAI_API_KEY} # ${VAR} env interpolation in any string valuemodels:
- display_name: gpt-4oprovider: openaimodel_name: gpt-4o-2024-11-20provider_key: openai-prod # file sugar: name ref → provider_key_idapi_keys:
- display_name: ci-bot # file sugar: identity only — stripped from the documentkey_env: CI_BOT_KEY # file sugar: env var NAME → SHA-256 → key_hashallowed_models: ["gpt-4o"]rate_limit_policies:
- name: cap-gpt4oscope: modelscope_ref: gpt-4o # file sugar: name → derived id for api_key/model scopeswindow: minutemax_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 explicitmodels[].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

coderabbitaiBot 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 nameStatusExplanationResolution
Security Check❌ ErrorRouter-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⚠️ WarningSolid 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 nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe 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.

@coderabbitaicoderabbitaiBot 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 threadcrates/aisix-admin/src/lib.rs
…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
MemberAuthor

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 mainJul 13, 2026
12 checks passed
@moonming
moonming deleted the feat/file-resource-source branch July 13, 2026 07:58
Sign up for freeto 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

@moonming