Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,23 @@ jobs:
- uses: ./.github/actions/setup-protoc
- uses: Swatinem/rust-cache@v2
- name: regenerate schemas
run: cargo run -p aisix-core --bin dump-schema
run: |
# The dump only ever writes, so a file it STOPPED emitting (a
# resource dropped from RESOURCES) would sit in the tree forever and
# keep being vendored. Clear both sets first so a vanished file shows
# up in `git diff` as a deletion.
rm -f schemas/resources/*.schema.json schemas/resources-lenient/*.schema.json
cargo run -p aisix-core --bin dump-schema
- name: assert no drift
run: |
# --intent-to-add so a file the dump newly creates (a new resource,
# or a whole set) shows up in `git diff` instead of passing as
# untracked. Both published sets are checked: `schemas/resources/`
# (strict write contract) and `schemas/resources-lenient/` (the
# etcd loader's read contract).
git add --intent-to-add schemas/
if ! git diff --exit-code schemas/; then
echo "::error::Resource JSON Schemas in 'schemas/resources/' drift from the Rust types in 'crates/aisix-core/src/models/'."
echo "::error::Resource JSON Schemas in 'schemas/resources/' or 'schemas/resources-lenient/' drift from the Rust types in 'crates/aisix-core/src/models/'."
echo "::error::Fix: run 'cargo run -p aisix-core --bin dump-schema' locally and commit the diff."
exit 1
fi
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,16 @@ This repo reads its config from etcd, but users never write etcd directly — th

(Lesson from AISIX-Cloud#873 routing: `least_cost` / `least_latency` / `least_busy`, per-target `tags`, and `sticky` canary all shipped DP-only across #681/#682/#684/#686/#687 while `cp-admin.yaml` still pinned the closed `[round_robin, weighted, failover]` enum and the dashboard had no fields — so none of it was actually usable until the matching CP integration landed. The meta-repo `AGENTS.md` carries the same rule for cross-plane agents.)

## A Control-Plane Response Decodes Loosely

**A data plane may run against a control plane many releases newer than itself, and the etcd read tolerance covers only the resources it loads — the gateway's direct HTTP channels to the control plane need the same tolerance stated for them.** Any struct that decodes a control-plane response must never carry `#[serde(deny_unknown_fields)]`, and every field except the one the decision hinges on must be `#[serde(default)]`, so a field the newer control plane added, or an optional one it stopped sending, cannot turn into a hard error on the request path. A response the gateway does not parse today — one it reads only the status of — stays unparsed unless it is decoded under the same rule. The startup `Config` and its blocks are the deliberate exception: they are `deny_unknown_fields` because they parse the gateway's own `config.yaml`, which nothing but the operator writes.

## The Resource Model Is Canonical in cp-admin.yaml

**When this repo and the control plane disagree about a resource field's name, enum values, or nesting, the control plane's spec (`AISIX-Cloud: openapi/cp-admin.yaml`) wins by definition — this repo converges to it.**

- Adding or renaming a user-facing resource field starts by defining its name and shape in `cp-admin.yaml` (in the paired CP PR — see the config-knob rule above); the Rust model then implements exactly that name. The naming decision happens once, in the spec — never independently here.
- Renames converge with `#[serde(alias = "…")]` so stored documents and existing callers keep loading through the deprecation window; never hard-rename a shipped field in one step (an unreleased field with no consumers may rename outright, as #657 did). Regenerate `schemas/resources/` afterwards (`cargo run -p aisix-core --bin dump-schema`).
- Renames converge with `#[serde(alias = "…")]` so stored documents and existing callers keep loading through the deprecation window; never hard-rename a shipped field in one step (an unreleased field with no consumers may rename outright, as #657 did). Regenerate `schemas/` afterwards (`cargo run -p aisix-core --bin dump-schema`, which writes both the strict and the lenient set).
- Exactly four divergence axes are registered as intentional and allowed: reference style (names here vs UUIDs in the CP), tenancy scoping (flat here vs org/environment there), credential custody (`key_hash` in documents here vs server-generated plaintext-once there), and CP-derived fields (`cost`, `telemetry_tags`). Anything else that diverges from cp-admin.yaml is drift — the planned cross-plane contract check will fail it.
- Why the CP spec and not this repo's schemas: the CP is spec-first behind a closed validator (its spec already is the authoritative field shape on that side), the spec renders into the customer-facing API reference, and this repo's schemas are generated from the implementation — a schema that follows the implementation cannot lead it. Naming drift has already cost real churn: #644 (the generated schema advertised `rps`/`rph` the validator rejected) and #657 (a wire-breaking rename because the field was named DP-first).

Expand Down
62 changes: 52 additions & 10 deletions crates/aisix-core/src/bin/dump-schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@
//! `schemars` 0.8) — nested types live in the `definitions/` section
//! of the same document, no cross-file `$ref` required.
//!
//! Every run writes BOTH published sets, under the same file names:
//!
//! - `schemas/resources/` — the **strict** write contract
//! (`resource_root_schema(name, true)`), what `aisix validate` and the
//! resources-file source enforce.
//! - `schemas/resources-lenient/` — the **read** contract
//! (`resource_root_schema(name, false)`), the schema the etcd loader
//! actually validates stored documents against. It is free of
//! `additionalProperties: false` at every depth, so a document written by
//! a newer control plane loads with its extra fields ignored instead of
//! the whole row being skipped. Published so a consumer that needs to know
//! what this build will LOAD can read it instead of deriving it from the
//! strict files. It is NOT a write contract, and for `model`, `api_key`,
//! `guardrail` and `mcp_policy` it relaxes more than unknown fields —
//! `schemas/README.md` lists what.
//!
//! The five nested struct types have no standalone validator on either path,
//! so their standalone files (in both sets) document the struct's shape
//! rather than anything enforced; the authoritative copy of one is the
//! embedding resource's own `definitions` entry.
//!
//! Re-run after modifying any resource struct in
//! `crates/aisix-core/src/models/`. CI runs this binary and rejects PRs
//! that leave `schemas/` out of date (drift check, follow-up PR).
Expand All @@ -34,8 +55,11 @@ use aisix_core::models::schema;
use aisix_core::models::{EmbeddingConfig, EnsembleConfig, RateLimit, Routing, Semantic};

fn main() {
let out_dir = workspace_root().join("schemas").join("resources");
let schemas_dir = workspace_root().join("schemas");
let out_dir = schemas_dir.join("resources");
let lenient_dir = schemas_dir.join("resources-lenient");
fs::create_dir_all(&out_dir).expect("create schemas/resources dir");
fs::create_dir_all(&lenient_dir).expect("create schemas/resources-lenient dir");

// Every resource with a runtime validator goes through the SAME
// `resource_root_schema(name, strict: true)` producer the strict
Expand All @@ -44,8 +68,10 @@ fn main() {
// STRICT shape: they document the declarative write contract (unknown
// fields are rejected by `aisix validate` and the file source wherever
// a resource closes them) and the
// etcd loader's lenient read tolerance is a runtime behavior, not a
// contract callers may write against.
// etcd loader's lenient read tolerance is published beside them, as
// `schemas/resources-lenient/`, from the same producer with
// `strict: false` — the exact value `LENIENT_SCHEMAS` compiles, so the
// published read contract cannot drift from the enforced one either.
// `ensemble`/`rate_limit`/`routing` have no standalone validator (they
// are nested struct types) so they dump straight from the struct via
// `schema_for!`, closed the same way.
Expand All @@ -55,23 +81,39 @@ fn main() {
resource,
schema::resource_root_schema(resource, true),
);
dump_value(
&lenient_dir,
resource,
schema::resource_root_schema(resource, false),
);
}

dump::<EnsembleConfig>(&out_dir, "ensemble");
dump::<RateLimit>(&out_dir, "rate_limit");
dump::<Routing>(&out_dir, "routing");
dump::<Semantic>(&out_dir, "semantic");
dump::<EmbeddingConfig>(&out_dir, "embedding");
dump::<EnsembleConfig>(&out_dir, &lenient_dir, "ensemble");
dump::<RateLimit>(&out_dir, &lenient_dir, "rate_limit");
dump::<Routing>(&out_dir, &lenient_dir, "routing");
dump::<Semantic>(&out_dir, &lenient_dir, "semantic");
dump::<EmbeddingConfig>(&out_dir, &lenient_dir, "embedding");
}

fn dump<T: JsonSchema>(out_dir: &Path, name: &str) {
fn dump<T: JsonSchema>(out_dir: &Path, lenient_dir: &Path, name: &str) {
let mut root = schemars::schema_for!(T);

// The lenient twin comes off the SAME producer, run through
// `schema::open_unknown_fields` — the pass `LENIENT_SCHEMAS` compiles the
// resource roots with — before the closing pass below runs. These nested
// types have no standalone validator on either path, so neither file is a
// contract; the pair documents the struct's shape under each strictness,
// and the enforced copy is the embedding resource's `definitions` entry.
let mut lenient = serde_json::to_value(&root).expect("serialize schema");
schema::open_unknown_fields(&mut lenient);
dump_value(lenient_dir, name, lenient);

// Serialize the `RootSchema` directly to preserve schemars' native key
// ordering. (Routing through `serde_json::Value` would re-sort keys.)
// These nested types belong to closed resources, so re-close the root
// and every struct-shaped definition on the typed schema — the same
// strictness `schema::close_unknown_fields` applies to the resource
// documents, kept typed here so the key order stays schemars-native.
let mut root = schemars::schema_for!(T);
close_object_schema(&mut root.schema);
for def in root.definitions.values_mut() {
if let schemars::schema::Schema::Object(obj) = def {
Expand Down
8 changes: 4 additions & 4 deletions crates/aisix-core/src/models/policy_conditions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,10 @@ pub struct ConditionGroup {

/// A slot in a condition list: leaf or nested group. Untagged — the
/// shapes are disjoint (a leaf requires `dimension`/`operator`/`value`,
/// a group `logic`/`children`), and the schema closes both variants
/// against unknown fields in **both** validator sets because serde
/// silently swallows unknown fields inside untagged content (same
/// reasoning as `OnEmbeddingFailure` in the model schema).
/// a group `logic`/`children`), and the write contract rejects unknown
/// fields inside either variant, because an unknown field placed there
/// is otherwise ignored without being reported (same reasoning as
/// `OnEmbeddingFailure` in the model schema).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(untagged)]
pub enum ConditionNode {
Expand Down
Loading