From d9c8531ced2ecb709f62a89f2284bbec038bc6fa Mon Sep 17 00:00:00 2001 From: Shreemaan Abhishek Date: Tue, 10 Nov 2026 18:12:04 +0800 Subject: [PATCH 1/2] feat: first version of aisix-controller (#1) * feat: add ProviderKey, Model and APIKey custom resource types * feat: generate structural CRD schemas from aisix-core * feat: render custom resources into resources-file entries * feat: validate the resources file with the gateway's own pipeline * feat: add the reconcile loop and CLI * build: add deploy manifests, Dockerfile and Makefile * test: add the kind-based e2e suite * ci: run lint, unit tests, CRD drift check and e2e * docs: add README --- .dockerignore | 4 + .github/workflows/ci.yaml | 44 + .gitignore | 1 + Cargo.lock | 3383 +++++++++++++++++++ Cargo.toml | 35 + Dockerfile | 16 + Makefile | 26 + README.md | 117 + deploy/controller/deployment.yaml | 50 + deploy/controller/kustomization.yaml | 5 + deploy/controller/rbac.yaml | 36 + deploy/crds/apikeys.aisix.api7.ai.yaml | 251 ++ deploy/crds/kustomization.yaml | 6 + deploy/crds/models.aisix.api7.ai.yaml | 533 +++ deploy/crds/providerkeys.aisix.api7.ai.yaml | 285 ++ deploy/kustomization.yaml | 5 + src/assemble.rs | 396 +++ src/crd.rs | 127 + src/lib.rs | 5 + src/main.rs | 86 + src/reconcile.rs | 667 ++++ src/render.rs | 377 +++ src/schema.rs | 892 +++++ tests/e2e/manifests/mock-openai.yaml | 76 + tests/e2e/manifests/resources.yaml | 43 + tests/e2e/run.sh | 185 + 26 files changed, 7651 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 deploy/controller/deployment.yaml create mode 100644 deploy/controller/kustomization.yaml create mode 100644 deploy/controller/rbac.yaml create mode 100644 deploy/crds/apikeys.aisix.api7.ai.yaml create mode 100644 deploy/crds/kustomization.yaml create mode 100644 deploy/crds/models.aisix.api7.ai.yaml create mode 100644 deploy/crds/providerkeys.aisix.api7.ai.yaml create mode 100644 deploy/kustomization.yaml create mode 100644 src/assemble.rs create mode 100644 src/crd.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/reconcile.rs create mode 100644 src/render.rs create mode 100644 src/schema.rs create mode 100644 tests/e2e/manifests/mock-openai.yaml create mode 100644 tests/e2e/manifests/resources.yaml create mode 100755 tests/e2e/run.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..061fba0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +target +.git +tests +deploy diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..543b448 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: fmt + run: cargo fmt --check + - name: clippy + run: cargo clippy --all-targets --locked -- -D warnings + - name: unit tests + run: cargo test --locked + - name: CRDs match aisix-core + run: make crds-check + + e2e: + needs: check + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: helm/kind-action@v1 + with: + install_only: true + - uses: azure/setup-helm@v4 + - name: e2e + run: tests/e2e/run.sh + env: + # The chart download falls back to `gh release download`. + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..902c8fa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3383 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aisix-controller" +version = "0.1.0" +dependencies = [ + "aisix-core", + "anyhow", + "clap", + "futures", + "hex", + "k8s-openapi", + "kube", + "pretty_assertions", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "thiserror 2.0.21", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "aisix-core" +version = "0.0.0" +source = "git+https://github.com/api7/aisix?tag=v1.9.0#7e070f0bde3f653c3b197ed18c5b9205b866a4c5" +dependencies = [ + "anyhow", + "arc-swap", + "chrono", + "chrono-tz", + "config", + "dashmap", + "hex", + "http", + "ipnet", + "jsonschema", + "once_cell", + "regex", + "ring", + "rustix", + "schemars 0.8.22", + "serde", + "serde_ignored", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "tokio", + "url", + "uuid", + "yaml-rust2", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "arc-swap" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db4c94f57a570fce5d381be3289b3f79aef6332d72948fe82ab5772e423ccf92" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "nom", + "pathdiff", + "serde", + "serde_json", + "toml", + "yaml-rust2", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00371d2607edea062396ef1ce6ade65c8e915ac7a1277daa198208420f682c6" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "granit-parser" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f05586d4d4df3d47123207826c40b252728b438a1a07bad3c01ee6bea401df" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14ed8250fe4472173a765b89fea4b46f2c4aa12adc12a3e310f1dc6c108e041b" +dependencies = [ + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecfc96f3a325d3a9b0ee6db9a81a83b40b94e781021c044a0a7f4c4630a1e482" +dependencies = [ + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9522de39a5298859d9e844b660eb7d5e6fd1b63b9953f51535408a62f2d31b3" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonpath-rust" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c4668c489390e66d586fe2238ee8568473f7ac1607b411342f787c5e1bedd7" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonschema" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42a11b47492e6a2cc76e9c7bcc8077776cb31dac444e0795d7f61bcc6441c08" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "107aeb6ff14b8deb5610096a8ce5c554ec89806b521147eaeb3cae05b07edb7a" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e281d6b177bb4834f2ffd6f92aa0443cffe29c996b251c8d94eb6f456186feba" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "memchr", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + +[[package]] +name = "k8s-openapi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab0d13801242174b1f3966907c3f32039f2d6b36c7ce82c61225d66ed5d1eb70" +dependencies = [ + "base64 0.22.1", + "jiff", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73b082f549411623a4f8949bd24dcaedacb1511379c461c76d579fde301d021" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae56b223bca490db1f1b115bfc606d411177511d7748d8cc019a7e3b2935430b" +dependencies = [ + "base64 0.23.1", + "bytes", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jiff", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "percent-encoding", + "rustls", + "rustls-platform-verifier", + "secrecy", + "serde", + "serde-saphyr", + "serde_json", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6de84b47fc1aa35ba0ef9059ef54b47c8b47f0979afd15487e525282f2d73fb9" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "json-patch", + "k8s-openapi", + "schemars 1.2.3", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "kube-derive" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de1fb46ac34371491ac0277c13e0e1bb9288000e64c86ca3d91343754818e64e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.9", +] + +[[package]] +name = "kube-runtime" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dea60005024b6ec1819c426209aa29b9eaca64f6b3988ab73c55f43fa484392" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "backon", + "educe", + "futures", + "hashbrown 0.16.1", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mime" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" +dependencies = [ + "memchr", + "pest_stack_guard", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "pest_meta" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" +dependencies = [ + "pest", +] + +[[package]] +name = "pest_stack_guard" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac6154fd4418b8f0b82532c59c26f303f9ca59a64c883e8ce3971fd82733439" +dependencies = [ + "psm", + "stacker", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "referencing" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42818036d3b37cda6bd4c494543158a36e431cb717015ef7c93acc6ba214d3b" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "chrono", + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive 1.2.3", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.29.1", + "syn 2.0.119", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.30.0", + "syn 3.0.9", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43fb82d550b07e6f929a58d7d0935182602f48ddead875f5ee313c41c8f07fe" +dependencies = [ + "annotate-snippets", + "base64 0.23.1", + "encoding_rs_io", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "base64 0.22.1", + "bitflags", + "bytes", + "http", + "http-body", + "mime", + "percent-encoding", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a06f08a856b009bc7bbaf7d3e79808595d032dd19074c0c2ef26e49e03e6bdc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..942546b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "aisix-controller" +version = "0.1.0" +edition = "2021" +rust-version = "1.93" +license = "Apache-2.0" +description = "Kubernetes controller that reconciles AISIX custom resources into an AISIX gateway" +publish = false + +[dependencies] +# The gateway's own types, schemas and resources-file pipeline. Bump the tag +# together with AISIX_VERSION in the Makefile and the e2e chart version. +aisix-core = { git = "https://github.com/api7/aisix", tag = "v1.9.0" } + +anyhow = "1" +clap = { version = "4", features = ["derive", "env"] } +futures = "0.3" +k8s-openapi = { version = "0.29", features = ["v1_33"] } +kube = { version = "5", default-features = false, features = ["client", "config", "runtime", "derive", "rustls-tls", "ring"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +hex = "0.4" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "sync"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +[dev-dependencies] +pretty_assertions = "1" + +[profile.release] +lto = "thin" +strip = true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f7b9fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 +FROM rust:1.93-bookworm AS build +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/src/target \ + cargo build --release --locked \ + && cp target/release/aisix-controller /aisix-controller + +FROM gcr.io/distroless/cc-debian12:nonroot +COPY --from=build /aisix-controller /aisix-controller +USER 65532:65532 +ENTRYPOINT ["/aisix-controller"] +CMD ["run"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..64ce848 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +IMAGE ?= aisix-controller:dev + +.PHONY: build test lint crds crds-check image e2e + +build: + cargo build + +test: + cargo test --locked + +lint: + cargo fmt --check + cargo clippy --all-targets --locked -- -D warnings + +# Regenerate deploy/crds from aisix-core's schemas. +crds: + cargo run -q --locked -- crds + +crds-check: crds + git diff --exit-code deploy/crds + +image: + docker build -t $(IMAGE) . + +e2e: + IMAGE=$(IMAGE) tests/e2e/run.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..b093996 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# aisix-controller + +A Kubernetes controller for the [AISIX](https://github.com/api7/aisix) AI gateway. It reconciles AISIX custom resources into the gateway's standalone resources file, so models, provider keys and caller API keys are managed with `kubectl` and GitOps. + +v0.1 covers the request path: `ProviderKey`, `Model` and `APIKey`. + +## How it works + +``` +ProviderKey / Model / APIKey + Secrets + │ watch + â–¼ +aisix-controller ── render ── validate with the gateway's own pipeline + │ + ├─► Secret aisix-resources (resources.yaml, standalone format v1) + └─► Deployment aisix (pod-template hash annotation → rollout) +``` + +1. The controller watches the three resource kinds, the Secrets they reference, and the gateway Deployment in its namespace. +2. It renders every resource into one `resources.yaml` and checks it with `aisix_core::filesource::load_from_str`, the same code the gateway runs at boot. +3. A resource the gateway would reject is reported on its status, and the version currently served stays in the file. A resource with no served version is left out. The others still apply. +4. When the document changes, the controller writes it to the gateway's resources Secret and stamps its hash on the Deployment's pod template, which rolls the pods. + +Status conditions on every resource: + +| Condition | True when | +|---|---| +| `Accepted` | The current spec is valid. | +| `Programmed` | The gateway serves the current spec (the rollout carrying it finished). | + +## Resources + +The spec is the AISIX resource document, with the gateway's own field names. Credentials and references are replaced by Kubernetes references: + +| Kind | Replaced field | Kubernetes field | +|---|---|---| +| `ProviderKey` | `api_key` / `secret` | `api_key_secret_ref: {name, key}` | +| `Model` | `provider_key_id` | `provider_key_ref: {name}` (a ProviderKey's `metadata.name`) | +| `APIKey` | `key_hash` | `key_secret_ref: {name, key}` (plaintext; the controller writes only its SHA-256) | + +`display_name` defaults to `metadata.name`. Set it when the AISIX name is not a valid Kubernetes name, for example a model called `openai/gpt-4o`. Id-form references (`model_id`, `allowed_model_ids`, ...) are not accepted; use the name forms. + +```yaml +apiVersion: aisix.api7.ai/v1alpha1 +kind: ProviderKey +metadata: + name: openai +spec: + provider: openai + api_key_secret_ref: {name: llm-creds, key: openai} +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: Model +metadata: + name: gpt-4o +spec: + provider: openai + model_name: gpt-4o + provider_key_ref: {name: openai} +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: APIKey +metadata: + name: team-a +spec: + allowed_models: ["gpt-4o"] + key_secret_ref: {name: caller-keys, key: team-a} +``` + +`kubectl get providerkeys,models.aisix.api7.ai,apikeys` shows `Accepted` and `Programmed` columns. + +## Schemas + +The CRD schemas in `deploy/crds` are generated from aisix-core's strict resource schemas (`resource_root_schema`), pinned by the `aisix-core` git tag in `Cargo.toml`: + +```bash +make crds # regenerate +make crds-check # CI: fail when deploy/crds is stale +``` + +The generator converts aisix-core's draft-07 schemas to structural OpenAPI. Types, required fields, enums, ranges, lengths and patterns carry over. Cross-field rules that a structural schema cannot express (for example "a direct model cannot also route") are dropped from the CRD and enforced by the controller's validation step, which reports them on `Accepted`. + +## Install + +The controller and the gateway run in the same namespace. Install the controller first, so the resources Secret exists when the gateway starts. + +```bash +kubectl create namespace aisix +kubectl apply --server-side -k deploy/crds +kubectl -n aisix apply -k deploy/controller # set the image with a kustomize overlay + +helm repo add api7 https://charts.api7.ai +helm install aisix api7/aisix -n aisix \ + --set controlPlane.enabled=false \ + --set standalone.existingSecret=aisix-resources \ + --set fullnameOverride=aisix +``` + +Controller flags: `--namespace` (defaults to its own), `--secret-name` (`aisix-resources`), `--secret-key` (`resources.yaml`), `--deployment-name` (`aisix`), `--resync-seconds` (`60`). + +## Development + +```bash +make test # unit tests +make lint # fmt + clippy +make e2e # kind cluster + AISIX chart + controller + mock upstream +KEEP=1 make e2e # keep the cluster afterwards +``` + +The e2e suite needs Docker, kind, kubectl and helm. It covers: resources served end to end (including a `$` in a credential), updates, CRD field validation, a gateway-rejected change keeping the served version, a dangling reference, and key revocation. + +## v0.1 limitations + +- Every configuration change rolls the gateway pods, because standalone AISIX reloads only on SIGHUP. Node-local rate-limit counters reset on each rollout. A push endpoint in AISIX would remove this. +- One namespace, one gateway Deployment and one controller replica (no leader election). +- Three kinds. Guardrails, MCP servers, A2A agents, cache and rate-limit policies, OIDC providers, claim mappings, passthrough routes and exporters come next. +- No admission webhook: invalid resources are accepted by the API server and reported on status. diff --git a/deploy/controller/deployment.yaml b/deploy/controller/deployment.yaml new file mode 100644 index 0000000..4ac40f5 --- /dev/null +++ b/deploy/controller/deployment.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aisix-controller + labels: + app.kubernetes.io/name: aisix-controller +spec: + # One writer per gateway; no leader election in v0.1. + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: aisix-controller + template: + metadata: + labels: + app.kubernetes.io/name: aisix-controller + spec: + serviceAccountName: aisix-controller + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: controller + image: aisix-controller:dev + imagePullPolicy: IfNotPresent + args: + - run + - --secret-name=aisix-resources + - --deployment-name=aisix + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: RUST_LOG + value: info,kube=warn + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] diff --git a/deploy/controller/kustomization.yaml b/deploy/controller/kustomization.yaml new file mode 100644 index 0000000..4b14a26 --- /dev/null +++ b/deploy/controller/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - rbac.yaml + - deployment.yaml diff --git a/deploy/controller/rbac.yaml b/deploy/controller/rbac.yaml new file mode 100644 index 0000000..62c8a2f --- /dev/null +++ b/deploy/controller/rbac.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: aisix-controller +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: aisix-controller +rules: + - apiGroups: ["aisix.api7.ai"] + resources: ["providerkeys", "models", "apikeys"] + verbs: ["get", "list", "watch"] + - apiGroups: ["aisix.api7.ai"] + resources: ["providerkeys/status", "models/status", "apikeys/status"] + verbs: ["get", "patch", "update"] + # Reads the Secrets that resources reference; writes the resources Secret. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "patch", "update"] + # Stamps the resources hash on the gateway pod template to roll it out. + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: aisix-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: aisix-controller +subjects: + - kind: ServiceAccount + name: aisix-controller diff --git a/deploy/crds/apikeys.aisix.api7.ai.yaml b/deploy/crds/apikeys.aisix.api7.ai.yaml new file mode 100644 index 0000000..273f408 --- /dev/null +++ b/deploy/crds/apikeys.aisix.api7.ai.yaml @@ -0,0 +1,251 @@ +# Generated by `aisix-controller crds` from aisix-core. Do not edit. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: apikeys.aisix.api7.ai +spec: + group: aisix.api7.ai + names: + kind: APIKey + plural: apikeys + shortNames: + - aikey + singular: apikey + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .status.conditions[?(@.type=="Programmed")].status + name: Programmed + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: AISIX APIKey resource. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + allowed_agents: + description: 'A2A agents this key may reach, named by their registered names. Entries are matched as single-`*` globs, mirroring `allowed_models`: `"*"` grants every agent and an entry without a `*` matches one agent exactly. When omitted, set to `null`, or set to an empty list, the key has no A2A agent access — access is granted explicitly.' + items: + type: string + nullable: true + type: array + allowed_models: + description: Model names this key may use, matched as single-`*` globs. Read only when `allowed_model_ids` is absent; ignored entirely when it is present. When both are omitted the key may use no model — model access is granted explicitly. + items: + type: string + type: array + allowed_routes: + description: 'Passthrough routes this key may use, named by their registered names. Entries are matched as single-`*` globs, mirroring `allowed_models`: `"*"` grants every route and an entry without a `*` matches one route exactly. When omitted, set to `null`, or set to an empty list, the key may use no passthrough route — access is granted explicitly.' + items: + type: string + nullable: true + type: array + disabled: + description: Administratively disabled. A disabled key is rejected with `401` until it is enabled again; the key itself is preserved. Treated as `false` when omitted. + type: boolean + display_name: + description: Operator-facing label for this key, as shown in the dashboard. Read by the `${request.api_key.name}` header template (AISIX-Cloud#1112) and as the `api_key_name` metric label; never used for authentication or routing. Defaults to `metadata.name`. + nullable: true + type: string + expires_at: + description: RFC 3339 timestamp after which the key stops authenticating. Requests presenting an expired key are rejected with `401`. When omitted or set to `null`, the key never expires. + nullable: true + type: string + jwt_provider: + description: Name of the `oidc_providers` entry permitted to assert this key's `jwt_subject`. A subject is only ever resolved for the trust provider named here, so a second trusted provider cannot mint a token impersonating this provider's identity of the same name. Required whenever `jwt_subject` is set; ignored otherwise. + minLength: 1 + nullable: true + type: string + jwt_subject: + description: External identity bound to this key for JWT authentication. When a request presents a valid JWT issued by the `oidc_providers` entry named in `jwt_provider`, the value of that provider's `identity_claim` selects the key whose `jwt_subject` equals it, and the request proceeds with this key's permissions, rate limits, and budget. The `(jwt_provider, jwt_subject)` pair is unique within the environment. When omitted, the key is never selected by JWT authentication. + minLength: 1 + nullable: true + type: string + key_secret_ref: + description: Secret key holding the caller's plaintext API key. The controller writes only its SHA-256 hash. + properties: + key: + description: Key within the Secret. + minLength: 1 + type: string + name: + description: Secret name, in the resource's namespace. + minLength: 1 + type: string + required: + - name + - key + type: object + mcp_access: + description: 'This key''s own layer of the MCP tool ACL, as namespaced `__` glob patterns, or as `allow_ids` / `deny_ids` entries naming the server by resource id. It is intersected with the environment and team MCP access policies: every present layer must allow a tool and no layer may deny it. When omitted the key adds no constraint of its own — but with no layer present anywhere the grant is empty, so MCP access is always granted explicitly.' + nullable: true + properties: + allow: + description: |- + Namespaced `__` patterns this key allows, intersected with the environment and team layers. Same single-`*` glob matching a policy's `allow` uses; an empty list leaves the key no MCP access, and `["*"]` narrows nothing (useful with `deny` alone). + + Required on the write path and defaulted by the runtime loader, for the reason given on [`McpPolicy::allow`]. Read only when `allow_ids` is absent; ignored entirely when it is present. + items: + type: string + type: array + deny: + description: Namespaced `__` patterns subtracted from this key's effective grant, using the same single-`*` glob matching as `allow`. Read only when `deny_ids` is absent. + items: + type: string + type: array + type: object + mcp_rate_limits: + additionalProperties: + description: |- + Limits for one API key's calls to one MCP server (`ApiKey::mcp_rate_limits`). + + Carries only the request-count dimensions of [`RateLimit`]: an MCP `tools/call` commits no tokens, so a token-rate cap here would never be consumed — the shape leaves `tpm`/`tpd` out rather than accepting a knob that is silently inert. + properties: + concurrency: + description: Max concurrent in-flight tool calls. + minimum: 0.0 + nullable: true + type: integer + rpd: + description: Tool calls per 86,400-second window. + minimum: 0.0 + nullable: true + type: integer + rph: + description: Tool calls per 3,600-second window. + minimum: 0.0 + nullable: true + type: integer + rpm: + description: Tool calls per 60-second window. + minimum: 0.0 + nullable: true + type: integer + rps: + description: Tool calls per 1-second window. + minimum: 0.0 + nullable: true + type: integer + type: object + description: |- + Per-MCP-server limits for this key, keyed by the registered MCP server name — the `` half of the `__` names the gateway exposes. A `tools/call` is metered against the entry for the server it targets **and** the key's own `rate_limit`, each in its own counter, so a burst against one server never consumes another's budget. A server with no entry here is bounded by `rate_limit` alone. Only tool calls are metered; the `initialize` / `tools/list` handshake is not. + + Read only when `mcp_rate_limits_by_id` is absent; ignored entirely when it is present. + nullable: true + type: object + rate_limit: + description: Request, token, and concurrency limits for this key. + nullable: true + properties: + concurrency: + description: Max concurrent in-flight requests. + minimum: 0.0 + type: integer + rpd: + description: Requests per 86,400-second window. + minimum: 0.0 + type: integer + rph: + description: Requests per 3,600-second window. There is no per-hour token limit field. + minimum: 0.0 + type: integer + rpm: + description: Requests per 60-second window. + minimum: 0.0 + type: integer + rps: + description: Requests per 1-second window. There is no per-second token limit field. + minimum: 0.0 + type: integer + tpd: + description: Tokens per 86,400-second window. + minimum: 0.0 + type: integer + tpm: + description: Tokens per 60-second window. + minimum: 0.0 + type: integer + type: object + tags: + additionalProperties: + maxLength: 128 + minLength: 1 + type: string + description: 'Cost-attribution tags, as tag key to tag value — for example `{"department": "rnd", "subsidiary": "emea"}`. Every request this key authenticates is attributed to one value per tag key, resolved when the request is made with the nearest setting winning: this key''s own value, else the value in the `tags` of the team `team_id` names, else the value in the `tags` of the member `user_id` names. Different keys combine, so a request can carry tags from all three. A request authenticated by a JWT takes the value its identity provider''s `tag_claims` reads from the verified token ahead of all three; nothing else in a request sets a tag. Keys match `^[a-z][a-z0-9_]{0,62}$`, values are 1 to 128 characters, and at most 32 entries are allowed. Omitted or empty means the key adds no tags of its own.' + maxProperties: 32 + type: object + team_id: + description: Team this API key belongs to. Used for matching team-scope rate limit policies. + minLength: 1 + nullable: true + type: string + user_id: + description: Org member who owns this key. Used for matching member-scope rate limit policies. + minLength: 1 + nullable: true + type: string + user_name: + description: Readable display name of the owning member. Used only for telemetry labels alongside `user_id`; never used for authentication or routing. Read only when no `users` document exists for `user_id`; when one does, its name is used instead. When neither provides a name, telemetry reports the user name as `"unknown"`. + nullable: true + type: string + required: + - key_secret_ref + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + reason: + type: string + status: + enum: + - 'True' + - 'False' + - Unknown + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/kustomization.yaml b/deploy/crds/kustomization.yaml new file mode 100644 index 0000000..db7ebea --- /dev/null +++ b/deploy/crds/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - providerkeys.aisix.api7.ai.yaml + - models.aisix.api7.ai.yaml + - apikeys.aisix.api7.ai.yaml diff --git a/deploy/crds/models.aisix.api7.ai.yaml b/deploy/crds/models.aisix.api7.ai.yaml new file mode 100644 index 0000000..806bac3 --- /dev/null +++ b/deploy/crds/models.aisix.api7.ai.yaml @@ -0,0 +1,533 @@ +# Generated by `aisix-controller crds` from aisix-core. Do not edit. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: models.aisix.api7.ai +spec: + group: aisix.api7.ai + names: + kind: Model + plural: models + shortNames: + - aimodel + singular: model + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .status.conditions[?(@.type=="Programmed")].status + name: Programmed + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: AISIX Model resource. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + allowed_cidrs: + description: Client IP allowlist in CIDR notation. Empty or absent allows all clients. + items: + minLength: 1 + type: string + type: array + auto_prompt_caching: + description: Automatic prompt-cache breakpoint injection for Claude models on the Anthropic API, Amazon Bedrock and Vertex AI. Omit to leave injection off. + properties: + enabled: + description: Whether automatic prompt-cache injection is active for this model. + type: boolean + ttl: + description: 'Cache lifetime for injected breakpoints: `5m` (default when omitted) or `1h`. A `1h` cache write costs 2x the base input rate versus 1.25x for `5m`, so it pays off only when the cached prefix is reused across a longer session. Some Claude models on Amazon Bedrock accept only the 5-minute cache, and Bedrock rejects a request to such a model that asks for `1h`.' + enum: + - 5m + - 1h + type: string + required: + - enabled + type: object + background_model_check: + description: Direct-model-only background health-check configuration. + properties: + enabled: + description: Whether background health checks are enabled for this model. + type: boolean + ignore_statuses: + description: Upstream status codes to ignore when evaluating background check failures. + items: + maximum: 599.0 + minimum: 100.0 + type: integer + type: array + interval_seconds: + description: 'Seconds between background health checks. Minimum: 5.' + minimum: 5.0 + type: integer + max_tokens: + description: Maximum completion tokens requested during each background health check. + minimum: 1.0 + type: integer + prompt: + description: Prompt sent to the model during each background health check. + minLength: 1 + type: string + stale_after_seconds: + description: Seconds after which the last completed background check is considered stale. + minimum: 1.0 + type: integer + timeout_seconds: + description: 'Request timeout in seconds for each background health check. Minimum: 1.' + minimum: 1.0 + type: integer + required: + - enabled + - interval_seconds + - max_tokens + - prompt + - stale_after_seconds + - timeout_seconds + type: object + cooldown: + description: 'Direct-model-only request-path cooldown configuration. Cooldown is opt-in: omit this field, or leave `enabled` unset, and the model is never taken out of rotation by request-path failures.' + properties: + default_seconds: + description: Cooldown TTL in seconds when the upstream did not supply a `Retry-After` header or `honor_retry_after` is `false`. + minimum: 0.0 + type: integer + enabled: + description: Whether cooldown is active for this model. Cooldown is off unless this is set to `true`, so a model that omits it stays in rotation regardless of upstream failures. + type: boolean + honor_retry_after: + description: Whether to use the upstream's `Retry-After` header as the cooldown TTL when it contains seconds. + type: boolean + max_seconds: + description: Upper bound on cooldown TTL when `Retry-After` is used. + minimum: 1.0 + type: integer + trigger_on_timeout: + description: Whether request-path timeouts trigger cooldown. + type: boolean + trigger_on_transport: + description: Whether transport, decode, or stream-abort errors trigger cooldown. + type: boolean + trigger_statuses: + description: Status codes that trigger cooldown, covering authentication failures, rate limits, and transient server errors. Caller-side validation errors such as `400`, `403`, and `422` are excluded. + items: + maximum: 599.0 + minimum: 100.0 + type: integer + type: array + type: object + cost: + description: Per-token cost for budget tracking. Omit it when cost tracking is not needed. + properties: + input_per_1k: + description: Prompt token cost in USD per 1,000 tokens. + minimum: 0.0 + type: number + output_per_1k: + description: Completion token cost in USD per 1,000 tokens. + minimum: 0.0 + type: number + required: + - input_per_1k + - output_per_1k + type: object + display_name: + description: Operator-facing unique label. Surfaces on `/v1/models`, `req.model` on chat completions, `ApiKey.allowed_models`, and the dashboard model list. `Resource::name()` returns this. Defaults to `metadata.name`. + minLength: 1 + type: string + effort_mapping: + description: |- + Direct-model-only mapping from a client-requested reasoning effort to the value sent upstream. After resolving the final target the gateway looks the requested effort up once — the exact entry first, then the `*` entry — and never looks the result up again. A requested value that matches neither is sent unchanged. + + Three entries are reserved. The empty-string key matches a request that sets no effort at all, meaning the effort field is absent, `null`, or empty, and its value is added to the outbound request. The `*` key matches any other present value that has no entry of its own, and never matches a request that sets no effort. A `null` value removes the effort field from the outbound request so the provider's own default applies; mapping `*` to `null` therefore strips the effort from every request whose value has no entry of its own. The empty-string key may not be mapped to `null`, because a request that sets no effort has nothing to remove, and no entry may be mapped to an empty value. + + On the Anthropic messages and token-counting endpoints only `output_config.effort` is read and rewritten. A `thinking` block is not an effort setting for this mapping, so a request that carries `thinking` but no `output_config.effort` sets no effort and the empty-string entry applies to it. When such a request is dispatched to a provider that does not accept the Anthropic protocol, the upstream reasoning effort is derived from the mapped `output_config.effort` when one is present and from `thinking` otherwise, except that an entry that removed the effort sends no effort at all. A request that sets `thinking.type: disabled` is never given an effort by this mapping: on the Anthropic protocol no entry writes a tier to it, though an entry that removes the effort still removes it, and on any other protocol it always sends the `none` effort. + + Claude families that reject `output_config.effort` (Haiku 4.5, Sonnet 4.5 and earlier) are the exception on those two endpoints when the model speaks the Anthropic protocol: an effort the empty-string entry would add to a request that sets none becomes `thinking: {type: enabled, budget_tokens}` instead (minimal and low 1024, medium 2048, high 4096, xhigh 8192, max 16384). The budget stays below the request's `max_tokens`, a request on the messages endpoint without `max_tokens` is sent the budget plus 4096, and a `max_tokens` of 1024 or less gets no thinking. A request that already carries a `thinking` block is sent as written, so the empty-string entry has no effect on it for these families, and a value with no budget is not sent at all. + properties: {} + type: object + embedding: + description: Embedding-modality metadata. Present on direct Models that serve an OpenAI-compatible `/v1/embeddings` endpoint (and can be referenced by a semantic router's `embedding_model`). + properties: + dimensions: + description: Output vector dimensionality. Used to validate vectors, key the example-vector cache, and (for endpoints that support it) request a reduced output size. + minimum: 1.0 + type: integer + normalize: + description: Whether the endpoint already returns L2-normalized vectors. When `false`, the gateway normalizes before computing cosine similarity. Defaults to `true`. + type: boolean + required: + - dimensions + type: object + ensemble: + description: Ensemble configuration for panel calls and judge synthesis. + properties: + judge: + description: Direct model that combines successful panel responses. + properties: + model: + description: Model alias for the direct model that synthesizes panel responses. Read only when `model_id` is absent. + minLength: 1 + type: string + synthesis_prompt: + description: Override for the built-in synthesis prompt template. + minLength: 1 + type: string + type: object + min_responses: + description: Minimum successful panel responses required before judge synthesis. When omitted, the gateway requires the smaller of 2 and the panel size. + minimum: 1.0 + type: integer + panel: + description: Direct models called concurrently for each ensemble request. + items: + description: One member of an ensemble panel. The member model is named either by `model` (its display name) or by `model_id` (its resource id); a member must carry at least one of the two. + properties: + model: + description: Model alias for a direct model that receives one panel request. Read only when `model_id` is absent. + minLength: 1 + type: string + seed: + description: Sampling seed for this panel member. + minimum: 0.0 + type: integer + temperature: + description: Sampling temperature for this panel member. Omit it to keep the request's temperature. + minimum: 0.0 + type: number + weight: + description: Reserved for a future voting/quorum strategy. AISIX currently ignores this field. + minimum: 0.0 + type: integer + type: object + minItems: 1 + type: array + timeout_ms: + description: Per-call upstream deadline applied to each panel member and the judge. Set `0` or omit it to disable the ensemble-level deadline. + minimum: 0.0 + type: integer + required: + - judge + - panel + type: object + model_name: + description: Upstream model identifier sent in provider requests. Routing and ensemble models leave this field unset. + minLength: 1 + type: string + pricing_key: + description: Name of a shared `pricing` document to take the per-token cost from, instead of setting `cost` on this model. The environment's own pricing documents are searched first and the shared catalog second; when the key matches neither, `cost` applies. Editing the pricing document repricies every model naming it, with no change to the models themselves. + maxLength: 255 + minLength: 1 + type: string + provider: + description: Upstream vendor identity used for dispatch, compatibility checks, telemetry, and access logs. Routing and ensemble models leave this field unset. + maxLength: 64 + minLength: 1 + pattern: ^[a-z0-9][a-z0-9._-]*$ + type: string + provider_key_ref: + description: ProviderKey, in the same namespace, this model authenticates with. Direct models need it; routing, ensemble and semantic models leave it unset. + properties: + name: + description: ProviderKey `metadata.name`. + minLength: 1 + type: string + required: + - name + type: object + rate_limit: + description: Request, token, and concurrency limits for this model. + properties: + concurrency: + description: Max concurrent in-flight requests. + minimum: 0.0 + type: integer + rpd: + description: Requests per 86,400-second window. + minimum: 0.0 + type: integer + rph: + description: Requests per 3,600-second window. There is no per-hour token limit field. + minimum: 0.0 + type: integer + rpm: + description: Requests per 60-second window. + minimum: 0.0 + type: integer + rps: + description: Requests per 1-second window. There is no per-second token limit field. + minimum: 0.0 + type: integer + tpd: + description: Tokens per 86,400-second window. + minimum: 0.0 + type: integer + tpm: + description: Tokens per 60-second window. + minimum: 0.0 + type: integer + type: object + retries: + description: Retry attempts against this model after a retryable upstream failure, before the request gives up (or, inside a model group, fails over to the next target). Absent falls back to the group's `routing.retries`, then to the deployment-wide `upstream.retries` default. + minimum: 0.0 + type: integer + routing: + description: Virtual routing configuration. When set, the gateway selects a target from `routing.targets` and uses that target model's `provider`, `model_name`, and `provider_key_id` fields for upstream dispatch. + properties: + fallback_on_statuses: + description: Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures. + items: + maximum: 599.0 + minimum: 400.0 + type: integer + type: array + hash_on: + description: 'Where the `consistent_hash` hash key comes from: an ordered chain of sources, the first non-empty value winning. Defaults to the `x-aisix-routing-key` request header, falling back to the caller''s API key id. Only valid with `strategy: consistent_hash`.' + items: + description: One source for the `consistent_hash` hash key. Sources are tried in order; the first one that yields a non-empty value wins. + properties: + name: + description: The header or cookie name to read. Required for `header` and `cookie` sources; not accepted for `api_key` or `client_ip`. + minLength: 1 + type: string + type: + description: Which request attribute supplies the hash key. + enum: + - header + - cookie + - api_key + - client_ip + type: string + required: + - type + type: object + minItems: 1 + type: array + max_fallbacks: + description: Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted. + minimum: 0.0 + type: integer + retries: + description: Retry attempts on the current target before failing over, applied to every target that does not set its own `retries`. Absent falls back to the deployment-wide `upstream.retries` default. + minimum: 0.0 + type: integer + retry_on_429: + description: Whether upstream 429 participates in retries and failover. + type: boolean + strategy: + description: Strategy used to select a target for each request. + enum: + - round_robin + - consistent_hash + - failover + - least_cost + - least_latency + - least_busy + type: string + targets: + description: Ordered set of direct models available to this routing model. + items: + description: One destination in a routing configuration. The target model is named either by `model` (its display name) or by `model_id` (its resource id); a target must carry at least one of the two. + properties: + model: + description: |- + Model alias for a direct model that can receive routed traffic. Read only when `model_id` is absent. + + Defaulted at the type level and required by the schemas instead — as one half of a "`model` or `model_id`" alternative, so a target naming its model by id alone validates while one naming it neither way is still rejected. + minLength: 1 + type: string + priority: + description: Priority tier, default `0`; a higher value is preferred (the APISIX node-priority convention — give backup targets `-1`). The strategy orders targets within each tier; a lower tier is only tried when every higher-tier target failed or is unavailable. + type: integer + tags: + description: Tags for tag/metadata-conditional routing. When a request carries routing tags, only targets whose tags intersect the request's are eligible; a target tagged `"default"` is the fallback used when nothing matches and for untagged requests. Absent/empty means the target opts out of tag filtering (eligible only via the default fallback once any sibling target is tagged). The configured strategy then orders whatever set survives. + items: + minLength: 1 + type: string + type: array + weight: + description: Target weight, default `1`. Used by `round_robin` (rotation share), `consistent_hash` (share of the hash ring), and `least_busy` (in-flight divided by weight). `failover`, `least_cost`, and `least_latency` accept the field but do not use it. + minimum: 0.0 + type: integer + type: object + minItems: 1 + type: array + when_all_unavailable: + description: Policy to apply when every target is unavailable because of runtime health or cooldown state. + enum: + - fail + - try_anyway + type: string + required: + - targets + type: object + semantic: + description: Semantic-routing configuration. When set, the gateway embeds the request and dispatches to the route whose examples it matches best, using that route's target Model for upstream dispatch. + properties: + classifier: + description: Route selection by a hosted decision model instead of embeddings. When set, `embedding_model`, `embedding_model_id`, `embedding_timeout_ms`, `on_embedding_failure` and `match` are not accepted, every route needs a `description` and takes no `examples` or `threshold`. When omitted, routes are matched by embedding similarity. + properties: + min_confidence: + description: Lowest confidence at which the picked route is used. A decision below it sends the request to `default`. + maximum: 1.0 + minimum: 0.0 + type: number + model: + description: Decision model to call. + minLength: 1 + type: string + on_failure: + description: Behavior when the decision call fails, times out, or returns an answer that names no route. Same values as `on_embedding_failure`. + x-kubernetes-preserve-unknown-fields: true + timeout_ms: + description: Deadline for the decision call, in milliseconds. + minimum: 1.0 + type: integer + type: + description: Decision model family. `jev` is the only supported value. + enum: + - jev + type: string + required: + - type + type: object + default: + description: Direct model alias used when no route clears its threshold. Read only when `default_id` is absent. + minLength: 1 + type: string + embedding_model: + description: Alias of an `embedding`-modality Model used to embed the request and, lazily on the first request that needs them, the route examples. Read only when `embedding_model_id` is absent. Required, under this name or as `embedding_model_id`, when there is no `classifier`. + minLength: 1 + type: string + embedding_timeout_ms: + description: Per-call deadline for the embedding request in milliseconds. `0` or absent disables the embedding-specific deadline. + minimum: 0.0 + type: integer + match: + description: Shared matching parameters (metric, aggregation, default threshold). Required when there is no `classifier`. + properties: + aggregation: + description: 'Per-example score aggregation. v1: max.' + enum: + - max + type: string + distance_metric: + description: 'Similarity metric. v1: cosine.' + enum: + - cosine + type: string + threshold: + description: Default similarity threshold for routes that do not set their own `threshold`. Higher is stricter. + maximum: 1.0 + minimum: 0.0 + type: number + required: + - threshold + type: object + on_embedding_failure: + description: Behavior when the embedding call fails or times out. Defaults to routing to `default`. + x-kubernetes-preserve-unknown-fields: true + routes: + description: Routes evaluated for each request. At least one is required. + items: + description: 'One semantic route: a labeled set of example utterances whose embeddings define the route. A request that scores high enough against them dispatches to `target`.' + properties: + description: + description: 'What requests this route handles. With a `classifier`, this is the text the decision model reads to pick the route, and it is required. Without one, it is documentation only: routes are matched on `examples`.' + minLength: 1 + type: string + examples: + description: Example utterances that define this route. AISIX caches their vectors lazily. A cold route request passes its prompt and every distinct uncached example text as one logical embedding batch. Providers without batch support may issue multiple upstream calls (Bedrock Titan does). Required, with at least one example, when the router has no `classifier`; not accepted when it has one. + items: + minLength: 1 + type: string + minItems: 1 + type: array + name: + description: Operator-facing route label. Surfaced in access logs (e.g. `prod-chat -> route:legal`), and in the `x-aisix-route` response header only when `proxy.expose_debug_headers` is enabled. + minLength: 1 + type: string + target: + description: Direct model alias that receives traffic matching this route. Read only when `target_id` is absent. + minLength: 1 + type: string + threshold: + description: Per-route similarity threshold. A request matches this route only when its aggregated score is `>=` this value. When omitted, the router-level threshold applies. Not accepted with a `classifier`. + maximum: 1.0 + minimum: 0.0 + type: number + required: + - name + type: object + minItems: 1 + type: array + required: + - routes + type: object + stream_timeout: + description: Maximum gap in milliseconds between upstream streaming chunks. `0` or absent falls back to the group's `stream_timeout`, then to the model's (or group's) `timeout`, then to the deployment-wide `upstream.stream_timeout_ms` / `timeout_ms` defaults. + minimum: 0.0 + type: integer + timeout: + description: End-to-end timeout in milliseconds for non-streaming upstream calls. Absent falls back to the group's `timeout`, then to the deployment-wide `upstream.timeout_ms` default. `0` disables the non-streaming timeout for this model. + minimum: 0.0 + type: integer + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + reason: + type: string + status: + enum: + - 'True' + - 'False' + - Unknown + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/providerkeys.aisix.api7.ai.yaml b/deploy/crds/providerkeys.aisix.api7.ai.yaml new file mode 100644 index 0000000..3fc9969 --- /dev/null +++ b/deploy/crds/providerkeys.aisix.api7.ai.yaml @@ -0,0 +1,285 @@ +# Generated by `aisix-controller crds` from aisix-core. Do not edit. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: providerkeys.aisix.api7.ai +spec: + group: aisix.api7.ai + names: + kind: ProviderKey + plural: providerkeys + shortNames: + - aipk + singular: providerkey + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Accepted")].status + name: Accepted + type: string + - jsonPath: .status.conditions[?(@.type=="Programmed")].status + name: Programmed + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: AISIX ProviderKey resource. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + adapter: + description: Upstream API protocol family used when provider-specific dispatch is unavailable. + enum: + - openai + - anthropic + - bedrock + - vertex + - azure-openai + nullable: true + type: string + api_base: + description: Override base URL for the upstream provider. Required for custom or OpenAI-compatible providers that should not use a built-in vendor endpoint. + nullable: true + type: string + api_key_secret_ref: + description: Secret key holding the upstream provider credential. The controller writes it as `api_key`. + properties: + key: + description: Key within the Secret. + minLength: 1 + type: string + name: + description: Secret name, in the resource's namespace. + minLength: 1 + type: string + required: + - name + - key + type: object + apis: + description: |- + API surfaces this upstream serves natively, beyond the one its `adapter` already implies, and the base URL each one lives at. + + One upstream account often exposes more than one protocol, on different paths of the same host — an OpenAI-compatible `/v1/chat/completions` under `…/v1` and an Anthropic-compatible `/v1/messages` under `…/anthropic`, both authenticated by the same credential. `api_base` can only name one of them, so without this field every request the declared path cannot serve gets translated instead — losing whatever the target protocol carries that the canonical chat shape does not (prompt-cache breakpoints, thinking blocks). Declaring the second entry here lets each inbound protocol reach its own native path under the one credential. + + Each surface resolves on its own terms; see [`ProviderApis`]. Surfaces this map has no key for — embeddings, audio, images, videos, files/batches/fine-tuning, rerank — always use `api_base`, exactly as before. + nullable: true + properties: + messages: + description: Anthropic-wire `/v1/messages` (and `/v1/messages/count_tokens`). + properties: + base: + description: |- + Base URL this surface is served at. Omit when it is the same one `api_base` names — an entry with no `base` still carries the declaration that the surface exists. + + Deliberately NOT length-constrained, matching `api_base`. The lenient read schema keeps every constraint but the open-object one, so a `minLength` here would make an empty string skip the whole Provider Key row — and with it every model that references the key — where the same empty string on `api_base` is the control plane's documented way to clear an override. An empty value is treated as "no override" at resolution time instead. + type: string + type: object + responses: + description: OpenAI-wire `/v1/responses`. + properties: + base: + description: |- + Base URL this surface is served at. Omit when it is the same one `api_base` names — an entry with no `base` still carries the declaration that the surface exists. + + Deliberately NOT length-constrained, matching `api_base`. The lenient read schema keeps every constraint but the open-object one, so a `minLength` here would make an empty string skip the whole Provider Key row — and with it every model that references the key — where the same empty string on `api_base` is the control plane's documented way to clear an override. An empty value is treated as "no override" at resolution time instead. + type: string + type: object + type: object + display_name: + description: Operator-facing label, unique within the gateway. Surfaces in the Admin API list view and in dashboard UIs that wrap this resource. Defaults to `metadata.name`. + minLength: 1 + type: string + provider: + description: Upstream provider identifier, such as `"deepseek"`, `"openai"`, or a model catalog ID. The gateway uses this value for provider-specific dispatch and base URL validation. + type: string + request: + description: Per-key request-shape overrides applied by supported provider paths before dispatch to the upstream provider. + nullable: true + properties: + default_body_fields: + additionalProperties: + x-kubernetes-preserve-unknown-fields: true + description: '`apply_default_body_fields` input. Top-level body fields added when the caller did not set them. `serde_json::Map` preserves insertion order on serialize, matching the etcd round-trip.' + type: object + default_headers: + additionalProperties: + type: string + description: |- + Top-level headers added to the outbound request when the caller did not set them. Values may reference the request context with `${...}` variables, such as `"${request.api_key.team_id}"`; a header whose variables do not all resolve is dropped rather than sent blank. See [`crate::header_template`] for the closed variable vocabulary. + + "When the caller did not set them" includes the gateway itself: an entry naming the slot this ProviderKey's credential occupies is not applied. Use `forward_client_headers` to put the caller's own credential there instead. + type: object + forward_client_headers: + description: |- + Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`"anthropic-beta"`, `"x-trace-*"`, `"authorization"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167. + + A header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded. + + A header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from. + + A credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `"*"` or `"x-*"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry. + + Two cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature. + + Naming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one. + + Headers whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry. + items: + type: string + type: array + param_constraints: + description: Parameter constraints applied to the outbound request. If omitted, no clamping is applied. + properties: + temperature_max: + description: Upper bound for `temperature`. Values above this are clamped to this value. If omitted, no upper bound is applied. + type: number + temperature_min: + description: Lower bound for `temperature`. Values below this are clamped to this value. If omitted, no lower bound is applied. + type: number + type: object + param_renames: + additionalProperties: + type: string + description: '`apply_param_renames` input. Top-level body keys named on the left are renamed to the right. Leave empty to preserve request parameter names.' + type: object + type: object + resolve_addresses: + description: |- + IP addresses the gateway connects to for this key's `api_base` host, instead of resolving that host through DNS. Each entry is an IPv4 or IPv6 address literal, without a port and without brackets. + + Use this when the upstream is reached over a private link that has no DNS entry, while the provider still requires its own hostname in the request. Only the connection target changes: the `Host` header, the HTTP/2 `:authority`, the TLS server name and the certificate check all keep using the hostname from `api_base`, and the port and scheme keep coming from `api_base` too. + + Several addresses are tried in the order given, as a resolver's answer would be: the next one is attempted when a connection cannot be established, which is how a private link that terminates on one address per availability zone stays reachable when one is down. + + Scoped to the `api_base` hostname and nothing else. An `apis` entry that serves a second protocol from the same host is reached over the same link, because it is the same hostname; one that names a different host is resolved normally. A key with no `api_base`, or whose `api_base` is already an address literal, has no hostname to override and is dispatched unchanged. + + Honoured on every surface that dispatches through the Provider Key's own client: chat completions, completions, embeddings, images, audio, `/v1/messages` (and `count_tokens`), `/v1/responses`, rerank, videos, the files/batches/fine-tuning surface and `/passthrough/*`. Not honoured for Amazon Bedrock or `/v1/realtime`, which connect on their own transports — the same two that `tls` does not reach. + + Not applicable when the gateway reaches its upstreams through a forward proxy (`HTTPS_PROXY` / `ALL_PROXY` in the gateway's environment): the proxy is given the hostname and resolves it itself, so nothing here is consulted. + items: + pattern: ^[0-9A-Fa-f:.]+$ + type: string + minItems: 1 + nullable: true + type: array + response: + description: Per-key response-shape overrides applied by provider bridges that support response transformation. + nullable: true + properties: + content_list_to_string: + description: When `true`, the request-body `messages[*].content` array of text blocks gets flattened to a single string before dispatch. + type: boolean + error_envelope: + description: Stored error-envelope preference for compatibility with control-plane configuration. The proxy does not currently apply this field. + type: string + reasoning_field: + description: 'Path used to extract reasoning content from the provider response. If omitted or empty, no reasoning field is lifted. Example: `"delta.reasoning_content"`.' + type: string + stream_done_marker: + description: Stream `[DONE]` terminator expectation. If omitted, either presence or absence of the terminator is accepted. + enum: + - required + - optional + - none + type: string + type: object + strip_headers: + description: 'Inbound headers removed before passthrough forwarding. A passthrough route with `credential_mode: inject` also removes every credential slot, listed here or not.' + items: + type: string + type: array + telemetry_tags: + description: Telemetry tags carried alongside the key for metric and log emission. + properties: + branded_provider: + description: Branded provider slug for catalog entries, such as `"openai"` or `"anthropic"`. Bring-your-own providers leave this field unset. + nullable: true + type: string + byo_label: + description: Operator-defined label for bring-your-own entries, such as an internal team name. Catalog entries leave this field unset. + nullable: true + type: string + featured: + description: Whether this provider is surfaced in the featured list. + type: boolean + kind: + description: Provider-key category, such as `"catalog"` for curated providers or `"byo"` for bring-your-own providers. + enum: + - catalog + - byo + nullable: true + type: string + pk_label: + description: Operator-defined label for this provider key, such as `"production"` or `"shared-test"`. + nullable: true + type: string + type: object + tls: + description: TLS settings for connections to this key's `api_base`. Omit to use the gateway's deployment-wide trust settings. + nullable: true + properties: + ca_cert: + description: PEM-encoded certificate authority certificates trusted as issuers for this endpoint, in addition to the gateway's default trust store. A bundle containing several certificates is accepted. + type: string + verify: + description: Whether the endpoint's certificate is verified. Setting this to `false` accepts any certificate, including one presented by an intercepting party, and is intended only for test environments. + type: boolean + type: object + required: + - api_key_secret_ref + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + reason: + type: string + status: + enum: + - 'True' + - 'False' + - Unknown + type: string + type: + type: string + required: + - type + - status + - lastTransitionTime + - reason + - message + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml new file mode 100644 index 0000000..d054565 --- /dev/null +++ b/deploy/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - crds + - controller diff --git a/src/assemble.rs b/src/assemble.rs new file mode 100644 index 0000000..38a3daf --- /dev/null +++ b/src/assemble.rs @@ -0,0 +1,396 @@ +//! Rendered entries -> one validated resources file. +//! +//! The document is checked with the gateway's own pipeline +//! (`aisix_core::filesource::load_from_str`) before it is written. An entry +//! the gateway would reject is replaced by the version currently served, so +//! one bad resource never takes the others down. With no served version it is +//! left out. + +use std::collections::BTreeMap; + +use aisix_core::filesource::load_from_str; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::crd::{Kind, IDENTITY_FIELD}; +use crate::render::{Entry, Source, SourceKey}; + +pub const FORMAT_VERSION: &str = "1"; + +/// What happened to one custom resource. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Disposition { + /// Its current spec is in the document. + Applied, + /// Rejected; the previously served version stays in the document. + Retained(String), + /// Rejected, and not served at all. + Rejected(String), +} + +#[derive(Debug)] +pub struct Outcome { + /// The document to serve, or `None` when it could not be made valid. + pub document: Option, + pub dispositions: BTreeMap, + /// Set when the document as a whole failed. + pub error: Option, +} + +/// Entries of the currently served document, by collection and identity. +pub type Served = BTreeMap<(Kind, String), Entry>; + +/// Parse the served document. Anything unreadable counts as nothing served. +pub fn served_entries(document: Option<&str>) -> Served { + let mut out = Served::new(); + let Some(Ok(Value::Object(root))) = document.map(serde_json::from_str::) else { + return out; + }; + for kind in Kind::ALL { + let Some(Value::Array(items)) = root.get(kind.collection()) else { + continue; + }; + for item in items { + if let Some(id) = item.get(IDENTITY_FIELD).and_then(Value::as_str) { + out.insert((kind, id.replace("$$", "$")), item.clone()); + } + } + } + out +} + +pub fn document(entries: &[(Kind, Entry)]) -> String { + let mut root = Map::new(); + root.insert( + "_format_version".into(), + Value::String(FORMAT_VERSION.into()), + ); + for kind in Kind::ALL { + let items: Vec = entries + .iter() + .filter(|(k, _)| *k == kind) + .map(|(_, e)| e.clone()) + .collect(); + if !items.is_empty() { + root.insert(kind.collection().into(), Value::Array(items)); + } + } + let mut out = serde_json::to_string_pretty(&Value::Object(root)).expect("document serializes"); + out.push('\n'); + out +} + +pub fn hash(document: &str) -> String { + hex::encode(Sha256::digest(document.as_bytes())) +} + +/// Run the gateway's resources-file pipeline over a document. +/// Errors come back as `(scope, message)`. +pub fn check(document: &str) -> Result<(), Vec<(String, String)>> { + load_from_str(document, "aisix-controller", 1, &|_| None) + .map(|_| ()) + .map_err(|e| e.errors.into_iter().map(|e| (e.scope, e.message)).collect()) +} + +/// `models[2] ("gpt")` -> (Model, 2). +fn parse_scope(scope: &str) -> Option<(Kind, usize)> { + let (collection, rest) = scope.split_once('[')?; + let (index, _) = rest.split_once(']')?; + Some((Kind::from_collection(collection)?, index.parse().ok()?)) +} + +#[derive(Clone, Debug)] +enum State { + Current(Entry), + Served(Entry, String), + Dropped(String), +} + +pub fn assemble(rendered: Vec<(Source, Result)>, served: &Served) -> Outcome { + let fallback = |s: &Source, reason: String| match served.get(&(s.kind, s.identity())) { + Some(e) => State::Served(e.clone(), reason), + None => State::Dropped(reason), + }; + + let mut items: Vec<(Source, State)> = rendered + .into_iter() + .map(|(s, r)| { + let state = match r { + Ok(e) => State::Current(e), + Err(reason) => fallback(&s, reason), + }; + (s, state) + }) + .collect(); + + // Each round drops at least one entry, so this terminates. + let rounds = items.len() * 2 + 1; + for _ in 0..rounds { + let mut included: Vec<(Kind, Entry)> = Vec::new(); + let mut positions: BTreeMap<(Kind, usize), usize> = BTreeMap::new(); + let mut per_kind: BTreeMap = BTreeMap::new(); + for (i, (s, st)) in items.iter().enumerate() { + let entry = match st { + State::Current(e) | State::Served(e, _) => e, + State::Dropped(_) => continue, + }; + let n = per_kind.entry(s.kind).or_default(); + positions.insert((s.kind, *n), i); + *n += 1; + included.push((s.kind, entry.clone())); + } + let doc = document(&included); + let errors = match check(&doc) { + Ok(()) => return finish(items, Some(doc), None), + Err(errors) => errors, + }; + + let mut faults: BTreeMap> = BTreeMap::new(); + for (scope, message) in &errors { + match parse_scope(scope).and_then(|p| positions.get(&p)) { + Some(i) => faults.entry(*i).or_default().push(message.clone()), + None => { + let all = errors + .iter() + .map(|(s, m)| format!("{s}: {m}")) + .collect::>() + .join("; "); + return finish(items, None, Some(all)); + } + } + } + for (i, messages) in faults { + let reason = messages.join("; "); + let (s, st) = &mut items[i]; + *st = match st { + State::Current(_) => fallback(s, reason), + State::Served(_, earlier) => State::Dropped(format!( + "{earlier}; the previously served version is no longer valid either: {reason}" + )), + State::Dropped(r) => State::Dropped(r.clone()), + }; + } + } + finish( + items, + None, + Some("the resources file did not converge to a valid state".into()), + ) +} + +fn finish(items: Vec<(Source, State)>, document: Option, error: Option) -> Outcome { + let dispositions = items + .into_iter() + .map(|(s, st)| { + let d = match st { + State::Current(_) => Disposition::Applied, + State::Served(_, r) => Disposition::Retained(r), + State::Dropped(r) => Disposition::Rejected(r), + }; + (s.key(), d) + }) + .collect(); + Outcome { + document, + dispositions, + error, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::{render_all, Secrets}; + use serde_json::json; + + fn source(kind: Kind, name: &str, fields: Value) -> Source { + Source { + kind, + name: name.into(), + generation: Some(1), + created: None, + fields: fields.as_object().unwrap().clone(), + } + } + + fn secrets() -> Secrets { + let mut s = Secrets::new(); + s.insert( + "creds".into(), + BTreeMap::from([ + ("pk".into(), b"sk-test".to_vec()), + ("caller".into(), b"caller".to_vec()), + ]), + ); + s + } + + fn provider() -> Source { + source( + Kind::ProviderKey, + "mock", + json!({"provider": "openai", "adapter": "openai", "api_base": "http://mock:8080/v1", "api_key_secret_ref": {"name": "creds", "key": "pk"}}), + ) + } + + fn model(name: &str, provider_ref: &str) -> Source { + source( + Kind::Model, + name, + json!({"provider": "openai", "model_name": "upstream", "provider_key_ref": {"name": provider_ref}}), + ) + } + + fn api_key() -> Source { + source( + Kind::APIKey, + "caller", + json!({"allowed_models": ["*"], "key_secret_ref": {"name": "creds", "key": "caller"}}), + ) + } + + fn run(sources: &[Source], served: &Served) -> Outcome { + assemble(render_all(sources, &secrets()), served) + } + + #[test] + fn empty_document_is_valid() { + assert_eq!(check(&document(&[])), Ok(())); + } + + #[test] + fn valid_resources_produce_a_loadable_document() { + let out = run( + &[provider(), model("chat", "mock"), api_key()], + &Served::new(), + ); + let doc = out.document.expect("document"); + assert!(out.error.is_none()); + assert!(out + .dispositions + .values() + .all(|d| *d == Disposition::Applied)); + assert_eq!(check(&doc), Ok(())); + let v: Value = serde_json::from_str(&doc).unwrap(); + assert_eq!(v["_format_version"], json!("1")); + assert_eq!(v["models"][0]["provider_key"], json!("mock")); + } + + #[test] + fn gateway_rejection_is_attributed_to_the_resource() { + let bad = source( + Kind::Model, + "bad", + json!({"provider": "openai", "model_name": "x", "provider_key_ref": {"name": "mock"}, "timeout": "soon"}), + ); + let out = run(&[provider(), model("good", "mock"), bad], &Served::new()); + assert!(out.document.is_some()); + let key = SourceKey { + kind: Kind::Model, + name: "bad".into(), + }; + match &out.dispositions[&key] { + Disposition::Rejected(m) => assert!(m.contains("timeout"), "{m}"), + d => panic!("{d:?}"), + } + let good = SourceKey { + kind: Kind::Model, + name: "good".into(), + }; + assert_eq!(out.dispositions[&good], Disposition::Applied); + } + + #[test] + fn rejected_update_keeps_the_served_version() { + let first = run(&[provider(), model("chat", "mock")], &Served::new()); + let served = served_entries(first.document.as_deref()); + + let broken = source( + Kind::Model, + "chat", + json!({"provider": "openai", "model_name": "upstream", "provider_key_ref": {"name": "mock"}, "timeout": -1}), + ); + let out = run(&[provider(), broken], &served); + let key = SourceKey { + kind: Kind::Model, + name: "chat".into(), + }; + match &out.dispositions[&key] { + Disposition::Retained(m) => assert!(m.contains("timeout"), "{m}"), + d => panic!("{d:?}"), + } + assert_eq!(out.document, first.document); + } + + #[test] + fn dangling_reference_cascades_to_dependents() { + let broken_provider = source( + Kind::ProviderKey, + "mock", + json!({"api_key_secret_ref": {"name": "gone", "key": "pk"}}), + ); + let out = run(&[broken_provider, model("chat", "mock")], &Served::new()); + let pk = SourceKey { + kind: Kind::ProviderKey, + name: "mock".into(), + }; + let chat = SourceKey { + kind: Kind::Model, + name: "chat".into(), + }; + assert!(matches!(out.dispositions[&pk], Disposition::Rejected(_))); + match &out.dispositions[&chat] { + Disposition::Rejected(m) => assert!(m.contains("mock"), "{m}"), + d => panic!("{d:?}"), + } + assert_eq!(check(out.document.as_deref().unwrap()), Ok(())); + } + + #[test] + fn cross_field_rules_dropped_from_the_crd_are_still_enforced() { + let first = run(&[provider(), model("chat", "mock")], &Served::new()); + let served = served_entries(first.document.as_deref()); + + // A direct model that also routes: the CRD has no oneOf, the gateway does. + let both = source( + Kind::Model, + "chat", + json!({"provider": "openai", "model_name": "upstream", "provider_key_ref": {"name": "mock"}, + "routing": {"targets": [{"model": "chat"}]}}), + ); + let out = run(&[provider(), both], &served); + let key = SourceKey { + kind: Kind::Model, + name: "chat".into(), + }; + assert!( + matches!(out.dispositions[&key], Disposition::Retained(_)), + "{:?}", + out.dispositions + ); + assert_eq!(out.document, first.document); + } + + #[test] + fn deleted_resources_leave_the_document() { + let first = run(&[provider(), model("chat", "mock")], &Served::new()); + let served = served_entries(first.document.as_deref()); + let out = run(&[provider()], &served); + let v: Value = serde_json::from_str(out.document.as_deref().unwrap()).unwrap(); + assert!(v.get("models").is_none()); + } + + #[test] + fn scopes_parse() { + assert_eq!(parse_scope("models[2] (\"gpt\")"), Some((Kind::Model, 2))); + assert_eq!(parse_scope("api_keys[0]"), Some((Kind::APIKey, 0))); + assert_eq!(parse_scope("(file)"), None); + } + + #[test] + fn served_identities_are_unescaped() { + let doc = document(&[(Kind::Model, json!({"display_name": "a$$b"}))]); + let served = served_entries(Some(&doc)); + assert!(served.contains_key(&(Kind::Model, "a$b".into()))); + } +} diff --git a/src/crd.rs b/src/crd.rs new file mode 100644 index 0000000..5613af1 --- /dev/null +++ b/src/crd.rs @@ -0,0 +1,127 @@ +//! Custom resource types. +//! +//! A spec holds the AISIX resource document with its own (snake_case) field +//! names, plus a reference field that replaces the inline credential or id. +//! The schema is generated from aisix-core (see `schema.rs`), so the Rust +//! side keeps the spec as an untyped map. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub const GROUP: &str = "aisix.api7.ai"; +pub const VERSION: &str = "v1alpha1"; + +/// Pod-template annotation carrying the hash of the served resources file. +pub const HASH_ANNOTATION: &str = "aisix.api7.ai/resources-hash"; + +#[derive(CustomResource, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[kube( + group = "aisix.api7.ai", + version = "v1alpha1", + kind = "ProviderKey", + plural = "providerkeys", + shortname = "aipk", + namespaced, + status = "ResourceStatus", + schema = "disabled" +)] +pub struct ProviderKeySpec { + #[serde(flatten)] + pub fields: Map, +} + +#[derive(CustomResource, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[kube( + group = "aisix.api7.ai", + version = "v1alpha1", + kind = "Model", + plural = "models", + shortname = "aimodel", + namespaced, + status = "ResourceStatus", + schema = "disabled" +)] +pub struct ModelSpec { + #[serde(flatten)] + pub fields: Map, +} + +#[derive(CustomResource, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[kube( + group = "aisix.api7.ai", + version = "v1alpha1", + kind = "APIKey", + plural = "apikeys", + shortname = "aikey", + namespaced, + status = "ResourceStatus", + schema = "disabled" +)] +pub struct APIKeySpec { + #[serde(flatten)] + pub fields: Map, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ResourceStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conditions: Vec, +} + +/// The three resource kinds v0.1 reconciles. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Kind { + ProviderKey, + Model, + APIKey, +} + +impl Kind { + pub const ALL: [Kind; 3] = [Kind::ProviderKey, Kind::Model, Kind::APIKey]; + + /// Kubernetes kind name. + pub fn name(self) -> &'static str { + match self { + Kind::ProviderKey => "ProviderKey", + Kind::Model => "Model", + Kind::APIKey => "APIKey", + } + } + + /// Collection key in the resources file. + pub fn collection(self) -> &'static str { + match self { + Kind::ProviderKey => "provider_keys", + Kind::Model => "models", + Kind::APIKey => "api_keys", + } + } + + /// Resource name in aisix-core's schema registry. + pub fn schema_resource(self) -> &'static str { + match self { + Kind::ProviderKey => "provider_key", + Kind::Model => "model", + Kind::APIKey => "api_key", + } + } + + pub fn from_collection(collection: &str) -> Option { + Kind::ALL.into_iter().find(|k| k.collection() == collection) + } +} + +/// Identity field shared by all three kinds; defaults to `metadata.name`. +pub const IDENTITY_FIELD: &str = "display_name"; + +/// ProviderKey: Secret holding the upstream credential. +pub const API_KEY_SECRET_REF: &str = "api_key_secret_ref"; +/// Model: the ProviderKey this model authenticates with. +pub const PROVIDER_KEY_REF: &str = "provider_key_ref"; +/// APIKey: Secret holding the caller's plaintext key. +pub const KEY_SECRET_REF: &str = "key_secret_ref"; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0892901 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod assemble; +pub mod crd; +pub mod reconcile; +pub mod render; +pub mod schema; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..be8204a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,86 @@ +use std::path::PathBuf; +use std::time::Duration; + +use aisix_controller::crd::Kind; +use aisix_controller::reconcile::{self, Settings}; +use aisix_controller::schema; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + version, + about = "Reconciles AISIX custom resources into an AISIX gateway" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run the controller against the current cluster. + Run { + /// Namespace to watch. Defaults to the controller's own. + #[arg(long, env = "POD_NAMESPACE")] + namespace: String, + /// Secret the gateway reads its resources file from. + #[arg(long, default_value = "aisix-resources")] + secret_name: String, + /// Key of the resources file within that Secret. + #[arg(long, default_value = "resources.yaml")] + secret_key: String, + /// Gateway Deployment to roll out on change. + #[arg(long, default_value = "aisix")] + deployment_name: String, + /// Full resync interval, in seconds. + #[arg(long, default_value_t = 60)] + resync_seconds: u64, + }, + /// Write the CustomResourceDefinitions. + Crds { + #[arg(long, default_value = "deploy/crds")] + out: PathBuf, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,kube=warn".into()), + ) + .init(); + + match Cli::parse().command { + Command::Run { + namespace, + secret_name, + secret_key, + deployment_name, + resync_seconds, + } => { + let client = kube::Client::try_default().await?; + let settings = Settings { + namespace, + secret_name, + secret_key, + deployment_name, + resync: Duration::from_secs(resync_seconds), + }; + tokio::select! { + r = reconcile::run(client, settings) => r, + _ = tokio::signal::ctrl_c() => Ok(()), + } + } + Command::Crds { out } => { + std::fs::create_dir_all(&out)?; + for kind in Kind::ALL { + let path = out.join(schema::crd_file_name(kind)); + std::fs::write(&path, schema::crd_yaml(kind))?; + println!("{}", path.display()); + } + Ok(()) + } + } +} diff --git a/src/reconcile.rs b/src/reconcile.rs new file mode 100644 index 0000000..ea8340d --- /dev/null +++ b/src/reconcile.rs @@ -0,0 +1,667 @@ +//! The reconcile loop. +//! +//! The resources file is one document, so the controller reconciles the +//! whole namespace at once: any change to a custom resource, a Secret or the +//! gateway Deployment re-renders the document. A changed document is written +//! to the gateway's resources Secret and rolled out by stamping its hash on +//! the Deployment's pod template. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use futures::{stream, StreamExt}; +use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::api::core::v1::Secret; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time}; +use k8s_openapi::ByteString; +use kube::api::{Api, ObjectMeta, Patch, PatchParams}; +use kube::runtime::reflector::{self, Store}; +use kube::runtime::{watcher, WatchStreamExt}; +use kube::{Client, Resource, ResourceExt}; +use serde_json::json; +use tokio::sync::Notify; + +use crate::assemble::{assemble, document, hash, served_entries, Disposition, Outcome}; +use crate::crd::{APIKey, Kind, Model, ProviderKey, ResourceStatus, HASH_ANNOTATION}; +use crate::render::{render_all, secrets_from, Source, SourceKey}; + +pub const MANAGER: &str = "aisix-controller"; + +#[derive(Clone, Debug)] +pub struct Settings { + pub namespace: String, + pub secret_name: String, + pub secret_key: String, + pub deployment_name: String, + pub resync: Duration, +} + +struct Stores { + provider_keys: Store, + models: Store, + api_keys: Store, + secrets: Store, + deployments: Store, +} + +fn source(kind: Kind, obj: &K, fields: &serde_json::Map) -> Source +where + K: Resource, +{ + let meta = obj.meta(); + Source { + kind, + name: meta.name.clone().unwrap_or_default(), + generation: meta.generation, + created: meta.creation_timestamp.as_ref().map(|t| t.0.to_string()), + fields: fields.clone(), + } +} + +impl Stores { + fn sources(&self) -> Vec { + let mut out = Vec::new(); + out.extend( + self.provider_keys + .state() + .iter() + .map(|o| source(Kind::ProviderKey, o.as_ref(), &o.spec.fields)), + ); + out.extend( + self.models + .state() + .iter() + .map(|o| source(Kind::Model, o.as_ref(), &o.spec.fields)), + ); + out.extend( + self.api_keys + .state() + .iter() + .map(|o| source(Kind::APIKey, o.as_ref(), &o.spec.fields)), + ); + out + } +} + +/// Watch everything the document depends on and reconcile on every change, +/// plus a periodic resync. +pub async fn run(client: Client, settings: Settings) -> anyhow::Result<()> { + let ns = settings.namespace.as_str(); + let notify = Arc::new(Notify::new()); + + let (pk_store, pk_writer) = reflector::store(); + let (model_store, model_writer) = reflector::store(); + let (key_store, key_writer) = reflector::store(); + let (secret_store, secret_writer) = reflector::store(); + let (deploy_store, deploy_writer) = reflector::store(); + + let cfg = watcher::Config::default; + let deploy_cfg = cfg().fields(&format!("metadata.name={}", settings.deployment_name)); + + let streams = vec![ + watcher(Api::::namespaced(client.clone(), ns), cfg()) + .default_backoff() + .reflect(pk_writer) + .map(|_| ()) + .boxed(), + watcher(Api::::namespaced(client.clone(), ns), cfg()) + .default_backoff() + .reflect(model_writer) + .map(|_| ()) + .boxed(), + watcher(Api::::namespaced(client.clone(), ns), cfg()) + .default_backoff() + .reflect(key_writer) + .map(|_| ()) + .boxed(), + watcher(Api::::namespaced(client.clone(), ns), cfg()) + .default_backoff() + .reflect(secret_writer) + .map(|_| ()) + .boxed(), + watcher( + Api::::namespaced(client.clone(), ns), + deploy_cfg, + ) + .default_backoff() + .reflect(deploy_writer) + .map(|_| ()) + .boxed(), + ]; + let trigger = notify.clone(); + tokio::spawn(async move { + let mut events = stream::select_all(streams); + while events.next().await.is_some() { + trigger.notify_one(); + } + }); + + let stores = Stores { + provider_keys: pk_store, + models: model_store, + api_keys: key_store, + secrets: secret_store, + deployments: deploy_store, + }; + stores.provider_keys.wait_until_ready().await?; + stores.models.wait_until_ready().await?; + stores.api_keys.wait_until_ready().await?; + stores.secrets.wait_until_ready().await?; + stores.deployments.wait_until_ready().await?; + tracing::info!(namespace = ns, secret = %settings.secret_name, deployment = %settings.deployment_name, "caches synced"); + + loop { + // Coalesce bursts of events into one pass. + tokio::time::sleep(Duration::from_millis(300)).await; + if let Err(e) = reconcile(&client, &settings, &stores).await { + tracing::warn!(error = %format!("{e:#}"), "reconcile failed"); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + tokio::select! { + _ = notify.notified() => {} + _ = tokio::time::sleep(settings.resync) => {} + } + } +} + +/// State of the gateway rollout for the document hash it should serve. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Rollout { + Ready, + Progressing(String), + Missing, +} + +pub fn rollout(deployment: Option<&Deployment>, want: &str) -> Rollout { + let Some(d) = deployment else { + return Rollout::Missing; + }; + let stamped = d + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.annotations.as_ref()) + .and_then(|a| a.get(HASH_ANNOTATION)); + if stamped.map(String::as_str) != Some(want) { + return Rollout::Progressing( + "the gateway Deployment does not carry this configuration yet".into(), + ); + } + let replicas = d.spec.as_ref().and_then(|s| s.replicas).unwrap_or(1); + let Some(status) = d.status.as_ref() else { + return Rollout::Progressing("the gateway Deployment reports no status yet".into()); + }; + let observed = status.observed_generation.unwrap_or(0) >= d.metadata.generation.unwrap_or(0); + let updated = status.updated_replicas.unwrap_or(0); + let available = status.available_replicas.unwrap_or(0); + let total = status.replicas.unwrap_or(0); + if observed && updated == replicas && available == replicas && total == replicas { + Rollout::Ready + } else { + Rollout::Progressing(format!( + "rolling out: {updated}/{replicas} updated, {available}/{replicas} available" + )) + } +} + +async fn reconcile(client: &Client, settings: &Settings, stores: &Stores) -> anyhow::Result<()> { + let ns = settings.namespace.as_str(); + let secrets: Vec = stores + .secrets + .state() + .iter() + .map(|s| s.as_ref().clone()) + .collect(); + let current = secrets + .iter() + .find(|s| s.metadata.name.as_deref() == Some(settings.secret_name.as_str())) + .and_then(|s| s.data.as_ref()) + .and_then(|d| d.get(&settings.secret_key)) + .and_then(|b| String::from_utf8(b.0.clone()).ok()); + + let sources = stores.sources(); + let outcome = assemble( + render_all(&sources, &secrets_from(&secrets)), + &served_entries(current.as_deref()), + ); + if let Some(e) = &outcome.error { + tracing::warn!(error = %e, "resources file rejected; keeping the served version"); + } + + // A gateway needs a file to start, so seed an empty one. + let desired = match (&outcome.document, ¤t) { + (Some(doc), _) => Some(doc.clone()), + (None, None) => Some(document(&[])), + (None, Some(_)) => None, + }; + let served = match desired { + Some(doc) if current.as_deref() != Some(doc.as_str()) => { + write_secret(client, settings, &doc).await?; + tracing::info!(hash = %&hash(&doc)[..12], "resources file written"); + doc + } + Some(doc) => doc, + None => current.clone().unwrap_or_default(), + }; + let served_hash = hash(&served); + + let deployment = stores + .deployments + .state() + .into_iter() + .find(|d| d.metadata.name.as_deref() == Some(settings.deployment_name.as_str())); + let mut state = rollout(deployment.as_deref(), &served_hash); + if matches!(state, Rollout::Progressing(_)) + && deployment + .as_deref() + .is_some_and(|d| !stamped(d, &served_hash)) + { + stamp_deployment(client, settings, &served_hash).await?; + tracing::info!(hash = %&served_hash[..12], "gateway rollout started"); + state = Rollout::Progressing("rollout started".into()); + } + + let in_served = outcome.document.as_deref() == Some(served.as_str()); + for s in &sources { + let previous = status_of(stores, s); + let status = desired_status(s, &outcome, in_served, &state, previous.as_ref()); + if previous.as_ref() != Some(&status) { + patch_status(client, ns, &s.key(), &status).await?; + } + } + Ok(()) +} + +fn stamped(d: &Deployment, want: &str) -> bool { + d.spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.annotations.as_ref()) + .and_then(|a| a.get(HASH_ANNOTATION)) + .is_some_and(|h| h == want) +} + +async fn write_secret(client: &Client, settings: &Settings, doc: &str) -> anyhow::Result<()> { + let api: Api = Api::namespaced(client.clone(), &settings.namespace); + let secret = Secret { + metadata: ObjectMeta { + name: Some(settings.secret_name.clone()), + labels: Some(BTreeMap::from([( + "app.kubernetes.io/managed-by".to_string(), + MANAGER.to_string(), + )])), + annotations: Some(BTreeMap::from([(HASH_ANNOTATION.to_string(), hash(doc))])), + ..Default::default() + }, + data: Some(BTreeMap::from([( + settings.secret_key.clone(), + ByteString(doc.as_bytes().to_vec()), + )])), + type_: Some("Opaque".into()), + ..Default::default() + }; + let params = PatchParams::apply(MANAGER).force(); + api.patch(&settings.secret_name, ¶ms, &Patch::Apply(&secret)) + .await + .with_context(|| format!("writing Secret {}", settings.secret_name))?; + Ok(()) +} + +async fn stamp_deployment(client: &Client, settings: &Settings, hash: &str) -> anyhow::Result<()> { + let api: Api = Api::namespaced(client.clone(), &settings.namespace); + let patch = + json!({"spec": {"template": {"metadata": {"annotations": {HASH_ANNOTATION: hash}}}}}); + api.patch( + &settings.deployment_name, + &PatchParams::default(), + &Patch::Merge(&patch), + ) + .await + .with_context(|| format!("stamping Deployment {}", settings.deployment_name))?; + Ok(()) +} + +fn status_of(stores: &Stores, s: &Source) -> Option { + let find = |name: &str| -> Option> { + match s.kind { + Kind::ProviderKey => stores + .provider_keys + .state() + .into_iter() + .find(|o| o.name_any() == name) + .map(|o| o.status.clone()), + Kind::Model => stores + .models + .state() + .into_iter() + .find(|o| o.name_any() == name) + .map(|o| o.status.clone()), + Kind::APIKey => stores + .api_keys + .state() + .into_iter() + .find(|o| o.name_any() == name) + .map(|o| o.status.clone()), + } + }; + find(&s.name).flatten() +} + +fn condition( + type_: &str, + status: &str, + reason: &str, + message: String, + generation: Option, + previous: Option<&ResourceStatus>, +) -> Condition { + let prior = previous.and_then(|p| p.conditions.iter().find(|c| c.type_ == type_)); + let last_transition_time = match prior { + Some(c) if c.status == status => c.last_transition_time.clone(), + _ => Time(k8s_openapi::jiff::Timestamp::now()), + }; + Condition { + type_: type_.into(), + status: status.into(), + reason: reason.into(), + message, + observed_generation: generation, + last_transition_time, + } +} + +/// Conditions for one resource. `Accepted`: its current spec is valid. +/// `Programmed`: the gateway serves its current spec. +pub fn desired_status( + s: &Source, + outcome: &Outcome, + in_served: bool, + rollout: &Rollout, + previous: Option<&ResourceStatus>, +) -> ResourceStatus { + let g = s.generation; + let disposition = outcome.dispositions.get(&s.key()); + let (accepted, programmed) = match (disposition, &outcome.error) { + (Some(Disposition::Applied), None) => { + let accepted = condition("Accepted", "True", "Accepted", "valid".into(), g, previous); + let programmed = match (in_served, rollout) { + (true, Rollout::Ready) => condition( + "Programmed", + "True", + "Programmed", + "served by the gateway".into(), + g, + previous, + ), + (_, Rollout::Missing) => condition( + "Programmed", + "False", + "GatewayNotFound", + "the gateway Deployment does not exist".into(), + g, + previous, + ), + (_, Rollout::Progressing(m)) => { + condition("Programmed", "False", "Pending", m.clone(), g, previous) + } + (false, Rollout::Ready) => condition( + "Programmed", + "False", + "Pending", + "waiting for the resources file to be written".into(), + g, + previous, + ), + }; + (accepted, programmed) + } + (Some(Disposition::Retained(m)), None) => ( + condition("Accepted", "False", "Invalid", m.clone(), g, previous), + condition( + "Programmed", + "False", + "Invalid", + "the previously accepted version keeps serving".into(), + g, + previous, + ), + ), + (Some(Disposition::Rejected(m)), None) => ( + condition("Accepted", "False", "Invalid", m.clone(), g, previous), + condition( + "Programmed", + "False", + "Invalid", + "not served".into(), + g, + previous, + ), + ), + (Some(Disposition::Rejected(m)), Some(_)) | (Some(Disposition::Retained(m)), Some(_)) => ( + condition("Accepted", "False", "Invalid", m.clone(), g, previous), + condition( + "Programmed", + "False", + "Invalid", + "not served".into(), + g, + previous, + ), + ), + (_, Some(e)) => ( + condition( + "Accepted", + "Unknown", + "Pending", + "the resources file as a whole is invalid".into(), + g, + previous, + ), + condition( + "Programmed", + "False", + "ConfigurationRejected", + e.clone(), + g, + previous, + ), + ), + (None, None) => ( + condition( + "Accepted", + "Unknown", + "Pending", + "not reconciled yet".into(), + g, + previous, + ), + condition( + "Programmed", + "Unknown", + "Pending", + "not reconciled yet".into(), + g, + previous, + ), + ), + }; + ResourceStatus { + observed_generation: g, + conditions: vec![accepted, programmed], + } +} + +async fn patch_status( + client: &Client, + ns: &str, + key: &SourceKey, + status: &ResourceStatus, +) -> anyhow::Result<()> { + let patch = Patch::Merge(json!({"status": status})); + let params = PatchParams::default(); + let name = key.name.as_str(); + let result = match key.kind { + Kind::ProviderKey => Api::::namespaced(client.clone(), ns) + .patch_status(name, ¶ms, &patch) + .await + .map(|_| ()), + Kind::Model => Api::::namespaced(client.clone(), ns) + .patch_status(name, ¶ms, &patch) + .await + .map(|_| ()), + Kind::APIKey => Api::::namespaced(client.clone(), ns) + .patch_status(name, ¶ms, &patch) + .await + .map(|_| ()), + }; + match result { + Ok(()) => Ok(()), + // Deleted between the read and the write. + Err(kube::Error::Api(e)) if e.code == 404 => Ok(()), + Err(e) => Err(e).with_context(|| format!("updating status of {key}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use k8s_openapi::api::apps::v1::{DeploymentSpec, DeploymentStatus}; + use k8s_openapi::api::core::v1::PodTemplateSpec; + + fn deployment(hash: Option<&str>, updated: i32, available: i32) -> Deployment { + Deployment { + metadata: ObjectMeta { + generation: Some(2), + ..Default::default() + }, + spec: Some(DeploymentSpec { + replicas: Some(2), + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + annotations: hash.map(|h| { + BTreeMap::from([(HASH_ANNOTATION.to_string(), h.to_string())]) + }), + ..Default::default() + }), + spec: None, + }, + ..Default::default() + }), + status: Some(DeploymentStatus { + observed_generation: Some(2), + replicas: Some(2), + updated_replicas: Some(updated), + available_replicas: Some(available), + ..Default::default() + }), + } + } + + #[test] + fn rollout_states() { + assert_eq!(rollout(None, "h"), Rollout::Missing); + assert!(matches!( + rollout(Some(&deployment(None, 2, 2)), "h"), + Rollout::Progressing(_) + )); + assert!(matches!( + rollout(Some(&deployment(Some("old"), 2, 2)), "h"), + Rollout::Progressing(_) + )); + assert!(matches!( + rollout(Some(&deployment(Some("h"), 1, 2)), "h"), + Rollout::Progressing(_) + )); + assert_eq!( + rollout(Some(&deployment(Some("h"), 2, 2)), "h"), + Rollout::Ready + ); + } + + fn src() -> Source { + Source { + kind: Kind::Model, + name: "m".into(), + generation: Some(3), + created: None, + fields: Default::default(), + } + } + + fn outcome(d: Disposition, error: Option<&str>) -> Outcome { + Outcome { + document: Some("{}".into()), + dispositions: BTreeMap::from([(src().key(), d)]), + error: error.map(String::from), + } + } + + fn get<'a>(s: &'a ResourceStatus, t: &str) -> &'a Condition { + s.conditions.iter().find(|c| c.type_ == t).unwrap() + } + + #[test] + fn applied_and_rolled_out_is_programmed() { + let s = desired_status( + &src(), + &outcome(Disposition::Applied, None), + true, + &Rollout::Ready, + None, + ); + assert_eq!(get(&s, "Accepted").status, "True"); + assert_eq!(get(&s, "Programmed").status, "True"); + assert_eq!(s.observed_generation, Some(3)); + assert_eq!(get(&s, "Programmed").observed_generation, Some(3)); + } + + #[test] + fn applied_while_rolling_is_pending() { + let s = desired_status( + &src(), + &outcome(Disposition::Applied, None), + true, + &Rollout::Progressing("x".into()), + None, + ); + assert_eq!(get(&s, "Programmed").reason, "Pending"); + } + + #[test] + fn retained_is_not_accepted() { + let s = desired_status( + &src(), + &outcome(Disposition::Retained("bad".into()), None), + true, + &Rollout::Ready, + None, + ); + assert_eq!(get(&s, "Accepted").status, "False"); + assert_eq!(get(&s, "Accepted").message, "bad"); + assert!(get(&s, "Programmed") + .message + .contains("previously accepted")); + } + + #[test] + fn transition_time_is_kept_when_status_is_unchanged() { + let first = desired_status( + &src(), + &outcome(Disposition::Applied, None), + true, + &Rollout::Ready, + None, + ); + let second = desired_status( + &src(), + &outcome(Disposition::Applied, None), + true, + &Rollout::Ready, + Some(&first), + ); + assert_eq!(first, second); + } +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..5ab2d73 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,377 @@ +//! Custom resources + Secrets -> resources-file entries. +//! +//! Each CR becomes one entry of the gateway's `resources.yaml` collection for +//! its kind. Reference fields are resolved here; everything else passes +//! through untouched for the gateway pipeline to validate. + +use std::collections::BTreeMap; + +use aisix_core::models::ApiKey; +use k8s_openapi::api::core::v1::Secret; +use serde_json::{Map, Value}; + +use crate::crd::{Kind, API_KEY_SECRET_REF, IDENTITY_FIELD, KEY_SECRET_REF, PROVIDER_KEY_REF}; + +/// One custom resource, reduced to what rendering needs. +#[derive(Clone, Debug, PartialEq)] +pub struct Source { + pub kind: Kind, + pub name: String, + pub generation: Option, + /// Sort key for duplicate identities: the older resource wins. + pub created: Option, + pub fields: Map, +} + +impl Source { + pub fn key(&self) -> SourceKey { + SourceKey { + kind: self.kind, + name: self.name.clone(), + } + } + + /// AISIX identity: `display_name`, defaulting to `metadata.name`. + pub fn identity(&self) -> String { + self.fields + .get(IDENTITY_FIELD) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(String::from) + .unwrap_or_else(|| self.name.clone()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SourceKey { + pub kind: Kind, + pub name: String, +} + +impl std::fmt::Display for SourceKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.kind.name(), self.name) + } +} + +/// Secret data by Secret name, then key. +pub type Secrets = BTreeMap>>; + +pub fn secrets_from(list: &[Secret]) -> Secrets { + list.iter() + .filter_map(|s| { + let name = s.metadata.name.clone()?; + let data = s + .data + .as_ref() + .map(|d| d.iter().map(|(k, v)| (k.clone(), v.0.clone())).collect()) + .unwrap_or_default(); + Some((name, data)) + }) + .collect() +} + +/// A rendered entry, already escaped for `${VAR}` interpolation. +pub type Entry = Value; + +/// Render every source. Sources come back in file order: by kind, then by +/// creation time and name. +pub fn render_all(sources: &[Source], secrets: &Secrets) -> Vec<(Source, Result)> { + let mut ordered: Vec = sources.to_vec(); + ordered.sort_by(|a, b| (a.kind, &a.created, &a.name).cmp(&(b.kind, &b.created, &b.name))); + + let provider_identities: BTreeMap = ordered + .iter() + .filter(|s| s.kind == Kind::ProviderKey) + .map(|s| (s.name.clone(), s.identity())) + .collect(); + + let mut seen: BTreeMap<(Kind, String), String> = BTreeMap::new(); + ordered + .into_iter() + .map(|s| { + let result = match seen.get(&(s.kind, s.identity())) { + Some(owner) => Err(format!( + "{IDENTITY_FIELD} {:?} is already used by {}/{owner}", + s.identity(), + s.kind.name() + )), + None => { + seen.insert((s.kind, s.identity()), s.name.clone()); + render_one(&s, secrets, &provider_identities) + } + }; + (s, result) + }) + .collect() +} + +fn render_one( + source: &Source, + secrets: &Secrets, + provider_identities: &BTreeMap, +) -> Result { + let mut fields = source.fields.clone(); + fields.insert(IDENTITY_FIELD.into(), Value::String(source.identity())); + + match source.kind { + Kind::ProviderKey => { + for inline in ["api_key", "secret"] { + if fields.contains_key(inline) { + return Err(format!( + "set the credential through {API_KEY_SECRET_REF}, not `{inline}`" + )); + } + } + let reference = fields + .remove(API_KEY_SECRET_REF) + .ok_or_else(|| format!("{API_KEY_SECRET_REF} is required"))?; + let value = secret_value(&reference, secrets, API_KEY_SECRET_REF)?; + fields.insert("api_key".into(), Value::String(value)); + } + Kind::Model => { + if fields.contains_key("provider_key_id") { + return Err(format!( + "reference the provider key through {PROVIDER_KEY_REF}" + )); + } + if let Some(reference) = fields.remove(PROVIDER_KEY_REF) { + let name = reference + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| format!("{PROVIDER_KEY_REF}.name is required"))?; + let identity = provider_identities.get(name).ok_or_else(|| { + format!("{PROVIDER_KEY_REF} names ProviderKey {name:?}, which does not exist") + })?; + fields.insert("provider_key".into(), Value::String(identity.clone())); + } + } + Kind::APIKey => { + if fields.contains_key("key_hash") { + return Err(format!( + "set the key through {KEY_SECRET_REF}, not `key_hash`" + )); + } + let reference = fields + .remove(KEY_SECRET_REF) + .ok_or_else(|| format!("{KEY_SECRET_REF} is required"))?; + let plaintext = secret_value(&reference, secrets, KEY_SECRET_REF)?; + fields.insert( + "key_hash".into(), + Value::String(ApiKey::hash_bearer(&plaintext)), + ); + } + } + + let mut entry = Value::Object(fields); + escape_dollars(&mut entry); + Ok(entry) +} + +fn secret_value(reference: &Value, secrets: &Secrets, field: &str) -> Result { + let name = reference + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| format!("{field}.name is required"))?; + let key = reference + .get("key") + .and_then(Value::as_str) + .ok_or_else(|| format!("{field}.key is required"))?; + let data = secrets + .get(name) + .ok_or_else(|| format!("{field}: Secret {name:?} not found"))?; + let bytes = data + .get(key) + .ok_or_else(|| format!("{field}: Secret {name:?} has no key {key:?}"))?; + let value = String::from_utf8(bytes.clone()) + .map_err(|_| format!("{field}: Secret {name:?} key {key:?} is not UTF-8"))?; + let value = value.trim_end_matches(['\r', '\n']).to_string(); + if value.is_empty() { + return Err(format!("{field}: Secret {name:?} key {key:?} is empty")); + } + Ok(value) +} + +/// The resources file interpolates `${VAR}` in every string scalar. Values +/// from custom resources are literal, so every `$` is written as `$$`. +pub fn escape_dollars(value: &mut Value) { + match value { + Value::String(s) if s.contains('$') => *s = s.replace('$', "$$"), + Value::Array(items) => items.iter_mut().for_each(escape_dollars), + Value::Object(map) => map.values_mut().for_each(escape_dollars), + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn source(kind: Kind, name: &str, fields: Value) -> Source { + Source { + kind, + name: name.into(), + generation: Some(1), + created: Some("2026-01-01T00:00:00Z".into()), + fields: fields.as_object().unwrap().clone(), + } + } + + fn secrets() -> Secrets { + let mut s = Secrets::new(); + s.insert( + "creds".into(), + BTreeMap::from([ + ("openai".into(), b"sk-$abc\n".to_vec()), + ("caller".into(), b"caller-key".to_vec()), + ("empty".into(), Vec::new()), + ]), + ); + s + } + + fn rendered(sources: &[Source]) -> BTreeMap> { + render_all(sources, &secrets()) + .into_iter() + .map(|(s, r)| (s.key().to_string(), r)) + .collect() + } + + #[test] + fn provider_key_takes_credential_from_secret_and_escapes_it() { + let out = rendered(&[source( + Kind::ProviderKey, + "openai", + json!({"provider": "openai", "api_base": "http://x/${not_env}", "api_key_secret_ref": {"name": "creds", "key": "openai"}}), + )]); + let entry = out["ProviderKey/openai"].as_ref().unwrap(); + assert_eq!(entry["api_key"], json!("sk-$$abc")); + assert_eq!(entry["api_base"], json!("http://x/$${not_env}")); + assert_eq!(entry["display_name"], json!("openai")); + assert!(entry.get(API_KEY_SECRET_REF).is_none()); + } + + #[test] + fn model_reference_resolves_to_provider_identity() { + let out = rendered(&[ + source( + Kind::ProviderKey, + "pk", + json!({"display_name": "Main OpenAI", "api_key_secret_ref": {"name": "creds", "key": "openai"}}), + ), + source( + Kind::Model, + "gpt", + json!({"display_name": "gpt-4o", "provider": "openai", "model_name": "gpt-4o", "provider_key_ref": {"name": "pk"}}), + ), + ]); + let entry = out["Model/gpt"].as_ref().unwrap(); + assert_eq!(entry["provider_key"], json!("Main OpenAI")); + assert_eq!(entry["display_name"], json!("gpt-4o")); + assert!(entry.get(PROVIDER_KEY_REF).is_none()); + } + + #[test] + fn api_key_is_hashed() { + let out = rendered(&[source( + Kind::APIKey, + "caller", + json!({"allowed_models": ["*"], "key_secret_ref": {"name": "creds", "key": "caller"}}), + )]); + let entry = out["APIKey/caller"].as_ref().unwrap(); + assert_eq!(entry["key_hash"], json!(ApiKey::hash_bearer("caller-key"))); + assert_eq!(entry["key_hash"].as_str().unwrap().len(), 64); + assert!(!entry.to_string().contains("caller-key")); + } + + #[test] + fn missing_references_are_errors() { + let out = rendered(&[ + source( + Kind::ProviderKey, + "a", + json!({"api_key_secret_ref": {"name": "nope", "key": "k"}}), + ), + source( + Kind::ProviderKey, + "b", + json!({"api_key_secret_ref": {"name": "creds", "key": "nope"}}), + ), + source( + Kind::ProviderKey, + "c", + json!({"api_key_secret_ref": {"name": "creds", "key": "empty"}}), + ), + source( + Kind::Model, + "m", + json!({"provider_key_ref": {"name": "ghost"}}), + ), + source(Kind::APIKey, "k", json!({})), + ]); + assert!(out["ProviderKey/a"] + .as_ref() + .unwrap_err() + .contains("not found")); + assert!(out["ProviderKey/b"] + .as_ref() + .unwrap_err() + .contains("has no key")); + assert!(out["ProviderKey/c"].as_ref().unwrap_err().contains("empty")); + assert!(out["Model/m"] + .as_ref() + .unwrap_err() + .contains("does not exist")); + assert!(out["APIKey/k"].as_ref().unwrap_err().contains("required")); + } + + #[test] + fn inline_credentials_are_refused() { + let out = rendered(&[source( + Kind::ProviderKey, + "a", + json!({"api_key": "sk", "api_key_secret_ref": {"name": "creds", "key": "openai"}}), + )]); + assert!(out["ProviderKey/a"] + .as_ref() + .unwrap_err() + .contains(API_KEY_SECRET_REF)); + } + + #[test] + fn duplicate_identity_loses_to_older_resource() { + let mut older = source(Kind::Model, "zeta", json!({"display_name": "same"})); + older.created = Some("2025-01-01T00:00:00Z".into()); + let newer = source(Kind::Model, "alpha", json!({"display_name": "same"})); + let out = rendered(&[newer, older]); + assert!(out["Model/zeta"].is_ok()); + assert!(out["Model/alpha"] + .as_ref() + .unwrap_err() + .contains("already used by Model/zeta")); + } + + #[test] + fn order_is_kind_then_age_then_name() { + let out = render_all( + &[ + source( + Kind::APIKey, + "k", + json!({"key_secret_ref": {"name": "creds", "key": "caller"}}), + ), + source(Kind::Model, "b", json!({})), + source(Kind::Model, "a", json!({})), + source( + Kind::ProviderKey, + "p", + json!({"api_key_secret_ref": {"name": "creds", "key": "openai"}}), + ), + ], + &secrets(), + ); + let names: Vec = out.iter().map(|(s, _)| s.key().to_string()).collect(); + assert_eq!(names, ["ProviderKey/p", "Model/a", "Model/b", "APIKey/k"]); + } +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..7a33d95 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,892 @@ +//! CRD generation from aisix-core's strict resource schemas. +//! +//! aisix-core publishes draft-07 JSON Schemas (`resource_root_schema`). A +//! CRD needs a structural OpenAPI v3 schema, so this module: +//! +//! 1. shapes each resource for Kubernetes: drops the inline credential and +//! id-form references, and adds the reference fields the controller +//! resolves (`crd.rs`); +//! 2. converts the result to a structural schema: `$ref`s inlined, nullable +//! types folded, composition keywords merged or dropped. +//! +//! The conversion keeps types, required fields, enums, ranges, lengths and +//! patterns. It drops what a structural schema cannot carry (exclusive +//! `oneOf` branches, `if`/`then`, `not`, defaults, formats). Those rules are +//! still enforced: the controller validates every document with the +//! gateway's own pipeline before writing it. + +use std::collections::BTreeSet; + +use aisix_core::filesource::{mcp_ref_id_fields, model_ref_id_fields}; +use aisix_core::models::schema::resource_root_schema; +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::{ + CustomResourceColumnDefinition, CustomResourceDefinition, CustomResourceValidation, + JSONSchemaProps, +}; +use kube::CustomResourceExt; +use serde_json::{json, Map, Value}; + +use crate::crd::{ + APIKey, Kind, Model, ProviderKey, API_KEY_SECRET_REF, IDENTITY_FIELD, KEY_SECRET_REF, + PROVIDER_KEY_REF, +}; + +/// Fields removed from a kind's spec, at any depth. +fn removed_fields(kind: Kind) -> BTreeSet<&'static str> { + let mut out: BTreeSet<&'static str> = BTreeSet::new(); + match kind { + Kind::ProviderKey => { + out.extend(["api_key", "secret"]); + } + Kind::Model => { + out.insert("provider_key_id"); + } + Kind::APIKey => { + out.insert("key_hash"); + } + } + // The resources file refuses id-form references; they are written by name. + for f in model_ref_id_fields(kind.collection()) { + out.insert(f.field); + } + for f in mcp_ref_id_fields(kind.collection()) { + out.insert(f.field); + } + out +} + +fn secret_key_ref(description: &str) -> Value { + json!({ + "type": "object", + "description": description, + "required": ["name", "key"], + "properties": { + "name": {"type": "string", "minLength": 1, "description": "Secret name, in the resource's namespace."}, + "key": {"type": "string", "minLength": 1, "description": "Key within the Secret."} + } + }) +} + +/// Reference fields the controller resolves, with whether each is required. +fn added_fields(kind: Kind) -> Vec<(&'static str, Value, bool)> { + match kind { + Kind::ProviderKey => vec![( + API_KEY_SECRET_REF, + secret_key_ref("Secret key holding the upstream provider credential. The controller writes it as `api_key`."), + true, + )], + Kind::Model => vec![( + PROVIDER_KEY_REF, + json!({ + "type": "object", + "description": "ProviderKey, in the same namespace, this model authenticates with. Direct models need it; routing, ensemble and semantic models leave it unset.", + "required": ["name"], + "properties": { + "name": {"type": "string", "minLength": 1, "description": "ProviderKey `metadata.name`."} + } + }), + false, + )], + Kind::APIKey => vec![( + KEY_SECRET_REF, + secret_key_ref("Secret key holding the caller's plaintext API key. The controller writes only its SHA-256 hash."), + true, + )], + } +} + +/// The aisix-core strict schema for a kind, shaped for Kubernetes. +/// Still draft-07 (with `definitions`); see [`to_structural`]. +pub fn shaped_schema(kind: Kind) -> Value { + let mut root = resource_root_schema(kind.schema_resource(), true); + let removed = removed_fields(kind); + strip_fields(&mut root, &removed); + + let obj = root.as_object_mut().expect("resource schema is an object"); + // Root compositions only encode cross-field rules (credential spelling, + // model shape), which the gateway pipeline enforces. + for k in ["anyOf", "oneOf", "allOf", "if", "then", "else", "not"] { + obj.remove(k); + } + + let mut required: Vec = obj + .get("required") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|v| v.as_str() != Some(IDENTITY_FIELD)) + .collect(); + + let props = obj + .entry("properties") + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("properties is an object"); + if let Some(Value::Object(identity)) = props.get_mut(IDENTITY_FIELD) { + let base = identity + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + identity.insert( + "description".into(), + Value::String( + format!("{base} Defaults to `metadata.name`.",) + .trim() + .to_string(), + ), + ); + } + for (name, schema, is_required) in added_fields(kind) { + props.insert(name.to_string(), schema); + if is_required { + required.push(Value::String(name.to_string())); + } + } + if required.is_empty() { + obj.remove("required"); + } else { + obj.insert("required".into(), Value::Array(required)); + } + root +} + +/// Remove the named properties everywhere: in `properties`, `required`, and +/// inside definitions and compositions. +fn strip_fields(node: &mut Value, removed: &BTreeSet<&str>) { + match node { + Value::Object(map) => { + if let Some(Value::Object(props)) = map.get_mut("properties") { + props.retain(|k, _| !removed.contains(k.as_str())); + } + if let Some(Value::Array(req)) = map.get_mut("required") { + req.retain(|v| v.as_str().is_none_or(|s| !removed.contains(s))); + } + for (_, v) in map.iter_mut() { + strip_fields(v, removed); + } + } + Value::Array(items) => { + for v in items { + strip_fields(v, removed); + } + } + _ => {} + } +} + +const PRESERVE: &str = "x-kubernetes-preserve-unknown-fields"; +const INT_OR_STRING: &str = "x-kubernetes-int-or-string"; + +/// Keywords copied as-is onto a structural node. +const KEPT: &[&str] = &[ + "description", + "enum", + "minimum", + "maximum", + "minLength", + "maxLength", + "pattern", + "minItems", + "maxItems", + "minProperties", + "maxProperties", + "required", +]; + +fn preserve(description: Option<&Value>) -> Value { + let mut m = Map::new(); + if let Some(d) = description { + m.insert("description".into(), d.clone()); + } + m.insert(PRESERVE.into(), Value::Bool(true)); + Value::Object(m) +} + +/// Convert a draft-07 schema (with its root `definitions`) to a structural +/// OpenAPI v3 schema. +pub fn to_structural(root: &Value) -> Value { + let defs = root + .get("definitions") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut stack = Vec::new(); + let mut out = convert(root, &defs, &mut stack); + if let Value::Object(m) = &mut out { + // A spec is always an object. + m.remove(PRESERVE); + m.insert("type".into(), json!("object")); + } + out +} + +/// True when a composition branch only constrains values (required fields, +/// negations) and adds no shape of its own. +fn is_constraint_only(branch: &Value) -> bool { + match branch { + Value::Object(m) => m.keys().all(|k| { + matches!( + k.as_str(), + "required" | "not" | "title" | "description" | "if" | "then" | "else" + ) + }), + _ => false, + } +} + +fn is_null_branch(branch: &Value) -> bool { + branch.get("type").and_then(Value::as_str) == Some("null") +} + +/// Resolve `$ref` and single-branch `allOf` into one flat schema object, +/// keeping the referring node's own keywords (its description wins). +fn flatten( + node: &Map, + defs: &Map, + stack: &mut Vec, +) -> Option> { + let mut out = node.clone(); + if let Some(r) = out.remove("$ref") { + let name = r.as_str()?.strip_prefix("#/definitions/")?.to_string(); + if stack.contains(&name) { + return None; + } + let target = defs.get(&name)?.as_object()?.clone(); + stack.push(name); + let resolved = flatten(&target, defs, stack); + stack.pop(); + let mut merged = resolved?; + for (k, v) in out { + merged.insert(k, v); + } + out = merged; + } + if let Some(Value::Array(all)) = out.get("allOf").cloned() { + let shaped: Vec<&Value> = all.iter().filter(|b| !is_constraint_only(b)).collect(); + if shaped.len() == 1 { + out.remove("allOf"); + let branch = shaped[0].as_object()?; + let mut merged = flatten(branch, defs, stack)?; + for (k, v) in out { + merged.insert(k, v); + } + out = merged; + } + } + Some(out) +} + +fn convert(node: &Value, defs: &Map, stack: &mut Vec) -> Value { + let map = match node { + Value::Bool(true) => return preserve(None), + Value::Object(m) if m.is_empty() => return preserve(None), + Value::Object(m) => m, + _ => return preserve(None), + }; + let Some(mut m) = flatten(map, defs, stack) else { + return preserve(map.get("description")); + }; + + let mut nullable = false; + + // anyOf / oneOf / multi-branch allOf. + for key in ["anyOf", "oneOf", "allOf"] { + let Some(Value::Array(branches)) = m.remove(key) else { + continue; + }; + let mut shaped = Vec::new(); + for b in branches { + if is_null_branch(&b) { + nullable = true; + } else if !is_constraint_only(&b) { + shaped.push(b); + } + } + if shaped.is_empty() { + continue; + } + let converted: Vec = shaped.iter().map(|b| convert(b, defs, stack)).collect(); + match merge_branches(&converted, key == "allOf") { + Some(merged) => { + for (k, v) in merged { + if k == "description" && m.contains_key("description") { + continue; + } + if k == "properties" { + let props = m.entry("properties").or_insert_with(|| json!({})); + if let (Value::Object(dst), Value::Object(src)) = (props, v) { + for (pk, pv) in src { + dst.entry(pk).or_insert(pv); + } + } + continue; + } + m.entry(k).or_insert(v); + } + } + None => { + let mut p = preserve(m.get("description")).as_object().cloned().unwrap(); + if converted.len() == 2 + && converted + .iter() + .any(|c| c.get("type") == Some(&json!("integer"))) + && converted + .iter() + .any(|c| c.get("type") == Some(&json!("string"))) + { + p.remove(PRESERVE); + p.insert(INT_OR_STRING.into(), Value::Bool(true)); + } + if nullable { + p.insert("nullable".into(), Value::Bool(true)); + } + return Value::Object(p); + } + } + } + + // type: [X, "null"] + let mut ty: Option = None; + match m.remove("type") { + Some(Value::String(t)) if t == "null" => nullable = true, + Some(Value::String(t)) => ty = Some(t), + Some(Value::Array(ts)) => { + let mut rest: Vec = Vec::new(); + for t in ts { + match t.as_str() { + Some("null") => nullable = true, + Some(s) => rest.push(s.to_string()), + None => {} + } + } + match rest.len() { + 0 => {} + 1 => ty = rest.pop(), + _ => { + let mut p = preserve(m.get("description")).as_object().cloned().unwrap(); + if rest.len() == 2 + && rest.contains(&"integer".to_string()) + && rest.contains(&"string".to_string()) + { + p.remove(PRESERVE); + p.insert(INT_OR_STRING.into(), Value::Bool(true)); + } + if nullable { + p.insert("nullable".into(), Value::Bool(true)); + } + return Value::Object(p); + } + } + } + _ => {} + } + + let mut out = Map::new(); + for k in KEPT { + if let Some(v) = m.get(*k) { + out.insert((*k).to_string(), v.clone()); + } + } + if let Some(c) = m.get("const") { + out.insert("enum".into(), json!([c])); + } + // draft-07 exclusive bounds are numbers; OpenAPI v3.0 uses booleans. + for (ex, base) in [ + ("exclusiveMinimum", "minimum"), + ("exclusiveMaximum", "maximum"), + ] { + if let Some(n) = m.get(ex).filter(|v| v.is_number()) { + out.insert(base.into(), n.clone()); + out.insert(ex.into(), Value::Bool(true)); + } + } + if let Some(Value::Array(values)) = out.get_mut("enum") { + if values.iter().any(Value::is_null) { + nullable = true; + values.retain(|v| !v.is_null()); + } + if values.is_empty() { + out.remove("enum"); + } + } + + let props = m.get("properties").and_then(Value::as_object); + let additional = m.get("additionalProperties"); + let items = m.get("items"); + + if ty.is_none() { + ty = if props.is_some() || additional.is_some() { + Some("object".into()) + } else if items.is_some() { + Some("array".into()) + } else if let Some(Value::Array(values)) = out.get("enum") { + infer_enum_type(values) + } else { + None + }; + } + + match ty.as_deref() { + Some("object") => { + let additional_schema = match additional { + Some(Value::Object(a)) if !a.is_empty() => { + Some(convert(additional.unwrap(), defs, stack)) + } + Some(Value::Bool(true)) | Some(Value::Object(_)) => Some(preserve(None)), + _ => None, + }; + let props_nonempty = props.is_some_and(|p| !p.is_empty()); + if let (Some(a), false) = (&additional_schema, props_nonempty) { + out.insert("additionalProperties".into(), a.clone()); + } else if props_nonempty { + let mut converted = Map::new(); + for (k, v) in props.unwrap() { + if k.is_empty() { + continue; + } + converted.insert(k.clone(), convert(v, defs, stack)); + } + out.insert("properties".into(), Value::Object(converted)); + } else if let Some(a) = additional_schema { + out.insert("additionalProperties".into(), a); + } else { + out.insert(PRESERVE.into(), Value::Bool(true)); + } + out.insert("type".into(), json!("object")); + prune_required(&mut out); + } + Some("array") => { + let item = match items { + Some(Value::Array(_)) | None => preserve(None), + Some(i) => convert(i, defs, stack), + }; + out.insert("items".into(), item); + out.insert("type".into(), json!("array")); + out.remove("required"); + } + Some(t @ ("string" | "integer" | "number" | "boolean")) => { + out.insert("type".into(), json!(t)); + out.remove("required"); + if let Some(Value::Array(values)) = out.get("enum") { + if !values.iter().all(|v| matches_type(v, t)) { + out.remove("enum"); + } + } + } + _ => { + let mut p = preserve(m.get("description")).as_object().cloned().unwrap(); + if nullable { + p.insert("nullable".into(), Value::Bool(true)); + } + return Value::Object(p); + } + } + if nullable { + out.insert("nullable".into(), Value::Bool(true)); + } + Value::Object(out) +} + +/// Keep only `required` entries that name a declared property. +fn prune_required(out: &mut Map) { + let declared: BTreeSet = out + .get("properties") + .and_then(Value::as_object) + .map(|p| p.keys().cloned().collect()) + .unwrap_or_default(); + if let Some(Value::Array(req)) = out.get_mut("required") { + req.retain(|v| v.as_str().is_some_and(|s| declared.contains(s))); + if req.is_empty() { + out.remove("required"); + } + } +} + +fn matches_type(v: &Value, t: &str) -> bool { + match t { + "string" => v.is_string(), + "integer" => v.is_i64() || v.is_u64(), + "number" => v.is_number(), + "boolean" => v.is_boolean(), + _ => false, + } +} + +fn infer_enum_type(values: &[Value]) -> Option { + for t in ["string", "integer", "number", "boolean"] { + if !values.is_empty() && values.iter().all(|v| matches_type(v, t)) { + return Some(t.into()); + } + } + None +} + +/// Merge converted branches into one shape, or `None` when their types differ. +/// `all` (allOf) keeps the union of `required`; anyOf/oneOf drop it. +fn merge_branches(branches: &[Value], all: bool) -> Option> { + let types: BTreeSet> = branches + .iter() + .map(|b| b.get("type").and_then(Value::as_str)) + .collect(); + if types.len() != 1 || types.contains(&None) { + return None; + } + let ty = types.into_iter().next().flatten()?; + let mut out = Map::new(); + out.insert("type".into(), json!(ty)); + match ty { + "object" => { + let mut props = Map::new(); + let mut required: BTreeSet = BTreeSet::new(); + let mut any_preserve = false; + for b in branches { + if let Some(Value::Object(p)) = b.get("properties") { + for (k, v) in p { + props.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + if b.get(PRESERVE) == Some(&Value::Bool(true)) + || b.get("additionalProperties").is_some() + { + any_preserve = true; + } + if all { + if let Some(Value::Array(r)) = b.get("required") { + required.extend(r.iter().filter_map(|v| v.as_str().map(String::from))); + } + } + } + if any_preserve { + // Branch shapes disagree on openness; keep everything. + out.insert(PRESERVE.into(), Value::Bool(true)); + } + if !props.is_empty() { + out.insert("properties".into(), Value::Object(props)); + } + if !required.is_empty() { + out.insert( + "required".into(), + Value::Array(required.into_iter().map(Value::String).collect()), + ); + } + } + "array" => { + out.insert("items".into(), preserve(None)); + } + _ => { + let mut values: Vec = Vec::new(); + let mut all_enum = true; + for b in branches { + match b.get("enum") { + Some(Value::Array(e)) => { + for v in e { + if !values.contains(v) { + values.push(v.clone()); + } + } + } + _ => all_enum = false, + } + } + if all_enum && !values.is_empty() { + out.insert("enum".into(), Value::Array(values)); + } + } + } + Some(out) +} + +fn status_schema() -> Value { + json!({ + "type": "object", + "properties": { + "observedGeneration": {"type": "integer", "format": "int64"}, + "conditions": { + "type": "array", + "x-kubernetes-list-type": "map", + "x-kubernetes-list-map-keys": ["type"], + "items": { + "type": "object", + "required": ["type", "status", "lastTransitionTime", "reason", "message"], + "properties": { + "type": {"type": "string"}, + "status": {"type": "string", "enum": ["True", "False", "Unknown"]}, + "observedGeneration": {"type": "integer", "format": "int64"}, + "lastTransitionTime": {"type": "string", "format": "date-time"}, + "reason": {"type": "string"}, + "message": {"type": "string"} + } + } + } + } + }) +} + +/// The full structural schema for a kind's custom resource. +pub fn resource_schema(kind: Kind) -> Value { + let spec = to_structural(&shaped_schema(kind)); + json!({ + "type": "object", + "description": format!("AISIX {} resource.", kind.name()), + "required": ["spec"], + "properties": { + "apiVersion": {"type": "string"}, + "kind": {"type": "string"}, + "metadata": {"type": "object"}, + "spec": spec, + "status": status_schema() + } + }) +} + +fn condition_column(name: &str) -> CustomResourceColumnDefinition { + CustomResourceColumnDefinition { + name: name.to_string(), + type_: "string".to_string(), + json_path: format!(".status.conditions[?(@.type==\"{name}\")].status"), + ..Default::default() + } +} + +/// The CustomResourceDefinition for a kind. +pub fn crd(kind: Kind) -> CustomResourceDefinition { + let mut crd = match kind { + Kind::ProviderKey => ProviderKey::crd(), + Kind::Model => Model::crd(), + Kind::APIKey => APIKey::crd(), + }; + let props: JSONSchemaProps = + serde_json::from_value(resource_schema(kind)).expect("structural schema deserializes"); + for v in &mut crd.spec.versions { + v.schema = Some(CustomResourceValidation { + open_api_v3_schema: Some(props.clone()), + }); + v.additional_printer_columns = Some(vec![ + condition_column("Accepted"), + condition_column("Programmed"), + CustomResourceColumnDefinition { + name: "Age".into(), + type_: "date".into(), + json_path: ".metadata.creationTimestamp".into(), + ..Default::default() + }, + ]); + } + crd +} + +/// CRD manifest as YAML, with a generated-file header. +pub fn crd_yaml(kind: Kind) -> String { + let body = serde_yaml::to_string(&crd(kind)).expect("CRD serializes"); + format!("# Generated by `aisix-controller crds` from aisix-core. Do not edit.\n{body}") +} + +/// File name a kind's CRD is written to. +pub fn crd_file_name(kind: Kind) -> String { + let crd = crd(kind); + format!("{}.yaml", crd.metadata.name.unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn walk<'a>(node: &'a Value, path: String, f: &mut dyn FnMut(&str, &'a Map)) { + if let Value::Object(m) = node { + f(&path, m); + if let Some(Value::Object(p)) = m.get("properties") { + for (k, v) in p { + walk(v, format!("{path}.{k}"), f); + } + } + if let Some(i) = m.get("items") { + walk(i, format!("{path}[]"), f); + } + if let Some(a @ Value::Object(_)) = m.get("additionalProperties") { + walk(a, format!("{path}{{}}"), f); + } + } + } + + /// The subset of apiextensions' structural rules a generator can break. + fn assert_structural(schema: &Value) { + let forbidden = [ + "$ref", + "$schema", + "definitions", + "oneOf", + "anyOf", + "allOf", + "not", + "if", + "then", + "else", + "const", + "default", + "title", + "propertyNames", + "patternProperties", + "uniqueItems", + "examples", + ]; + walk(schema, "spec".into(), &mut |path, m| { + for k in forbidden { + assert!(!m.contains_key(k), "{path}: forbidden keyword {k}"); + } + let open = m.get(PRESERVE) == Some(&Value::Bool(true)) + || m.get(INT_OR_STRING) == Some(&Value::Bool(true)); + assert!(open || m.contains_key("type"), "{path}: node without type"); + if m.contains_key("properties") { + assert!( + !m.contains_key("additionalProperties"), + "{path}: properties with additionalProperties" + ); + assert_eq!( + m.get("type"), + Some(&json!("object")), + "{path}: properties on a non-object" + ); + } + if m.get("type") == Some(&json!("array")) { + assert!(m.contains_key("items"), "{path}: array without items"); + } + if let Some(Value::Bool(_)) = m.get("additionalProperties") { + panic!("{path}: boolean additionalProperties"); + } + if let Some(ex) = m.get("exclusiveMinimum") { + assert!(ex.is_boolean(), "{path}: numeric exclusiveMinimum"); + } + }); + } + + fn spec(kind: Kind) -> Value { + resource_schema(kind)["properties"]["spec"].clone() + } + + fn has_property_anywhere(node: &Value, name: &str) -> bool { + let mut found = false; + walk(node, String::new(), &mut |_, m| { + if let Some(Value::Object(p)) = m.get("properties") { + found |= p.contains_key(name); + } + }); + found + } + + #[test] + fn every_spec_is_structural() { + for kind in Kind::ALL { + assert_structural(&spec(kind)); + } + } + + #[test] + fn credentials_never_appear_inline() { + let pk = spec(Kind::ProviderKey); + assert!(!has_property_anywhere(&pk, "api_key")); + assert!(!has_property_anywhere(&pk, "secret")); + assert!(pk["properties"][API_KEY_SECRET_REF].is_object()); + assert!(pk["required"] + .as_array() + .unwrap() + .contains(&json!(API_KEY_SECRET_REF))); + + let key = spec(Kind::APIKey); + assert!(!has_property_anywhere(&key, "key_hash")); + assert!(key["required"] + .as_array() + .unwrap() + .contains(&json!(KEY_SECRET_REF))); + } + + #[test] + fn id_form_references_are_removed() { + let model = spec(Kind::Model); + for f in [ + "provider_key_id", + "model_id", + "target_id", + "embedding_model_id", + "default_id", + ] { + assert!(!has_property_anywhere(&model, f), "model still has {f}"); + } + assert!(model["properties"][PROVIDER_KEY_REF].is_object()); + assert!(has_property_anywhere(&model, "routing")); + + let key = spec(Kind::APIKey); + for f in [ + "allowed_model_ids", + "mcp_rate_limits_by_id", + "allow_ids", + "deny_ids", + ] { + assert!(!has_property_anywhere(&key, f), "api key still has {f}"); + } + assert!(has_property_anywhere(&key, "allowed_models")); + } + + #[test] + fn identity_is_optional() { + for kind in Kind::ALL { + let s = spec(kind); + let required = s + .get("required") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + assert!(!required.contains(&json!(IDENTITY_FIELD)), "{kind:?}"); + assert!(s["properties"][IDENTITY_FIELD].is_object(), "{kind:?}"); + } + } + + #[test] + fn nullable_and_refs_convert() { + let root = json!({ + "definitions": {"Inner": {"type": "object", "properties": {"a": {"type": "integer", "minimum": 0.0}}}}, + "type": "object", + "properties": { + "opt": {"type": ["string", "null"]}, + "inner": {"anyOf": [{"$ref": "#/definitions/Inner"}, {"type": "null"}]}, + "wrapped": {"allOf": [{"$ref": "#/definitions/Inner"}], "description": "kept"}, + "mixed": {"anyOf": [{"type": "string"}, {"type": "object", "properties": {"x": {"type": "string"}}}]}, + "ios": {"type": ["integer", "string"]}, + "c": {"const": "fixed"}, + "map": {"type": "object", "additionalProperties": {"type": "string"}}, + "free": {"type": "object"}, + "ex": {"type": "number", "exclusiveMinimum": 0.0} + } + }); + let s = to_structural(&root); + let p = &s["properties"]; + assert_eq!(p["opt"], json!({"type": "string", "nullable": true})); + assert_eq!(p["inner"]["nullable"], json!(true)); + assert_eq!(p["inner"]["properties"]["a"]["type"], json!("integer")); + assert_eq!(p["wrapped"]["description"], json!("kept")); + assert_eq!(p["wrapped"]["properties"]["a"]["minimum"], json!(0.0)); + assert_eq!(p["mixed"][PRESERVE], json!(true)); + assert_eq!(p["ios"][INT_OR_STRING], json!(true)); + assert_eq!(p["c"], json!({"type": "string", "enum": ["fixed"]})); + assert_eq!(p["map"]["additionalProperties"], json!({"type": "string"})); + assert_eq!(p["free"][PRESERVE], json!(true)); + assert_eq!(p["ex"]["exclusiveMinimum"], json!(true)); + assert_structural(&s); + } + + #[test] + fn committed_crds_match_generator() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("deploy/crds"); + for kind in Kind::ALL { + let path = dir.join(crd_file_name(kind)); + let committed = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{}: {e}; run `make crds`", path.display())); + assert!( + committed == crd_yaml(kind), + "{} is stale; run `make crds`", + path.display() + ); + } + } +} diff --git a/tests/e2e/manifests/mock-openai.yaml b/tests/e2e/manifests/mock-openai.yaml new file mode 100644 index 0000000..3b772ff --- /dev/null +++ b/tests/e2e/manifests/mock-openai.yaml @@ -0,0 +1,76 @@ +# OpenAI-compatible upstream that echoes what the gateway sent it. +apiVersion: v1 +kind: ConfigMap +metadata: + name: mock-openai +data: + server.py: | + import json + from http.server import BaseHTTPRequestHandler, HTTPServer + + class Handler(BaseHTTPRequestHandler): + def _send(self, code, body): + data = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + self._send(200, {"ok": True}) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + req = json.loads(self.rfile.read(length) or b"{}") + model = req.get("model", "") + auth = self.headers.get("Authorization", "") + self._send(200, { + "id": "chatcmpl-e2e", + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": f"upstream-model={model} auth={auth}"}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }) + + def log_message(self, *args): + pass + + HTTPServer(("0.0.0.0", 8080), Handler).serve_forever() +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mock-openai +spec: + replicas: 1 + selector: + matchLabels: {app: mock-openai} + template: + metadata: + labels: {app: mock-openai} + spec: + containers: + - name: mock + image: python:3.12-alpine + command: ["python", "-u", "/app/server.py"] + ports: [{containerPort: 8080}] + readinessProbe: + httpGet: {path: /healthz, port: 8080} + volumeMounts: [{name: app, mountPath: /app}] + volumes: + - name: app + configMap: {name: mock-openai} +--- +apiVersion: v1 +kind: Service +metadata: + name: mock-openai +spec: + selector: {app: mock-openai} + ports: [{port: 8080, targetPort: 8080}] diff --git a/tests/e2e/manifests/resources.yaml b/tests/e2e/manifests/resources.yaml new file mode 100644 index 0000000..ff71e2c --- /dev/null +++ b/tests/e2e/manifests/resources.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: Secret +metadata: + name: e2e-creds +type: Opaque +stringData: + # A literal `$` must reach the upstream unchanged. + upstream: "sk-e2e-$dollar" + caller: "caller-e2e-key" +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: ProviderKey +metadata: + name: mock +spec: + provider: openai + adapter: openai + api_base: http://mock-openai:8080/v1 + api_key_secret_ref: + name: e2e-creds + key: upstream +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: Model +metadata: + name: chat +spec: + # Not a valid Kubernetes name, so it has to come from display_name. + display_name: e2e/chat + provider: openai + model_name: upstream-a + provider_key_ref: + name: mock +--- +apiVersion: aisix.api7.ai/v1alpha1 +kind: APIKey +metadata: + name: caller +spec: + allowed_models: ["*"] + key_secret_ref: + name: e2e-creds + key: caller diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..17c3d02 --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# End-to-end test: kind cluster, AISIX gateway from its Helm chart, the +# controller, and a mock OpenAI upstream. +# +# tests/e2e/run.sh # creates and deletes its own kind cluster +# KEEP=1 tests/e2e/run.sh # leaves the cluster up for inspection +set -euo pipefail +cd "$(dirname "$0")/../.." + +CLUSTER=${CLUSTER:-aisix-controller-e2e} +NS=${NS:-aisix-e2e} +IMAGE=${IMAGE:-aisix-controller:dev} +AISIX_CHART_VERSION=${AISIX_CHART_VERSION:-1.9.0} +KEEP=${KEEP:-0} +MANIFESTS=tests/e2e/manifests +CREATED_CLUSTER=0 + +log() { printf '\n==> %s\n' "$*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +dump() { + echo "---- diagnostics ----" + kubectl -n "$NS" get pods,deploy,secrets -o wide || true + kubectl -n "$NS" get providerkeys,models,apikeys -o yaml || true + kubectl -n "$NS" logs deploy/aisix-controller --tail=200 || true + kubectl -n "$NS" logs deploy/aisix --tail=100 || true +} + +cleanup() { + local rc=$? + if [ "$rc" -ne 0 ]; then dump; fi + if [ "$KEEP" != 1 ] && [ "$CREATED_CLUSTER" = 1 ]; then + kind delete cluster --name "$CLUSTER" >/dev/null 2>&1 || true + fi + exit "$rc" +} +trap cleanup EXIT + +k() { kubectl -n "$NS" "$@"; } + +# wait_condition : the condition holds for +# the resource's current generation. +wait_condition() { + local res=$1 name=$2 type=$3 want=$4 gen got + for _ in $(seq 1 120); do + gen=$(k get "$res" "$name" -o jsonpath='{.metadata.generation}') + got=$(k get "$res" "$name" -o jsonpath="{.status.conditions[?(@.type==\"$type\")].status}/{.status.conditions[?(@.type==\"$type\")].observedGeneration}") + if [ "$got" = "$want/$gen" ]; then return 0; fi + sleep 2 + done + k get "$res" "$name" -o yaml + fail "$res/$name: $type never became $want for generation $gen (last: $got)" +} + +# chat : prints " " for a request through the gateway. +chat() { + local out + out=$(k exec curl -- curl -s -w '\n%{http_code}' \ + -H "Authorization: Bearer $1" -H 'Content-Type: application/json' \ + -d '{"model":"e2e/chat","messages":[{"role":"user","content":"hi"}]}' \ + http://aisix/v1/chat/completions 2>/dev/null) || true + printf '%s %s\n' "${out##*$'\n'}" "${out%$'\n'*}" +} + +# expect_chat [substring]: retries while the gateway rolls. +expect_chat() { + local key=$1 code=$2 want=${3:-} out + for _ in $(seq 1 60); do + out=$(chat "$key") + if [ "${out%% *}" = "$code" ] && { [ -z "$want" ] || [[ "$out" == *"$want"* ]]; }; then + echo "ok: $out" + return 0 + fi + sleep 2 + done + fail "expected $code ${want:+containing '$want'}, got: $out" +} + +log "kind cluster $CLUSTER" +if ! kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then + kind create cluster --name "$CLUSTER" --wait 120s + CREATED_CLUSTER=1 +fi +kubectl config use-context "kind-$CLUSTER" >/dev/null + +log "build and load $IMAGE" +docker build -t "$IMAGE" . +kind load docker-image "$IMAGE" --name "$CLUSTER" + +log "install CRDs and controller" +kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - +kubectl apply --server-side -k deploy/crds +kubectl wait --for=condition=Established crd/providerkeys.aisix.api7.ai crd/models.aisix.api7.ai crd/apikeys.aisix.api7.ai --timeout=60s +k apply -k deploy/controller +k set image deploy/aisix-controller controller="$IMAGE" +k rollout status deploy/aisix-controller --timeout=120s + +log "controller seeds the resources Secret" +for _ in $(seq 1 60); do k get secret aisix-resources >/dev/null 2>&1 && break; sleep 1; done +k get secret aisix-resources >/dev/null || fail "aisix-resources Secret was not created" + +log "mock upstream and curl client" +k apply -f "$MANIFESTS/mock-openai.yaml" +k run curl --image=curlimages/curl:8.11.1 --restart=Never --dry-run=client -o yaml --command -- sleep infinity | k apply -f - +k rollout status deploy/mock-openai --timeout=120s +k wait --for=condition=Ready pod/curl --timeout=120s + +log "AISIX $AISIX_CHART_VERSION from its Helm chart, standalone mode" +helm repo add api7 https://charts.api7.ai >/dev/null 2>&1 || true +helm repo update api7 >/dev/null +# AISIX_CHART may point at a local chart .tgz; otherwise it is downloaded. +CHART=${AISIX_CHART:-} +if [ -z "$CHART" ]; then + CHART_DIR=$(mktemp -d) + CHART="$CHART_DIR/aisix-$AISIX_CHART_VERSION.tgz" + for i in 1 2 3; do + helm pull api7/aisix --version "$AISIX_CHART_VERSION" -d "$CHART_DIR" && break + # The chart index points at GitHub release assets; fetch through the API instead. + if command -v gh >/dev/null && gh release download "aisix-$AISIX_CHART_VERSION" \ + --repo api7/api7-helm-chart -p "aisix-$AISIX_CHART_VERSION.tgz" -D "$CHART_DIR" --clobber; then + break + fi + [ "$i" = 3 ] && fail "could not download the aisix chart" + sleep $((i * 5)) + done +fi +helm upgrade --install aisix "$CHART" -n "$NS" \ + --set controlPlane.enabled=false \ + --set standalone.existingSecret=aisix-resources \ + --set fullnameOverride=aisix \ + --set replicaCount=1 \ + --wait --timeout 5m + +log "1. resources are applied and served" +k apply -f "$MANIFESTS/resources.yaml" +for r in providerkey/mock model/chat apikey/caller; do + wait_condition "${r%%/*}" "${r##*/}" Accepted True + wait_condition "${r%%/*}" "${r##*/}" Programmed True +done +expect_chat caller-e2e-key 200 'upstream-model=upstream-a auth=Bearer sk-e2e-$dollar' + +log "2. an update reaches the gateway" +k patch model chat --type=merge -p '{"spec":{"model_name":"upstream-b"}}' +wait_condition model chat Programmed True +expect_chat caller-e2e-key 200 'upstream-model=upstream-b' + +log "3. the CRD schema rejects unknown fields" +if k apply -f - 2>/tmp/e2e-err <<'YAML' +apiVersion: aisix.api7.ai/v1alpha1 +kind: Model +metadata: + name: bogus +spec: + no_such_field: 1 +YAML +then + fail "unknown field was accepted" +fi +grep -q 'no_such_field' /tmp/e2e-err || fail "unexpected error: $(cat /tmp/e2e-err)" +echo "ok: $(cat /tmp/e2e-err)" + +log "4. a change the gateway rejects keeps the served version" +# Passes the CRD schema; the gateway refuses a direct model that also routes. +k patch model chat --type=merge -p '{"spec":{"routing":{"targets":[{"model":"e2e/chat"}]}}}' +wait_condition model chat Accepted False +k get model chat -o jsonpath='{.status.conditions[?(@.type=="Accepted")].message}'; echo +expect_chat caller-e2e-key 200 'upstream-model=upstream-b' +k patch model chat --type=json -p '[{"op":"remove","path":"/spec/routing"}]' +wait_condition model chat Accepted True +wait_condition model chat Programmed True + +log "5. a dangling reference is reported" +k patch model chat --type=merge -p '{"spec":{"provider_key_ref":{"name":"ghost"}}}' +wait_condition model chat Accepted False +k get model chat -o jsonpath='{.status.conditions[?(@.type=="Accepted")].message}' | grep -q 'ghost' \ + || fail "Accepted message does not name the missing ProviderKey" +expect_chat caller-e2e-key 200 'upstream-model=upstream-b' +k patch model chat --type=merge -p '{"spec":{"provider_key_ref":{"name":"mock"}}}' +wait_condition model chat Programmed True + +log "6. deleting the APIKey revokes it" +k delete apikey caller +expect_chat caller-e2e-key 401 + +log "PASS" From ab2d5c1d07b1b7d528f672d40a535035a6eae9f9 Mon Sep 17 00:00:00 2001 From: Shreemaan Abhishek Date: Tue, 10 Nov 2026 15:57:34 +0545 Subject: [PATCH 2/2] Revert "feat: first version of aisix-controller (#1)" This reverts commit d9c8531ced2ecb709f62a89f2284bbec038bc6fa. --- .dockerignore | 4 - .github/workflows/ci.yaml | 44 - .gitignore | 1 - Cargo.lock | 3383 ------------------- Cargo.toml | 35 - Dockerfile | 16 - Makefile | 26 - README.md | 117 - deploy/controller/deployment.yaml | 50 - deploy/controller/kustomization.yaml | 5 - deploy/controller/rbac.yaml | 36 - deploy/crds/apikeys.aisix.api7.ai.yaml | 251 -- deploy/crds/kustomization.yaml | 6 - deploy/crds/models.aisix.api7.ai.yaml | 533 --- deploy/crds/providerkeys.aisix.api7.ai.yaml | 285 -- deploy/kustomization.yaml | 5 - src/assemble.rs | 396 --- src/crd.rs | 127 - src/lib.rs | 5 - src/main.rs | 86 - src/reconcile.rs | 667 ---- src/render.rs | 377 --- src/schema.rs | 892 ----- tests/e2e/manifests/mock-openai.yaml | 76 - tests/e2e/manifests/resources.yaml | 43 - tests/e2e/run.sh | 185 - 26 files changed, 7651 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .github/workflows/ci.yaml delete mode 100644 .gitignore delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml delete mode 100644 Dockerfile delete mode 100644 Makefile delete mode 100644 README.md delete mode 100644 deploy/controller/deployment.yaml delete mode 100644 deploy/controller/kustomization.yaml delete mode 100644 deploy/controller/rbac.yaml delete mode 100644 deploy/crds/apikeys.aisix.api7.ai.yaml delete mode 100644 deploy/crds/kustomization.yaml delete mode 100644 deploy/crds/models.aisix.api7.ai.yaml delete mode 100644 deploy/crds/providerkeys.aisix.api7.ai.yaml delete mode 100644 deploy/kustomization.yaml delete mode 100644 src/assemble.rs delete mode 100644 src/crd.rs delete mode 100644 src/lib.rs delete mode 100644 src/main.rs delete mode 100644 src/reconcile.rs delete mode 100644 src/render.rs delete mode 100644 src/schema.rs delete mode 100644 tests/e2e/manifests/mock-openai.yaml delete mode 100644 tests/e2e/manifests/resources.yaml delete mode 100755 tests/e2e/run.sh diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 061fba0..0000000 --- a/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -target -.git -tests -deploy diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 543b448..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,44 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - name: fmt - run: cargo fmt --check - - name: clippy - run: cargo clippy --all-targets --locked -- -D warnings - - name: unit tests - run: cargo test --locked - - name: CRDs match aisix-core - run: make crds-check - - e2e: - needs: check - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - - uses: helm/kind-action@v1 - with: - install_only: true - - uses: azure/setup-helm@v4 - - name: e2e - run: tests/e2e/run.sh - env: - # The chart download falls back to `gh release download`. - GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore deleted file mode 100644 index ea8c4bf..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 902c8fa..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,3383 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "aisix-controller" -version = "0.1.0" -dependencies = [ - "aisix-core", - "anyhow", - "clap", - "futures", - "hex", - "k8s-openapi", - "kube", - "pretty_assertions", - "serde", - "serde_json", - "serde_yaml", - "sha2", - "thiserror 2.0.21", - "tokio", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "aisix-core" -version = "0.0.0" -source = "git+https://github.com/api7/aisix?tag=v1.9.0#7e070f0bde3f653c3b197ed18c5b9205b866a4c5" -dependencies = [ - "anyhow", - "arc-swap", - "chrono", - "chrono-tz", - "config", - "dashmap", - "hex", - "http", - "ipnet", - "jsonschema", - "once_cell", - "regex", - "ring", - "rustix", - "schemars 0.8.22", - "serde", - "serde_ignored", - "serde_json", - "serde_path_to_error", - "sha2", - "thiserror 1.0.69", - "tokio", - "url", - "uuid", - "yaml-rust2", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "annotate-snippets" -version = "0.12.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" -dependencies = [ - "anstyle", - "memchr", - "unicode-width", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "ar_archive_writer" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" -dependencies = [ - "object", -] - -[[package]] -name = "arc-swap" -version = "1.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4c94f57a570fce5d381be3289b3f79aef6332d72948fe82ab5772e423ccf92" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "borrow-or-share" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "chrono-tz" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" -dependencies = [ - "chrono", - "phf", -] - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "combine" -version = "4.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "config" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" -dependencies = [ - "nom", - "pathdiff", - "serde", - "serde_json", - "toml", - "yaml-rust2", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core_detect" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "darling" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 3.0.9", -] - -[[package]] -name = "darling_macro" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" -dependencies = [ - "darling_core", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "educe" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00371d2607edea062396ef1ce6ade65c8e915ac7a1277daa198208420f682c6" -dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "either" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - -[[package]] -name = "encoding_rs" -version = "0.8.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" -dependencies = [ - "cfg-if", - "core_detect", - "multiversion_no_op", - "rustversion", - "scopeguard", - "simdutf8", -] - -[[package]] -name = "encoding_rs_io" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" -dependencies = [ - "encoding_rs", -] - -[[package]] -name = "enum-ordinalize" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" -dependencies = [ - "enum-ordinalize-derive", -] - -[[package]] -name = "enum-ordinalize-derive" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "equivalent" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fancy-regex" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "find-msvc-tools" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" - -[[package]] -name = "fluent-uri" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" -dependencies = [ - "borrow-or-share", - "ref-cast", - "serde", -] - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fraction" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" -dependencies = [ - "num", - "num-bigint", -] - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "granit-parser" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f05586d4d4df3d47123207826c40b252728b438a1a07bad3c01ee6bea401df" -dependencies = [ - "arraydeque", - "smallvec", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" -dependencies = [ - "atomic-waker", - "bytes", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" -dependencies = [ - "http", - "hyper", - "hyper-util", - "log", - "rustls", - "rustls-native-certs", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "httparse", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" - -[[package]] -name = "icu_properties" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" - -[[package]] -name = "icu_provider" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "ipnet" -version = "2.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14ed8250fe4472173a765b89fea4b46f2c4aa12adc12a3e310f1dc6c108e041b" -dependencies = [ - "jiff-core", - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-core" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecfc96f3a325d3a9b0ee6db9a81a83b40b94e781021c044a0a7f4c4630a1e482" -dependencies = [ - "log", -] - -[[package]] -name = "jiff-static" -version = "0.2.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9522de39a5298859d9e844b660eb7d5e6fd1b63b9953f51535408a62f2d31b3" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.21", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "js-sys" -version = "0.3.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "json-patch" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" -dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 2.0.21", -] - -[[package]] -name = "jsonpath-rust" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c4668c489390e66d586fe2238ee8568473f7ac1607b411342f787c5e1bedd7" -dependencies = [ - "pest", - "pest_derive", - "regex", - "serde_json", - "thiserror 2.0.21", -] - -[[package]] -name = "jsonptr" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "jsonschema" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42a11b47492e6a2cc76e9c7bcc8077776cb31dac444e0795d7f61bcc6441c08" -dependencies = [ - "ahash", - "bytecount", - "data-encoding", - "email_address", - "fancy-regex", - "fraction", - "getrandom 0.3.4", - "itoa", - "jsonschema-regex", - "jsonschema-value", - "num-cmp", - "num-traits", - "percent-encoding", - "referencing", - "regex", - "serde", - "serde_json", - "strum", - "unicode-general-category", - "uuid-simd", -] - -[[package]] -name = "jsonschema-regex" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "107aeb6ff14b8deb5610096a8ce5c554ec89806b521147eaeb3cae05b07edb7a" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "jsonschema-value" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e281d6b177bb4834f2ffd6f92aa0443cffe29c996b251c8d94eb6f456186feba" -dependencies = [ - "ahash", - "bytecount", - "fraction", - "getrandom 0.3.4", - "memchr", - "num-cmp", - "num-traits", - "serde_json", - "zmij", -] - -[[package]] -name = "k8s-openapi" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab0d13801242174b1f3966907c3f32039f2d6b36c7ce82c61225d66ed5d1eb70" -dependencies = [ - "base64 0.22.1", - "jiff", - "serde", - "serde_json", -] - -[[package]] -name = "kube" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c73b082f549411623a4f8949bd24dcaedacb1511379c461c76d579fde301d021" -dependencies = [ - "k8s-openapi", - "kube-client", - "kube-core", - "kube-derive", - "kube-runtime", -] - -[[package]] -name = "kube-client" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae56b223bca490db1f1b115bfc606d411177511d7748d8cc019a7e3b2935430b" -dependencies = [ - "base64 0.23.1", - "bytes", - "either", - "futures", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-timeout", - "hyper-util", - "jiff", - "jsonpath-rust", - "k8s-openapi", - "kube-core", - "pem", - "percent-encoding", - "rustls", - "rustls-platform-verifier", - "secrecy", - "serde", - "serde-saphyr", - "serde_json", - "thiserror 2.0.21", - "tokio", - "tokio-util", - "tower", - "tower-http", - "tracing", -] - -[[package]] -name = "kube-core" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6de84b47fc1aa35ba0ef9059ef54b47c8b47f0979afd15487e525282f2d73fb9" -dependencies = [ - "derive_more", - "form_urlencoded", - "http", - "jiff", - "json-patch", - "k8s-openapi", - "schemars 1.2.3", - "serde", - "serde-value", - "serde_json", - "thiserror 2.0.21", -] - -[[package]] -name = "kube-derive" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de1fb46ac34371491ac0277c13e0e1bb9288000e64c86ca3d91343754818e64e" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn 3.0.9", -] - -[[package]] -name = "kube-runtime" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dea60005024b6ec1819c426209aa29b9eaca64f6b3988ab73c55f43fa484392" -dependencies = [ - "ahash", - "async-broadcast", - "async-stream", - "backon", - "educe", - "futures", - "hashbrown 0.16.1", - "hostname", - "json-patch", - "k8s-openapi", - "kube-client", - "parking_lot", - "pin-project", - "serde", - "serde_json", - "thiserror 2.0.21", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "lazy_static" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" - -[[package]] -name = "libc" -version = "0.2.191" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "micromap" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" - -[[package]] -name = "mime" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multiversion_no_op" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-cmp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.39.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openssl-probe" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" - -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pem" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" -dependencies = [ - "base64 0.23.1", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" -dependencies = [ - "memchr", - "pest_stack_guard", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "pest_meta" -version = "2.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" -dependencies = [ - "pest", -] - -[[package]] -name = "pest_stack_guard" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac6154fd4418b8f0b82532c59c26f303f9ca59a64c883e8ce3971fd82733439" -dependencies = [ - "psm", - "stacker", -] - -[[package]] -name = "phf" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_shared" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "psm" -version = "0.1.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" -dependencies = [ - "ar_archive_writer", - "cc", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "referencing" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f42818036d3b37cda6bd4c494543158a36e431cb717015ef7c93acc6ba214d3b" -dependencies = [ - "ahash", - "fluent-uri", - "getrandom 0.3.4", - "hashbrown 0.17.1", - "itoa", - "micromap", - "parking_lot", - "percent-encoding", - "serde_json", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" - -[[package]] -name = "rustls-webpki" -version = "0.103.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "chrono", - "dyn-clone", - "schemars_derive 0.8.22", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" -dependencies = [ - "dyn-clone", - "ref-cast", - "schemars_derive 1.2.3", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals 0.29.1", - "syn 2.0.119", -] - -[[package]] -name = "schemars_derive" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals 0.30.0", - "syn 3.0.9", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "secrecy" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" -dependencies = [ - "zeroize", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-saphyr" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43fb82d550b07e6f929a58d7d0935182602f48ddead875f5ee313c41c8f07fe" -dependencies = [ - "annotate-snippets", - "base64 0.23.1", - "encoding_rs_io", - "granit-parser", - "nohash-hasher", - "num-traits", - "serde_core", - "smallvec", - "zmij", -] - -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "serde_derive_internals" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "serde_ignored" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_json" -version = "1.0.154" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.16.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stacker" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl 2.0.21", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tinystr" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.54.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "slab", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" -dependencies = [ - "base64 0.22.1", - "bitflags", - "bytes", - "http", - "http-body", - "mime", - "percent-encoding", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "unicode-general-category" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" - -[[package]] -name = "unicode-ident" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" - -[[package]] -name = "unicode-width" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" -dependencies = [ - "utf_types", -] - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "utf_types" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" - -[[package]] -name = "uuid" -version = "1.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a06f08a856b009bc7bbaf7d3e79808595d032dd19074c0c2ef26e49e03e6bdc" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "uuid-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" -dependencies = [ - "cfg-if", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.9", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.130" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yaml-rust2" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" -dependencies = [ - "arraydeque", - "encoding_rs", - "hashlink", -] - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 942546b..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -[package] -name = "aisix-controller" -version = "0.1.0" -edition = "2021" -rust-version = "1.93" -license = "Apache-2.0" -description = "Kubernetes controller that reconciles AISIX custom resources into an AISIX gateway" -publish = false - -[dependencies] -# The gateway's own types, schemas and resources-file pipeline. Bump the tag -# together with AISIX_VERSION in the Makefile and the e2e chart version. -aisix-core = { git = "https://github.com/api7/aisix", tag = "v1.9.0" } - -anyhow = "1" -clap = { version = "4", features = ["derive", "env"] } -futures = "0.3" -k8s-openapi = { version = "0.29", features = ["v1_33"] } -kube = { version = "5", default-features = false, features = ["client", "config", "runtime", "derive", "rustls-tls", "ring"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_yaml = "0.9" -sha2 = "0.10" -hex = "0.4" -thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "sync"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } - -[dev-dependencies] -pretty_assertions = "1" - -[profile.release] -lto = "thin" -strip = true diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8f7b9fd..0000000 --- a/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM rust:1.93-bookworm AS build -WORKDIR /src -COPY Cargo.toml Cargo.lock ./ -COPY src ./src -RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/usr/local/cargo/git \ - --mount=type=cache,target=/src/target \ - cargo build --release --locked \ - && cp target/release/aisix-controller /aisix-controller - -FROM gcr.io/distroless/cc-debian12:nonroot -COPY --from=build /aisix-controller /aisix-controller -USER 65532:65532 -ENTRYPOINT ["/aisix-controller"] -CMD ["run"] diff --git a/Makefile b/Makefile deleted file mode 100644 index 64ce848..0000000 --- a/Makefile +++ /dev/null @@ -1,26 +0,0 @@ -IMAGE ?= aisix-controller:dev - -.PHONY: build test lint crds crds-check image e2e - -build: - cargo build - -test: - cargo test --locked - -lint: - cargo fmt --check - cargo clippy --all-targets --locked -- -D warnings - -# Regenerate deploy/crds from aisix-core's schemas. -crds: - cargo run -q --locked -- crds - -crds-check: crds - git diff --exit-code deploy/crds - -image: - docker build -t $(IMAGE) . - -e2e: - IMAGE=$(IMAGE) tests/e2e/run.sh diff --git a/README.md b/README.md deleted file mode 100644 index b093996..0000000 --- a/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# aisix-controller - -A Kubernetes controller for the [AISIX](https://github.com/api7/aisix) AI gateway. It reconciles AISIX custom resources into the gateway's standalone resources file, so models, provider keys and caller API keys are managed with `kubectl` and GitOps. - -v0.1 covers the request path: `ProviderKey`, `Model` and `APIKey`. - -## How it works - -``` -ProviderKey / Model / APIKey + Secrets - │ watch - â–¼ -aisix-controller ── render ── validate with the gateway's own pipeline - │ - ├─► Secret aisix-resources (resources.yaml, standalone format v1) - └─► Deployment aisix (pod-template hash annotation → rollout) -``` - -1. The controller watches the three resource kinds, the Secrets they reference, and the gateway Deployment in its namespace. -2. It renders every resource into one `resources.yaml` and checks it with `aisix_core::filesource::load_from_str`, the same code the gateway runs at boot. -3. A resource the gateway would reject is reported on its status, and the version currently served stays in the file. A resource with no served version is left out. The others still apply. -4. When the document changes, the controller writes it to the gateway's resources Secret and stamps its hash on the Deployment's pod template, which rolls the pods. - -Status conditions on every resource: - -| Condition | True when | -|---|---| -| `Accepted` | The current spec is valid. | -| `Programmed` | The gateway serves the current spec (the rollout carrying it finished). | - -## Resources - -The spec is the AISIX resource document, with the gateway's own field names. Credentials and references are replaced by Kubernetes references: - -| Kind | Replaced field | Kubernetes field | -|---|---|---| -| `ProviderKey` | `api_key` / `secret` | `api_key_secret_ref: {name, key}` | -| `Model` | `provider_key_id` | `provider_key_ref: {name}` (a ProviderKey's `metadata.name`) | -| `APIKey` | `key_hash` | `key_secret_ref: {name, key}` (plaintext; the controller writes only its SHA-256) | - -`display_name` defaults to `metadata.name`. Set it when the AISIX name is not a valid Kubernetes name, for example a model called `openai/gpt-4o`. Id-form references (`model_id`, `allowed_model_ids`, ...) are not accepted; use the name forms. - -```yaml -apiVersion: aisix.api7.ai/v1alpha1 -kind: ProviderKey -metadata: - name: openai -spec: - provider: openai - api_key_secret_ref: {name: llm-creds, key: openai} ---- -apiVersion: aisix.api7.ai/v1alpha1 -kind: Model -metadata: - name: gpt-4o -spec: - provider: openai - model_name: gpt-4o - provider_key_ref: {name: openai} ---- -apiVersion: aisix.api7.ai/v1alpha1 -kind: APIKey -metadata: - name: team-a -spec: - allowed_models: ["gpt-4o"] - key_secret_ref: {name: caller-keys, key: team-a} -``` - -`kubectl get providerkeys,models.aisix.api7.ai,apikeys` shows `Accepted` and `Programmed` columns. - -## Schemas - -The CRD schemas in `deploy/crds` are generated from aisix-core's strict resource schemas (`resource_root_schema`), pinned by the `aisix-core` git tag in `Cargo.toml`: - -```bash -make crds # regenerate -make crds-check # CI: fail when deploy/crds is stale -``` - -The generator converts aisix-core's draft-07 schemas to structural OpenAPI. Types, required fields, enums, ranges, lengths and patterns carry over. Cross-field rules that a structural schema cannot express (for example "a direct model cannot also route") are dropped from the CRD and enforced by the controller's validation step, which reports them on `Accepted`. - -## Install - -The controller and the gateway run in the same namespace. Install the controller first, so the resources Secret exists when the gateway starts. - -```bash -kubectl create namespace aisix -kubectl apply --server-side -k deploy/crds -kubectl -n aisix apply -k deploy/controller # set the image with a kustomize overlay - -helm repo add api7 https://charts.api7.ai -helm install aisix api7/aisix -n aisix \ - --set controlPlane.enabled=false \ - --set standalone.existingSecret=aisix-resources \ - --set fullnameOverride=aisix -``` - -Controller flags: `--namespace` (defaults to its own), `--secret-name` (`aisix-resources`), `--secret-key` (`resources.yaml`), `--deployment-name` (`aisix`), `--resync-seconds` (`60`). - -## Development - -```bash -make test # unit tests -make lint # fmt + clippy -make e2e # kind cluster + AISIX chart + controller + mock upstream -KEEP=1 make e2e # keep the cluster afterwards -``` - -The e2e suite needs Docker, kind, kubectl and helm. It covers: resources served end to end (including a `$` in a credential), updates, CRD field validation, a gateway-rejected change keeping the served version, a dangling reference, and key revocation. - -## v0.1 limitations - -- Every configuration change rolls the gateway pods, because standalone AISIX reloads only on SIGHUP. Node-local rate-limit counters reset on each rollout. A push endpoint in AISIX would remove this. -- One namespace, one gateway Deployment and one controller replica (no leader election). -- Three kinds. Guardrails, MCP servers, A2A agents, cache and rate-limit policies, OIDC providers, claim mappings, passthrough routes and exporters come next. -- No admission webhook: invalid resources are accepted by the API server and reported on status. diff --git a/deploy/controller/deployment.yaml b/deploy/controller/deployment.yaml deleted file mode 100644 index 4ac40f5..0000000 --- a/deploy/controller/deployment.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: aisix-controller - labels: - app.kubernetes.io/name: aisix-controller -spec: - # One writer per gateway; no leader election in v0.1. - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app.kubernetes.io/name: aisix-controller - template: - metadata: - labels: - app.kubernetes.io/name: aisix-controller - spec: - serviceAccountName: aisix-controller - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - containers: - - name: controller - image: aisix-controller:dev - imagePullPolicy: IfNotPresent - args: - - run - - --secret-name=aisix-resources - - --deployment-name=aisix - env: - - name: POD_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - - name: RUST_LOG - value: info,kube=warn - resources: - requests: - cpu: 10m - memory: 32Mi - limits: - memory: 256Mi - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] diff --git a/deploy/controller/kustomization.yaml b/deploy/controller/kustomization.yaml deleted file mode 100644 index 4b14a26..0000000 --- a/deploy/controller/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - rbac.yaml - - deployment.yaml diff --git a/deploy/controller/rbac.yaml b/deploy/controller/rbac.yaml deleted file mode 100644 index 62c8a2f..0000000 --- a/deploy/controller/rbac.yaml +++ /dev/null @@ -1,36 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: aisix-controller ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: aisix-controller -rules: - - apiGroups: ["aisix.api7.ai"] - resources: ["providerkeys", "models", "apikeys"] - verbs: ["get", "list", "watch"] - - apiGroups: ["aisix.api7.ai"] - resources: ["providerkeys/status", "models/status", "apikeys/status"] - verbs: ["get", "patch", "update"] - # Reads the Secrets that resources reference; writes the resources Secret. - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "list", "watch", "create", "patch", "update"] - # Stamps the resources hash on the gateway pod template to roll it out. - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get", "list", "watch", "patch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: aisix-controller -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: aisix-controller -subjects: - - kind: ServiceAccount - name: aisix-controller diff --git a/deploy/crds/apikeys.aisix.api7.ai.yaml b/deploy/crds/apikeys.aisix.api7.ai.yaml deleted file mode 100644 index 273f408..0000000 --- a/deploy/crds/apikeys.aisix.api7.ai.yaml +++ /dev/null @@ -1,251 +0,0 @@ -# Generated by `aisix-controller crds` from aisix-core. Do not edit. -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: apikeys.aisix.api7.ai -spec: - group: aisix.api7.ai - names: - kind: APIKey - plural: apikeys - shortNames: - - aikey - singular: apikey - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .status.conditions[?(@.type=="Accepted")].status - name: Accepted - type: string - - jsonPath: .status.conditions[?(@.type=="Programmed")].status - name: Programmed - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: AISIX APIKey resource. - properties: - apiVersion: - type: string - kind: - type: string - metadata: - type: object - spec: - properties: - allowed_agents: - description: 'A2A agents this key may reach, named by their registered names. Entries are matched as single-`*` globs, mirroring `allowed_models`: `"*"` grants every agent and an entry without a `*` matches one agent exactly. When omitted, set to `null`, or set to an empty list, the key has no A2A agent access — access is granted explicitly.' - items: - type: string - nullable: true - type: array - allowed_models: - description: Model names this key may use, matched as single-`*` globs. Read only when `allowed_model_ids` is absent; ignored entirely when it is present. When both are omitted the key may use no model — model access is granted explicitly. - items: - type: string - type: array - allowed_routes: - description: 'Passthrough routes this key may use, named by their registered names. Entries are matched as single-`*` globs, mirroring `allowed_models`: `"*"` grants every route and an entry without a `*` matches one route exactly. When omitted, set to `null`, or set to an empty list, the key may use no passthrough route — access is granted explicitly.' - items: - type: string - nullable: true - type: array - disabled: - description: Administratively disabled. A disabled key is rejected with `401` until it is enabled again; the key itself is preserved. Treated as `false` when omitted. - type: boolean - display_name: - description: Operator-facing label for this key, as shown in the dashboard. Read by the `${request.api_key.name}` header template (AISIX-Cloud#1112) and as the `api_key_name` metric label; never used for authentication or routing. Defaults to `metadata.name`. - nullable: true - type: string - expires_at: - description: RFC 3339 timestamp after which the key stops authenticating. Requests presenting an expired key are rejected with `401`. When omitted or set to `null`, the key never expires. - nullable: true - type: string - jwt_provider: - description: Name of the `oidc_providers` entry permitted to assert this key's `jwt_subject`. A subject is only ever resolved for the trust provider named here, so a second trusted provider cannot mint a token impersonating this provider's identity of the same name. Required whenever `jwt_subject` is set; ignored otherwise. - minLength: 1 - nullable: true - type: string - jwt_subject: - description: External identity bound to this key for JWT authentication. When a request presents a valid JWT issued by the `oidc_providers` entry named in `jwt_provider`, the value of that provider's `identity_claim` selects the key whose `jwt_subject` equals it, and the request proceeds with this key's permissions, rate limits, and budget. The `(jwt_provider, jwt_subject)` pair is unique within the environment. When omitted, the key is never selected by JWT authentication. - minLength: 1 - nullable: true - type: string - key_secret_ref: - description: Secret key holding the caller's plaintext API key. The controller writes only its SHA-256 hash. - properties: - key: - description: Key within the Secret. - minLength: 1 - type: string - name: - description: Secret name, in the resource's namespace. - minLength: 1 - type: string - required: - - name - - key - type: object - mcp_access: - description: 'This key''s own layer of the MCP tool ACL, as namespaced `__` glob patterns, or as `allow_ids` / `deny_ids` entries naming the server by resource id. It is intersected with the environment and team MCP access policies: every present layer must allow a tool and no layer may deny it. When omitted the key adds no constraint of its own — but with no layer present anywhere the grant is empty, so MCP access is always granted explicitly.' - nullable: true - properties: - allow: - description: |- - Namespaced `__` patterns this key allows, intersected with the environment and team layers. Same single-`*` glob matching a policy's `allow` uses; an empty list leaves the key no MCP access, and `["*"]` narrows nothing (useful with `deny` alone). - - Required on the write path and defaulted by the runtime loader, for the reason given on [`McpPolicy::allow`]. Read only when `allow_ids` is absent; ignored entirely when it is present. - items: - type: string - type: array - deny: - description: Namespaced `__` patterns subtracted from this key's effective grant, using the same single-`*` glob matching as `allow`. Read only when `deny_ids` is absent. - items: - type: string - type: array - type: object - mcp_rate_limits: - additionalProperties: - description: |- - Limits for one API key's calls to one MCP server (`ApiKey::mcp_rate_limits`). - - Carries only the request-count dimensions of [`RateLimit`]: an MCP `tools/call` commits no tokens, so a token-rate cap here would never be consumed — the shape leaves `tpm`/`tpd` out rather than accepting a knob that is silently inert. - properties: - concurrency: - description: Max concurrent in-flight tool calls. - minimum: 0.0 - nullable: true - type: integer - rpd: - description: Tool calls per 86,400-second window. - minimum: 0.0 - nullable: true - type: integer - rph: - description: Tool calls per 3,600-second window. - minimum: 0.0 - nullable: true - type: integer - rpm: - description: Tool calls per 60-second window. - minimum: 0.0 - nullable: true - type: integer - rps: - description: Tool calls per 1-second window. - minimum: 0.0 - nullable: true - type: integer - type: object - description: |- - Per-MCP-server limits for this key, keyed by the registered MCP server name — the `` half of the `__` names the gateway exposes. A `tools/call` is metered against the entry for the server it targets **and** the key's own `rate_limit`, each in its own counter, so a burst against one server never consumes another's budget. A server with no entry here is bounded by `rate_limit` alone. Only tool calls are metered; the `initialize` / `tools/list` handshake is not. - - Read only when `mcp_rate_limits_by_id` is absent; ignored entirely when it is present. - nullable: true - type: object - rate_limit: - description: Request, token, and concurrency limits for this key. - nullable: true - properties: - concurrency: - description: Max concurrent in-flight requests. - minimum: 0.0 - type: integer - rpd: - description: Requests per 86,400-second window. - minimum: 0.0 - type: integer - rph: - description: Requests per 3,600-second window. There is no per-hour token limit field. - minimum: 0.0 - type: integer - rpm: - description: Requests per 60-second window. - minimum: 0.0 - type: integer - rps: - description: Requests per 1-second window. There is no per-second token limit field. - minimum: 0.0 - type: integer - tpd: - description: Tokens per 86,400-second window. - minimum: 0.0 - type: integer - tpm: - description: Tokens per 60-second window. - minimum: 0.0 - type: integer - type: object - tags: - additionalProperties: - maxLength: 128 - minLength: 1 - type: string - description: 'Cost-attribution tags, as tag key to tag value — for example `{"department": "rnd", "subsidiary": "emea"}`. Every request this key authenticates is attributed to one value per tag key, resolved when the request is made with the nearest setting winning: this key''s own value, else the value in the `tags` of the team `team_id` names, else the value in the `tags` of the member `user_id` names. Different keys combine, so a request can carry tags from all three. A request authenticated by a JWT takes the value its identity provider''s `tag_claims` reads from the verified token ahead of all three; nothing else in a request sets a tag. Keys match `^[a-z][a-z0-9_]{0,62}$`, values are 1 to 128 characters, and at most 32 entries are allowed. Omitted or empty means the key adds no tags of its own.' - maxProperties: 32 - type: object - team_id: - description: Team this API key belongs to. Used for matching team-scope rate limit policies. - minLength: 1 - nullable: true - type: string - user_id: - description: Org member who owns this key. Used for matching member-scope rate limit policies. - minLength: 1 - nullable: true - type: string - user_name: - description: Readable display name of the owning member. Used only for telemetry labels alongside `user_id`; never used for authentication or routing. Read only when no `users` document exists for `user_id`; when one does, its name is used instead. When neither provides a name, telemetry reports the user name as `"unknown"`. - nullable: true - type: string - required: - - key_secret_ref - type: object - status: - properties: - conditions: - items: - properties: - lastTransitionTime: - format: date-time - type: string - message: - type: string - observedGeneration: - format: int64 - type: integer - reason: - type: string - status: - enum: - - 'True' - - 'False' - - Unknown - type: string - type: - type: string - required: - - type - - status - - lastTransitionTime - - reason - - message - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - observedGeneration: - format: int64 - type: integer - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} diff --git a/deploy/crds/kustomization.yaml b/deploy/crds/kustomization.yaml deleted file mode 100644 index db7ebea..0000000 --- a/deploy/crds/kustomization.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - providerkeys.aisix.api7.ai.yaml - - models.aisix.api7.ai.yaml - - apikeys.aisix.api7.ai.yaml diff --git a/deploy/crds/models.aisix.api7.ai.yaml b/deploy/crds/models.aisix.api7.ai.yaml deleted file mode 100644 index 806bac3..0000000 --- a/deploy/crds/models.aisix.api7.ai.yaml +++ /dev/null @@ -1,533 +0,0 @@ -# Generated by `aisix-controller crds` from aisix-core. Do not edit. -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: models.aisix.api7.ai -spec: - group: aisix.api7.ai - names: - kind: Model - plural: models - shortNames: - - aimodel - singular: model - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .status.conditions[?(@.type=="Accepted")].status - name: Accepted - type: string - - jsonPath: .status.conditions[?(@.type=="Programmed")].status - name: Programmed - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: AISIX Model resource. - properties: - apiVersion: - type: string - kind: - type: string - metadata: - type: object - spec: - properties: - allowed_cidrs: - description: Client IP allowlist in CIDR notation. Empty or absent allows all clients. - items: - minLength: 1 - type: string - type: array - auto_prompt_caching: - description: Automatic prompt-cache breakpoint injection for Claude models on the Anthropic API, Amazon Bedrock and Vertex AI. Omit to leave injection off. - properties: - enabled: - description: Whether automatic prompt-cache injection is active for this model. - type: boolean - ttl: - description: 'Cache lifetime for injected breakpoints: `5m` (default when omitted) or `1h`. A `1h` cache write costs 2x the base input rate versus 1.25x for `5m`, so it pays off only when the cached prefix is reused across a longer session. Some Claude models on Amazon Bedrock accept only the 5-minute cache, and Bedrock rejects a request to such a model that asks for `1h`.' - enum: - - 5m - - 1h - type: string - required: - - enabled - type: object - background_model_check: - description: Direct-model-only background health-check configuration. - properties: - enabled: - description: Whether background health checks are enabled for this model. - type: boolean - ignore_statuses: - description: Upstream status codes to ignore when evaluating background check failures. - items: - maximum: 599.0 - minimum: 100.0 - type: integer - type: array - interval_seconds: - description: 'Seconds between background health checks. Minimum: 5.' - minimum: 5.0 - type: integer - max_tokens: - description: Maximum completion tokens requested during each background health check. - minimum: 1.0 - type: integer - prompt: - description: Prompt sent to the model during each background health check. - minLength: 1 - type: string - stale_after_seconds: - description: Seconds after which the last completed background check is considered stale. - minimum: 1.0 - type: integer - timeout_seconds: - description: 'Request timeout in seconds for each background health check. Minimum: 1.' - minimum: 1.0 - type: integer - required: - - enabled - - interval_seconds - - max_tokens - - prompt - - stale_after_seconds - - timeout_seconds - type: object - cooldown: - description: 'Direct-model-only request-path cooldown configuration. Cooldown is opt-in: omit this field, or leave `enabled` unset, and the model is never taken out of rotation by request-path failures.' - properties: - default_seconds: - description: Cooldown TTL in seconds when the upstream did not supply a `Retry-After` header or `honor_retry_after` is `false`. - minimum: 0.0 - type: integer - enabled: - description: Whether cooldown is active for this model. Cooldown is off unless this is set to `true`, so a model that omits it stays in rotation regardless of upstream failures. - type: boolean - honor_retry_after: - description: Whether to use the upstream's `Retry-After` header as the cooldown TTL when it contains seconds. - type: boolean - max_seconds: - description: Upper bound on cooldown TTL when `Retry-After` is used. - minimum: 1.0 - type: integer - trigger_on_timeout: - description: Whether request-path timeouts trigger cooldown. - type: boolean - trigger_on_transport: - description: Whether transport, decode, or stream-abort errors trigger cooldown. - type: boolean - trigger_statuses: - description: Status codes that trigger cooldown, covering authentication failures, rate limits, and transient server errors. Caller-side validation errors such as `400`, `403`, and `422` are excluded. - items: - maximum: 599.0 - minimum: 100.0 - type: integer - type: array - type: object - cost: - description: Per-token cost for budget tracking. Omit it when cost tracking is not needed. - properties: - input_per_1k: - description: Prompt token cost in USD per 1,000 tokens. - minimum: 0.0 - type: number - output_per_1k: - description: Completion token cost in USD per 1,000 tokens. - minimum: 0.0 - type: number - required: - - input_per_1k - - output_per_1k - type: object - display_name: - description: Operator-facing unique label. Surfaces on `/v1/models`, `req.model` on chat completions, `ApiKey.allowed_models`, and the dashboard model list. `Resource::name()` returns this. Defaults to `metadata.name`. - minLength: 1 - type: string - effort_mapping: - description: |- - Direct-model-only mapping from a client-requested reasoning effort to the value sent upstream. After resolving the final target the gateway looks the requested effort up once — the exact entry first, then the `*` entry — and never looks the result up again. A requested value that matches neither is sent unchanged. - - Three entries are reserved. The empty-string key matches a request that sets no effort at all, meaning the effort field is absent, `null`, or empty, and its value is added to the outbound request. The `*` key matches any other present value that has no entry of its own, and never matches a request that sets no effort. A `null` value removes the effort field from the outbound request so the provider's own default applies; mapping `*` to `null` therefore strips the effort from every request whose value has no entry of its own. The empty-string key may not be mapped to `null`, because a request that sets no effort has nothing to remove, and no entry may be mapped to an empty value. - - On the Anthropic messages and token-counting endpoints only `output_config.effort` is read and rewritten. A `thinking` block is not an effort setting for this mapping, so a request that carries `thinking` but no `output_config.effort` sets no effort and the empty-string entry applies to it. When such a request is dispatched to a provider that does not accept the Anthropic protocol, the upstream reasoning effort is derived from the mapped `output_config.effort` when one is present and from `thinking` otherwise, except that an entry that removed the effort sends no effort at all. A request that sets `thinking.type: disabled` is never given an effort by this mapping: on the Anthropic protocol no entry writes a tier to it, though an entry that removes the effort still removes it, and on any other protocol it always sends the `none` effort. - - Claude families that reject `output_config.effort` (Haiku 4.5, Sonnet 4.5 and earlier) are the exception on those two endpoints when the model speaks the Anthropic protocol: an effort the empty-string entry would add to a request that sets none becomes `thinking: {type: enabled, budget_tokens}` instead (minimal and low 1024, medium 2048, high 4096, xhigh 8192, max 16384). The budget stays below the request's `max_tokens`, a request on the messages endpoint without `max_tokens` is sent the budget plus 4096, and a `max_tokens` of 1024 or less gets no thinking. A request that already carries a `thinking` block is sent as written, so the empty-string entry has no effect on it for these families, and a value with no budget is not sent at all. - properties: {} - type: object - embedding: - description: Embedding-modality metadata. Present on direct Models that serve an OpenAI-compatible `/v1/embeddings` endpoint (and can be referenced by a semantic router's `embedding_model`). - properties: - dimensions: - description: Output vector dimensionality. Used to validate vectors, key the example-vector cache, and (for endpoints that support it) request a reduced output size. - minimum: 1.0 - type: integer - normalize: - description: Whether the endpoint already returns L2-normalized vectors. When `false`, the gateway normalizes before computing cosine similarity. Defaults to `true`. - type: boolean - required: - - dimensions - type: object - ensemble: - description: Ensemble configuration for panel calls and judge synthesis. - properties: - judge: - description: Direct model that combines successful panel responses. - properties: - model: - description: Model alias for the direct model that synthesizes panel responses. Read only when `model_id` is absent. - minLength: 1 - type: string - synthesis_prompt: - description: Override for the built-in synthesis prompt template. - minLength: 1 - type: string - type: object - min_responses: - description: Minimum successful panel responses required before judge synthesis. When omitted, the gateway requires the smaller of 2 and the panel size. - minimum: 1.0 - type: integer - panel: - description: Direct models called concurrently for each ensemble request. - items: - description: One member of an ensemble panel. The member model is named either by `model` (its display name) or by `model_id` (its resource id); a member must carry at least one of the two. - properties: - model: - description: Model alias for a direct model that receives one panel request. Read only when `model_id` is absent. - minLength: 1 - type: string - seed: - description: Sampling seed for this panel member. - minimum: 0.0 - type: integer - temperature: - description: Sampling temperature for this panel member. Omit it to keep the request's temperature. - minimum: 0.0 - type: number - weight: - description: Reserved for a future voting/quorum strategy. AISIX currently ignores this field. - minimum: 0.0 - type: integer - type: object - minItems: 1 - type: array - timeout_ms: - description: Per-call upstream deadline applied to each panel member and the judge. Set `0` or omit it to disable the ensemble-level deadline. - minimum: 0.0 - type: integer - required: - - judge - - panel - type: object - model_name: - description: Upstream model identifier sent in provider requests. Routing and ensemble models leave this field unset. - minLength: 1 - type: string - pricing_key: - description: Name of a shared `pricing` document to take the per-token cost from, instead of setting `cost` on this model. The environment's own pricing documents are searched first and the shared catalog second; when the key matches neither, `cost` applies. Editing the pricing document repricies every model naming it, with no change to the models themselves. - maxLength: 255 - minLength: 1 - type: string - provider: - description: Upstream vendor identity used for dispatch, compatibility checks, telemetry, and access logs. Routing and ensemble models leave this field unset. - maxLength: 64 - minLength: 1 - pattern: ^[a-z0-9][a-z0-9._-]*$ - type: string - provider_key_ref: - description: ProviderKey, in the same namespace, this model authenticates with. Direct models need it; routing, ensemble and semantic models leave it unset. - properties: - name: - description: ProviderKey `metadata.name`. - minLength: 1 - type: string - required: - - name - type: object - rate_limit: - description: Request, token, and concurrency limits for this model. - properties: - concurrency: - description: Max concurrent in-flight requests. - minimum: 0.0 - type: integer - rpd: - description: Requests per 86,400-second window. - minimum: 0.0 - type: integer - rph: - description: Requests per 3,600-second window. There is no per-hour token limit field. - minimum: 0.0 - type: integer - rpm: - description: Requests per 60-second window. - minimum: 0.0 - type: integer - rps: - description: Requests per 1-second window. There is no per-second token limit field. - minimum: 0.0 - type: integer - tpd: - description: Tokens per 86,400-second window. - minimum: 0.0 - type: integer - tpm: - description: Tokens per 60-second window. - minimum: 0.0 - type: integer - type: object - retries: - description: Retry attempts against this model after a retryable upstream failure, before the request gives up (or, inside a model group, fails over to the next target). Absent falls back to the group's `routing.retries`, then to the deployment-wide `upstream.retries` default. - minimum: 0.0 - type: integer - routing: - description: Virtual routing configuration. When set, the gateway selects a target from `routing.targets` and uses that target model's `provider`, `model_name`, and `provider_key_id` fields for upstream dispatch. - properties: - fallback_on_statuses: - description: Additional upstream HTTP status codes that participate in retries and failover. By default a non-429 4xx response is treated as a caller error and returned as-is; providers that use 4xx codes for transient conditions (model overload, queue full, quota exhaustion) can be listed here, for example `[408, 409]`. 5xx codes are already retryable, so listing them changes nothing. Authentication (`401`/`403`) and validation (`400`) codes should only be listed when the provider is known to use them for transient failures. - items: - maximum: 599.0 - minimum: 400.0 - type: integer - type: array - hash_on: - description: 'Where the `consistent_hash` hash key comes from: an ordered chain of sources, the first non-empty value winning. Defaults to the `x-aisix-routing-key` request header, falling back to the caller''s API key id. Only valid with `strategy: consistent_hash`.' - items: - description: One source for the `consistent_hash` hash key. Sources are tried in order; the first one that yields a non-empty value wins. - properties: - name: - description: The header or cookie name to read. Required for `header` and `cookie` sources; not accepted for `api_key` or `client_ip`. - minLength: 1 - type: string - type: - description: Which request attribute supplies the hash key. - enum: - - header - - cookie - - api_key - - client_ip - type: string - required: - - type - type: object - minItems: 1 - type: array - max_fallbacks: - description: Max number of later targets to attempt after the initial target fails permanently. When omitted, all later targets may be attempted. - minimum: 0.0 - type: integer - retries: - description: Retry attempts on the current target before failing over, applied to every target that does not set its own `retries`. Absent falls back to the deployment-wide `upstream.retries` default. - minimum: 0.0 - type: integer - retry_on_429: - description: Whether upstream 429 participates in retries and failover. - type: boolean - strategy: - description: Strategy used to select a target for each request. - enum: - - round_robin - - consistent_hash - - failover - - least_cost - - least_latency - - least_busy - type: string - targets: - description: Ordered set of direct models available to this routing model. - items: - description: One destination in a routing configuration. The target model is named either by `model` (its display name) or by `model_id` (its resource id); a target must carry at least one of the two. - properties: - model: - description: |- - Model alias for a direct model that can receive routed traffic. Read only when `model_id` is absent. - - Defaulted at the type level and required by the schemas instead — as one half of a "`model` or `model_id`" alternative, so a target naming its model by id alone validates while one naming it neither way is still rejected. - minLength: 1 - type: string - priority: - description: Priority tier, default `0`; a higher value is preferred (the APISIX node-priority convention — give backup targets `-1`). The strategy orders targets within each tier; a lower tier is only tried when every higher-tier target failed or is unavailable. - type: integer - tags: - description: Tags for tag/metadata-conditional routing. When a request carries routing tags, only targets whose tags intersect the request's are eligible; a target tagged `"default"` is the fallback used when nothing matches and for untagged requests. Absent/empty means the target opts out of tag filtering (eligible only via the default fallback once any sibling target is tagged). The configured strategy then orders whatever set survives. - items: - minLength: 1 - type: string - type: array - weight: - description: Target weight, default `1`. Used by `round_robin` (rotation share), `consistent_hash` (share of the hash ring), and `least_busy` (in-flight divided by weight). `failover`, `least_cost`, and `least_latency` accept the field but do not use it. - minimum: 0.0 - type: integer - type: object - minItems: 1 - type: array - when_all_unavailable: - description: Policy to apply when every target is unavailable because of runtime health or cooldown state. - enum: - - fail - - try_anyway - type: string - required: - - targets - type: object - semantic: - description: Semantic-routing configuration. When set, the gateway embeds the request and dispatches to the route whose examples it matches best, using that route's target Model for upstream dispatch. - properties: - classifier: - description: Route selection by a hosted decision model instead of embeddings. When set, `embedding_model`, `embedding_model_id`, `embedding_timeout_ms`, `on_embedding_failure` and `match` are not accepted, every route needs a `description` and takes no `examples` or `threshold`. When omitted, routes are matched by embedding similarity. - properties: - min_confidence: - description: Lowest confidence at which the picked route is used. A decision below it sends the request to `default`. - maximum: 1.0 - minimum: 0.0 - type: number - model: - description: Decision model to call. - minLength: 1 - type: string - on_failure: - description: Behavior when the decision call fails, times out, or returns an answer that names no route. Same values as `on_embedding_failure`. - x-kubernetes-preserve-unknown-fields: true - timeout_ms: - description: Deadline for the decision call, in milliseconds. - minimum: 1.0 - type: integer - type: - description: Decision model family. `jev` is the only supported value. - enum: - - jev - type: string - required: - - type - type: object - default: - description: Direct model alias used when no route clears its threshold. Read only when `default_id` is absent. - minLength: 1 - type: string - embedding_model: - description: Alias of an `embedding`-modality Model used to embed the request and, lazily on the first request that needs them, the route examples. Read only when `embedding_model_id` is absent. Required, under this name or as `embedding_model_id`, when there is no `classifier`. - minLength: 1 - type: string - embedding_timeout_ms: - description: Per-call deadline for the embedding request in milliseconds. `0` or absent disables the embedding-specific deadline. - minimum: 0.0 - type: integer - match: - description: Shared matching parameters (metric, aggregation, default threshold). Required when there is no `classifier`. - properties: - aggregation: - description: 'Per-example score aggregation. v1: max.' - enum: - - max - type: string - distance_metric: - description: 'Similarity metric. v1: cosine.' - enum: - - cosine - type: string - threshold: - description: Default similarity threshold for routes that do not set their own `threshold`. Higher is stricter. - maximum: 1.0 - minimum: 0.0 - type: number - required: - - threshold - type: object - on_embedding_failure: - description: Behavior when the embedding call fails or times out. Defaults to routing to `default`. - x-kubernetes-preserve-unknown-fields: true - routes: - description: Routes evaluated for each request. At least one is required. - items: - description: 'One semantic route: a labeled set of example utterances whose embeddings define the route. A request that scores high enough against them dispatches to `target`.' - properties: - description: - description: 'What requests this route handles. With a `classifier`, this is the text the decision model reads to pick the route, and it is required. Without one, it is documentation only: routes are matched on `examples`.' - minLength: 1 - type: string - examples: - description: Example utterances that define this route. AISIX caches their vectors lazily. A cold route request passes its prompt and every distinct uncached example text as one logical embedding batch. Providers without batch support may issue multiple upstream calls (Bedrock Titan does). Required, with at least one example, when the router has no `classifier`; not accepted when it has one. - items: - minLength: 1 - type: string - minItems: 1 - type: array - name: - description: Operator-facing route label. Surfaced in access logs (e.g. `prod-chat -> route:legal`), and in the `x-aisix-route` response header only when `proxy.expose_debug_headers` is enabled. - minLength: 1 - type: string - target: - description: Direct model alias that receives traffic matching this route. Read only when `target_id` is absent. - minLength: 1 - type: string - threshold: - description: Per-route similarity threshold. A request matches this route only when its aggregated score is `>=` this value. When omitted, the router-level threshold applies. Not accepted with a `classifier`. - maximum: 1.0 - minimum: 0.0 - type: number - required: - - name - type: object - minItems: 1 - type: array - required: - - routes - type: object - stream_timeout: - description: Maximum gap in milliseconds between upstream streaming chunks. `0` or absent falls back to the group's `stream_timeout`, then to the model's (or group's) `timeout`, then to the deployment-wide `upstream.stream_timeout_ms` / `timeout_ms` defaults. - minimum: 0.0 - type: integer - timeout: - description: End-to-end timeout in milliseconds for non-streaming upstream calls. Absent falls back to the group's `timeout`, then to the deployment-wide `upstream.timeout_ms` default. `0` disables the non-streaming timeout for this model. - minimum: 0.0 - type: integer - type: object - status: - properties: - conditions: - items: - properties: - lastTransitionTime: - format: date-time - type: string - message: - type: string - observedGeneration: - format: int64 - type: integer - reason: - type: string - status: - enum: - - 'True' - - 'False' - - Unknown - type: string - type: - type: string - required: - - type - - status - - lastTransitionTime - - reason - - message - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - observedGeneration: - format: int64 - type: integer - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} diff --git a/deploy/crds/providerkeys.aisix.api7.ai.yaml b/deploy/crds/providerkeys.aisix.api7.ai.yaml deleted file mode 100644 index 3fc9969..0000000 --- a/deploy/crds/providerkeys.aisix.api7.ai.yaml +++ /dev/null @@ -1,285 +0,0 @@ -# Generated by `aisix-controller crds` from aisix-core. Do not edit. -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: providerkeys.aisix.api7.ai -spec: - group: aisix.api7.ai - names: - kind: ProviderKey - plural: providerkeys - shortNames: - - aipk - singular: providerkey - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .status.conditions[?(@.type=="Accepted")].status - name: Accepted - type: string - - jsonPath: .status.conditions[?(@.type=="Programmed")].status - name: Programmed - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: AISIX ProviderKey resource. - properties: - apiVersion: - type: string - kind: - type: string - metadata: - type: object - spec: - properties: - adapter: - description: Upstream API protocol family used when provider-specific dispatch is unavailable. - enum: - - openai - - anthropic - - bedrock - - vertex - - azure-openai - nullable: true - type: string - api_base: - description: Override base URL for the upstream provider. Required for custom or OpenAI-compatible providers that should not use a built-in vendor endpoint. - nullable: true - type: string - api_key_secret_ref: - description: Secret key holding the upstream provider credential. The controller writes it as `api_key`. - properties: - key: - description: Key within the Secret. - minLength: 1 - type: string - name: - description: Secret name, in the resource's namespace. - minLength: 1 - type: string - required: - - name - - key - type: object - apis: - description: |- - API surfaces this upstream serves natively, beyond the one its `adapter` already implies, and the base URL each one lives at. - - One upstream account often exposes more than one protocol, on different paths of the same host — an OpenAI-compatible `/v1/chat/completions` under `…/v1` and an Anthropic-compatible `/v1/messages` under `…/anthropic`, both authenticated by the same credential. `api_base` can only name one of them, so without this field every request the declared path cannot serve gets translated instead — losing whatever the target protocol carries that the canonical chat shape does not (prompt-cache breakpoints, thinking blocks). Declaring the second entry here lets each inbound protocol reach its own native path under the one credential. - - Each surface resolves on its own terms; see [`ProviderApis`]. Surfaces this map has no key for — embeddings, audio, images, videos, files/batches/fine-tuning, rerank — always use `api_base`, exactly as before. - nullable: true - properties: - messages: - description: Anthropic-wire `/v1/messages` (and `/v1/messages/count_tokens`). - properties: - base: - description: |- - Base URL this surface is served at. Omit when it is the same one `api_base` names — an entry with no `base` still carries the declaration that the surface exists. - - Deliberately NOT length-constrained, matching `api_base`. The lenient read schema keeps every constraint but the open-object one, so a `minLength` here would make an empty string skip the whole Provider Key row — and with it every model that references the key — where the same empty string on `api_base` is the control plane's documented way to clear an override. An empty value is treated as "no override" at resolution time instead. - type: string - type: object - responses: - description: OpenAI-wire `/v1/responses`. - properties: - base: - description: |- - Base URL this surface is served at. Omit when it is the same one `api_base` names — an entry with no `base` still carries the declaration that the surface exists. - - Deliberately NOT length-constrained, matching `api_base`. The lenient read schema keeps every constraint but the open-object one, so a `minLength` here would make an empty string skip the whole Provider Key row — and with it every model that references the key — where the same empty string on `api_base` is the control plane's documented way to clear an override. An empty value is treated as "no override" at resolution time instead. - type: string - type: object - type: object - display_name: - description: Operator-facing label, unique within the gateway. Surfaces in the Admin API list view and in dashboard UIs that wrap this resource. Defaults to `metadata.name`. - minLength: 1 - type: string - provider: - description: Upstream provider identifier, such as `"deepseek"`, `"openai"`, or a model catalog ID. The gateway uses this value for provider-specific dispatch and base URL validation. - type: string - request: - description: Per-key request-shape overrides applied by supported provider paths before dispatch to the upstream provider. - nullable: true - properties: - default_body_fields: - additionalProperties: - x-kubernetes-preserve-unknown-fields: true - description: '`apply_default_body_fields` input. Top-level body fields added when the caller did not set them. `serde_json::Map` preserves insertion order on serialize, matching the etcd round-trip.' - type: object - default_headers: - additionalProperties: - type: string - description: |- - Top-level headers added to the outbound request when the caller did not set them. Values may reference the request context with `${...}` variables, such as `"${request.api_key.team_id}"`; a header whose variables do not all resolve is dropped rather than sent blank. See [`crate::header_template`] for the closed variable vocabulary. - - "When the caller did not set them" includes the gateway itself: an entry naming the slot this ProviderKey's credential occupies is not applied. Use `forward_client_headers` to put the caller's own credential there instead. - type: object - forward_client_headers: - description: |- - Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`"anthropic-beta"`, `"x-trace-*"`, `"authorization"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167. - - A header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded. - - A header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from. - - A credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `"*"` or `"x-*"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry. - - Two cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature. - - Naming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one. - - Headers whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry. - items: - type: string - type: array - param_constraints: - description: Parameter constraints applied to the outbound request. If omitted, no clamping is applied. - properties: - temperature_max: - description: Upper bound for `temperature`. Values above this are clamped to this value. If omitted, no upper bound is applied. - type: number - temperature_min: - description: Lower bound for `temperature`. Values below this are clamped to this value. If omitted, no lower bound is applied. - type: number - type: object - param_renames: - additionalProperties: - type: string - description: '`apply_param_renames` input. Top-level body keys named on the left are renamed to the right. Leave empty to preserve request parameter names.' - type: object - type: object - resolve_addresses: - description: |- - IP addresses the gateway connects to for this key's `api_base` host, instead of resolving that host through DNS. Each entry is an IPv4 or IPv6 address literal, without a port and without brackets. - - Use this when the upstream is reached over a private link that has no DNS entry, while the provider still requires its own hostname in the request. Only the connection target changes: the `Host` header, the HTTP/2 `:authority`, the TLS server name and the certificate check all keep using the hostname from `api_base`, and the port and scheme keep coming from `api_base` too. - - Several addresses are tried in the order given, as a resolver's answer would be: the next one is attempted when a connection cannot be established, which is how a private link that terminates on one address per availability zone stays reachable when one is down. - - Scoped to the `api_base` hostname and nothing else. An `apis` entry that serves a second protocol from the same host is reached over the same link, because it is the same hostname; one that names a different host is resolved normally. A key with no `api_base`, or whose `api_base` is already an address literal, has no hostname to override and is dispatched unchanged. - - Honoured on every surface that dispatches through the Provider Key's own client: chat completions, completions, embeddings, images, audio, `/v1/messages` (and `count_tokens`), `/v1/responses`, rerank, videos, the files/batches/fine-tuning surface and `/passthrough/*`. Not honoured for Amazon Bedrock or `/v1/realtime`, which connect on their own transports — the same two that `tls` does not reach. - - Not applicable when the gateway reaches its upstreams through a forward proxy (`HTTPS_PROXY` / `ALL_PROXY` in the gateway's environment): the proxy is given the hostname and resolves it itself, so nothing here is consulted. - items: - pattern: ^[0-9A-Fa-f:.]+$ - type: string - minItems: 1 - nullable: true - type: array - response: - description: Per-key response-shape overrides applied by provider bridges that support response transformation. - nullable: true - properties: - content_list_to_string: - description: When `true`, the request-body `messages[*].content` array of text blocks gets flattened to a single string before dispatch. - type: boolean - error_envelope: - description: Stored error-envelope preference for compatibility with control-plane configuration. The proxy does not currently apply this field. - type: string - reasoning_field: - description: 'Path used to extract reasoning content from the provider response. If omitted or empty, no reasoning field is lifted. Example: `"delta.reasoning_content"`.' - type: string - stream_done_marker: - description: Stream `[DONE]` terminator expectation. If omitted, either presence or absence of the terminator is accepted. - enum: - - required - - optional - - none - type: string - type: object - strip_headers: - description: 'Inbound headers removed before passthrough forwarding. A passthrough route with `credential_mode: inject` also removes every credential slot, listed here or not.' - items: - type: string - type: array - telemetry_tags: - description: Telemetry tags carried alongside the key for metric and log emission. - properties: - branded_provider: - description: Branded provider slug for catalog entries, such as `"openai"` or `"anthropic"`. Bring-your-own providers leave this field unset. - nullable: true - type: string - byo_label: - description: Operator-defined label for bring-your-own entries, such as an internal team name. Catalog entries leave this field unset. - nullable: true - type: string - featured: - description: Whether this provider is surfaced in the featured list. - type: boolean - kind: - description: Provider-key category, such as `"catalog"` for curated providers or `"byo"` for bring-your-own providers. - enum: - - catalog - - byo - nullable: true - type: string - pk_label: - description: Operator-defined label for this provider key, such as `"production"` or `"shared-test"`. - nullable: true - type: string - type: object - tls: - description: TLS settings for connections to this key's `api_base`. Omit to use the gateway's deployment-wide trust settings. - nullable: true - properties: - ca_cert: - description: PEM-encoded certificate authority certificates trusted as issuers for this endpoint, in addition to the gateway's default trust store. A bundle containing several certificates is accepted. - type: string - verify: - description: Whether the endpoint's certificate is verified. Setting this to `false` accepts any certificate, including one presented by an intercepting party, and is intended only for test environments. - type: boolean - type: object - required: - - api_key_secret_ref - type: object - status: - properties: - conditions: - items: - properties: - lastTransitionTime: - format: date-time - type: string - message: - type: string - observedGeneration: - format: int64 - type: integer - reason: - type: string - status: - enum: - - 'True' - - 'False' - - Unknown - type: string - type: - type: string - required: - - type - - status - - lastTransitionTime - - reason - - message - type: object - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - observedGeneration: - format: int64 - type: integer - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml deleted file mode 100644 index d054565..0000000 --- a/deploy/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - crds - - controller diff --git a/src/assemble.rs b/src/assemble.rs deleted file mode 100644 index 38a3daf..0000000 --- a/src/assemble.rs +++ /dev/null @@ -1,396 +0,0 @@ -//! Rendered entries -> one validated resources file. -//! -//! The document is checked with the gateway's own pipeline -//! (`aisix_core::filesource::load_from_str`) before it is written. An entry -//! the gateway would reject is replaced by the version currently served, so -//! one bad resource never takes the others down. With no served version it is -//! left out. - -use std::collections::BTreeMap; - -use aisix_core::filesource::load_from_str; -use serde_json::{Map, Value}; -use sha2::{Digest, Sha256}; - -use crate::crd::{Kind, IDENTITY_FIELD}; -use crate::render::{Entry, Source, SourceKey}; - -pub const FORMAT_VERSION: &str = "1"; - -/// What happened to one custom resource. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Disposition { - /// Its current spec is in the document. - Applied, - /// Rejected; the previously served version stays in the document. - Retained(String), - /// Rejected, and not served at all. - Rejected(String), -} - -#[derive(Debug)] -pub struct Outcome { - /// The document to serve, or `None` when it could not be made valid. - pub document: Option, - pub dispositions: BTreeMap, - /// Set when the document as a whole failed. - pub error: Option, -} - -/// Entries of the currently served document, by collection and identity. -pub type Served = BTreeMap<(Kind, String), Entry>; - -/// Parse the served document. Anything unreadable counts as nothing served. -pub fn served_entries(document: Option<&str>) -> Served { - let mut out = Served::new(); - let Some(Ok(Value::Object(root))) = document.map(serde_json::from_str::) else { - return out; - }; - for kind in Kind::ALL { - let Some(Value::Array(items)) = root.get(kind.collection()) else { - continue; - }; - for item in items { - if let Some(id) = item.get(IDENTITY_FIELD).and_then(Value::as_str) { - out.insert((kind, id.replace("$$", "$")), item.clone()); - } - } - } - out -} - -pub fn document(entries: &[(Kind, Entry)]) -> String { - let mut root = Map::new(); - root.insert( - "_format_version".into(), - Value::String(FORMAT_VERSION.into()), - ); - for kind in Kind::ALL { - let items: Vec = entries - .iter() - .filter(|(k, _)| *k == kind) - .map(|(_, e)| e.clone()) - .collect(); - if !items.is_empty() { - root.insert(kind.collection().into(), Value::Array(items)); - } - } - let mut out = serde_json::to_string_pretty(&Value::Object(root)).expect("document serializes"); - out.push('\n'); - out -} - -pub fn hash(document: &str) -> String { - hex::encode(Sha256::digest(document.as_bytes())) -} - -/// Run the gateway's resources-file pipeline over a document. -/// Errors come back as `(scope, message)`. -pub fn check(document: &str) -> Result<(), Vec<(String, String)>> { - load_from_str(document, "aisix-controller", 1, &|_| None) - .map(|_| ()) - .map_err(|e| e.errors.into_iter().map(|e| (e.scope, e.message)).collect()) -} - -/// `models[2] ("gpt")` -> (Model, 2). -fn parse_scope(scope: &str) -> Option<(Kind, usize)> { - let (collection, rest) = scope.split_once('[')?; - let (index, _) = rest.split_once(']')?; - Some((Kind::from_collection(collection)?, index.parse().ok()?)) -} - -#[derive(Clone, Debug)] -enum State { - Current(Entry), - Served(Entry, String), - Dropped(String), -} - -pub fn assemble(rendered: Vec<(Source, Result)>, served: &Served) -> Outcome { - let fallback = |s: &Source, reason: String| match served.get(&(s.kind, s.identity())) { - Some(e) => State::Served(e.clone(), reason), - None => State::Dropped(reason), - }; - - let mut items: Vec<(Source, State)> = rendered - .into_iter() - .map(|(s, r)| { - let state = match r { - Ok(e) => State::Current(e), - Err(reason) => fallback(&s, reason), - }; - (s, state) - }) - .collect(); - - // Each round drops at least one entry, so this terminates. - let rounds = items.len() * 2 + 1; - for _ in 0..rounds { - let mut included: Vec<(Kind, Entry)> = Vec::new(); - let mut positions: BTreeMap<(Kind, usize), usize> = BTreeMap::new(); - let mut per_kind: BTreeMap = BTreeMap::new(); - for (i, (s, st)) in items.iter().enumerate() { - let entry = match st { - State::Current(e) | State::Served(e, _) => e, - State::Dropped(_) => continue, - }; - let n = per_kind.entry(s.kind).or_default(); - positions.insert((s.kind, *n), i); - *n += 1; - included.push((s.kind, entry.clone())); - } - let doc = document(&included); - let errors = match check(&doc) { - Ok(()) => return finish(items, Some(doc), None), - Err(errors) => errors, - }; - - let mut faults: BTreeMap> = BTreeMap::new(); - for (scope, message) in &errors { - match parse_scope(scope).and_then(|p| positions.get(&p)) { - Some(i) => faults.entry(*i).or_default().push(message.clone()), - None => { - let all = errors - .iter() - .map(|(s, m)| format!("{s}: {m}")) - .collect::>() - .join("; "); - return finish(items, None, Some(all)); - } - } - } - for (i, messages) in faults { - let reason = messages.join("; "); - let (s, st) = &mut items[i]; - *st = match st { - State::Current(_) => fallback(s, reason), - State::Served(_, earlier) => State::Dropped(format!( - "{earlier}; the previously served version is no longer valid either: {reason}" - )), - State::Dropped(r) => State::Dropped(r.clone()), - }; - } - } - finish( - items, - None, - Some("the resources file did not converge to a valid state".into()), - ) -} - -fn finish(items: Vec<(Source, State)>, document: Option, error: Option) -> Outcome { - let dispositions = items - .into_iter() - .map(|(s, st)| { - let d = match st { - State::Current(_) => Disposition::Applied, - State::Served(_, r) => Disposition::Retained(r), - State::Dropped(r) => Disposition::Rejected(r), - }; - (s.key(), d) - }) - .collect(); - Outcome { - document, - dispositions, - error, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::render::{render_all, Secrets}; - use serde_json::json; - - fn source(kind: Kind, name: &str, fields: Value) -> Source { - Source { - kind, - name: name.into(), - generation: Some(1), - created: None, - fields: fields.as_object().unwrap().clone(), - } - } - - fn secrets() -> Secrets { - let mut s = Secrets::new(); - s.insert( - "creds".into(), - BTreeMap::from([ - ("pk".into(), b"sk-test".to_vec()), - ("caller".into(), b"caller".to_vec()), - ]), - ); - s - } - - fn provider() -> Source { - source( - Kind::ProviderKey, - "mock", - json!({"provider": "openai", "adapter": "openai", "api_base": "http://mock:8080/v1", "api_key_secret_ref": {"name": "creds", "key": "pk"}}), - ) - } - - fn model(name: &str, provider_ref: &str) -> Source { - source( - Kind::Model, - name, - json!({"provider": "openai", "model_name": "upstream", "provider_key_ref": {"name": provider_ref}}), - ) - } - - fn api_key() -> Source { - source( - Kind::APIKey, - "caller", - json!({"allowed_models": ["*"], "key_secret_ref": {"name": "creds", "key": "caller"}}), - ) - } - - fn run(sources: &[Source], served: &Served) -> Outcome { - assemble(render_all(sources, &secrets()), served) - } - - #[test] - fn empty_document_is_valid() { - assert_eq!(check(&document(&[])), Ok(())); - } - - #[test] - fn valid_resources_produce_a_loadable_document() { - let out = run( - &[provider(), model("chat", "mock"), api_key()], - &Served::new(), - ); - let doc = out.document.expect("document"); - assert!(out.error.is_none()); - assert!(out - .dispositions - .values() - .all(|d| *d == Disposition::Applied)); - assert_eq!(check(&doc), Ok(())); - let v: Value = serde_json::from_str(&doc).unwrap(); - assert_eq!(v["_format_version"], json!("1")); - assert_eq!(v["models"][0]["provider_key"], json!("mock")); - } - - #[test] - fn gateway_rejection_is_attributed_to_the_resource() { - let bad = source( - Kind::Model, - "bad", - json!({"provider": "openai", "model_name": "x", "provider_key_ref": {"name": "mock"}, "timeout": "soon"}), - ); - let out = run(&[provider(), model("good", "mock"), bad], &Served::new()); - assert!(out.document.is_some()); - let key = SourceKey { - kind: Kind::Model, - name: "bad".into(), - }; - match &out.dispositions[&key] { - Disposition::Rejected(m) => assert!(m.contains("timeout"), "{m}"), - d => panic!("{d:?}"), - } - let good = SourceKey { - kind: Kind::Model, - name: "good".into(), - }; - assert_eq!(out.dispositions[&good], Disposition::Applied); - } - - #[test] - fn rejected_update_keeps_the_served_version() { - let first = run(&[provider(), model("chat", "mock")], &Served::new()); - let served = served_entries(first.document.as_deref()); - - let broken = source( - Kind::Model, - "chat", - json!({"provider": "openai", "model_name": "upstream", "provider_key_ref": {"name": "mock"}, "timeout": -1}), - ); - let out = run(&[provider(), broken], &served); - let key = SourceKey { - kind: Kind::Model, - name: "chat".into(), - }; - match &out.dispositions[&key] { - Disposition::Retained(m) => assert!(m.contains("timeout"), "{m}"), - d => panic!("{d:?}"), - } - assert_eq!(out.document, first.document); - } - - #[test] - fn dangling_reference_cascades_to_dependents() { - let broken_provider = source( - Kind::ProviderKey, - "mock", - json!({"api_key_secret_ref": {"name": "gone", "key": "pk"}}), - ); - let out = run(&[broken_provider, model("chat", "mock")], &Served::new()); - let pk = SourceKey { - kind: Kind::ProviderKey, - name: "mock".into(), - }; - let chat = SourceKey { - kind: Kind::Model, - name: "chat".into(), - }; - assert!(matches!(out.dispositions[&pk], Disposition::Rejected(_))); - match &out.dispositions[&chat] { - Disposition::Rejected(m) => assert!(m.contains("mock"), "{m}"), - d => panic!("{d:?}"), - } - assert_eq!(check(out.document.as_deref().unwrap()), Ok(())); - } - - #[test] - fn cross_field_rules_dropped_from_the_crd_are_still_enforced() { - let first = run(&[provider(), model("chat", "mock")], &Served::new()); - let served = served_entries(first.document.as_deref()); - - // A direct model that also routes: the CRD has no oneOf, the gateway does. - let both = source( - Kind::Model, - "chat", - json!({"provider": "openai", "model_name": "upstream", "provider_key_ref": {"name": "mock"}, - "routing": {"targets": [{"model": "chat"}]}}), - ); - let out = run(&[provider(), both], &served); - let key = SourceKey { - kind: Kind::Model, - name: "chat".into(), - }; - assert!( - matches!(out.dispositions[&key], Disposition::Retained(_)), - "{:?}", - out.dispositions - ); - assert_eq!(out.document, first.document); - } - - #[test] - fn deleted_resources_leave_the_document() { - let first = run(&[provider(), model("chat", "mock")], &Served::new()); - let served = served_entries(first.document.as_deref()); - let out = run(&[provider()], &served); - let v: Value = serde_json::from_str(out.document.as_deref().unwrap()).unwrap(); - assert!(v.get("models").is_none()); - } - - #[test] - fn scopes_parse() { - assert_eq!(parse_scope("models[2] (\"gpt\")"), Some((Kind::Model, 2))); - assert_eq!(parse_scope("api_keys[0]"), Some((Kind::APIKey, 0))); - assert_eq!(parse_scope("(file)"), None); - } - - #[test] - fn served_identities_are_unescaped() { - let doc = document(&[(Kind::Model, json!({"display_name": "a$$b"}))]); - let served = served_entries(Some(&doc)); - assert!(served.contains_key(&(Kind::Model, "a$b".into()))); - } -} diff --git a/src/crd.rs b/src/crd.rs deleted file mode 100644 index 5613af1..0000000 --- a/src/crd.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Custom resource types. -//! -//! A spec holds the AISIX resource document with its own (snake_case) field -//! names, plus a reference field that replaces the inline credential or id. -//! The schema is generated from aisix-core (see `schema.rs`), so the Rust -//! side keeps the spec as an untyped map. - -use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; -use kube::CustomResource; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; - -pub const GROUP: &str = "aisix.api7.ai"; -pub const VERSION: &str = "v1alpha1"; - -/// Pod-template annotation carrying the hash of the served resources file. -pub const HASH_ANNOTATION: &str = "aisix.api7.ai/resources-hash"; - -#[derive(CustomResource, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -#[kube( - group = "aisix.api7.ai", - version = "v1alpha1", - kind = "ProviderKey", - plural = "providerkeys", - shortname = "aipk", - namespaced, - status = "ResourceStatus", - schema = "disabled" -)] -pub struct ProviderKeySpec { - #[serde(flatten)] - pub fields: Map, -} - -#[derive(CustomResource, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -#[kube( - group = "aisix.api7.ai", - version = "v1alpha1", - kind = "Model", - plural = "models", - shortname = "aimodel", - namespaced, - status = "ResourceStatus", - schema = "disabled" -)] -pub struct ModelSpec { - #[serde(flatten)] - pub fields: Map, -} - -#[derive(CustomResource, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -#[kube( - group = "aisix.api7.ai", - version = "v1alpha1", - kind = "APIKey", - plural = "apikeys", - shortname = "aikey", - namespaced, - status = "ResourceStatus", - schema = "disabled" -)] -pub struct APIKeySpec { - #[serde(flatten)] - pub fields: Map, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ResourceStatus { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub observed_generation: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub conditions: Vec, -} - -/// The three resource kinds v0.1 reconciles. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum Kind { - ProviderKey, - Model, - APIKey, -} - -impl Kind { - pub const ALL: [Kind; 3] = [Kind::ProviderKey, Kind::Model, Kind::APIKey]; - - /// Kubernetes kind name. - pub fn name(self) -> &'static str { - match self { - Kind::ProviderKey => "ProviderKey", - Kind::Model => "Model", - Kind::APIKey => "APIKey", - } - } - - /// Collection key in the resources file. - pub fn collection(self) -> &'static str { - match self { - Kind::ProviderKey => "provider_keys", - Kind::Model => "models", - Kind::APIKey => "api_keys", - } - } - - /// Resource name in aisix-core's schema registry. - pub fn schema_resource(self) -> &'static str { - match self { - Kind::ProviderKey => "provider_key", - Kind::Model => "model", - Kind::APIKey => "api_key", - } - } - - pub fn from_collection(collection: &str) -> Option { - Kind::ALL.into_iter().find(|k| k.collection() == collection) - } -} - -/// Identity field shared by all three kinds; defaults to `metadata.name`. -pub const IDENTITY_FIELD: &str = "display_name"; - -/// ProviderKey: Secret holding the upstream credential. -pub const API_KEY_SECRET_REF: &str = "api_key_secret_ref"; -/// Model: the ProviderKey this model authenticates with. -pub const PROVIDER_KEY_REF: &str = "provider_key_ref"; -/// APIKey: Secret holding the caller's plaintext key. -pub const KEY_SECRET_REF: &str = "key_secret_ref"; diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 0892901..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod assemble; -pub mod crd; -pub mod reconcile; -pub mod render; -pub mod schema; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index be8204a..0000000 --- a/src/main.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::path::PathBuf; -use std::time::Duration; - -use aisix_controller::crd::Kind; -use aisix_controller::reconcile::{self, Settings}; -use aisix_controller::schema; -use clap::{Parser, Subcommand}; - -#[derive(Parser)] -#[command( - version, - about = "Reconciles AISIX custom resources into an AISIX gateway" -)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Run the controller against the current cluster. - Run { - /// Namespace to watch. Defaults to the controller's own. - #[arg(long, env = "POD_NAMESPACE")] - namespace: String, - /// Secret the gateway reads its resources file from. - #[arg(long, default_value = "aisix-resources")] - secret_name: String, - /// Key of the resources file within that Secret. - #[arg(long, default_value = "resources.yaml")] - secret_key: String, - /// Gateway Deployment to roll out on change. - #[arg(long, default_value = "aisix")] - deployment_name: String, - /// Full resync interval, in seconds. - #[arg(long, default_value_t = 60)] - resync_seconds: u64, - }, - /// Write the CustomResourceDefinitions. - Crds { - #[arg(long, default_value = "deploy/crds")] - out: PathBuf, - }, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info,kube=warn".into()), - ) - .init(); - - match Cli::parse().command { - Command::Run { - namespace, - secret_name, - secret_key, - deployment_name, - resync_seconds, - } => { - let client = kube::Client::try_default().await?; - let settings = Settings { - namespace, - secret_name, - secret_key, - deployment_name, - resync: Duration::from_secs(resync_seconds), - }; - tokio::select! { - r = reconcile::run(client, settings) => r, - _ = tokio::signal::ctrl_c() => Ok(()), - } - } - Command::Crds { out } => { - std::fs::create_dir_all(&out)?; - for kind in Kind::ALL { - let path = out.join(schema::crd_file_name(kind)); - std::fs::write(&path, schema::crd_yaml(kind))?; - println!("{}", path.display()); - } - Ok(()) - } - } -} diff --git a/src/reconcile.rs b/src/reconcile.rs deleted file mode 100644 index ea8340d..0000000 --- a/src/reconcile.rs +++ /dev/null @@ -1,667 +0,0 @@ -//! The reconcile loop. -//! -//! The resources file is one document, so the controller reconciles the -//! whole namespace at once: any change to a custom resource, a Secret or the -//! gateway Deployment re-renders the document. A changed document is written -//! to the gateway's resources Secret and rolled out by stamping its hash on -//! the Deployment's pod template. - -use std::collections::BTreeMap; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Context; -use futures::{stream, StreamExt}; -use k8s_openapi::api::apps::v1::Deployment; -use k8s_openapi::api::core::v1::Secret; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time}; -use k8s_openapi::ByteString; -use kube::api::{Api, ObjectMeta, Patch, PatchParams}; -use kube::runtime::reflector::{self, Store}; -use kube::runtime::{watcher, WatchStreamExt}; -use kube::{Client, Resource, ResourceExt}; -use serde_json::json; -use tokio::sync::Notify; - -use crate::assemble::{assemble, document, hash, served_entries, Disposition, Outcome}; -use crate::crd::{APIKey, Kind, Model, ProviderKey, ResourceStatus, HASH_ANNOTATION}; -use crate::render::{render_all, secrets_from, Source, SourceKey}; - -pub const MANAGER: &str = "aisix-controller"; - -#[derive(Clone, Debug)] -pub struct Settings { - pub namespace: String, - pub secret_name: String, - pub secret_key: String, - pub deployment_name: String, - pub resync: Duration, -} - -struct Stores { - provider_keys: Store, - models: Store, - api_keys: Store, - secrets: Store, - deployments: Store, -} - -fn source(kind: Kind, obj: &K, fields: &serde_json::Map) -> Source -where - K: Resource, -{ - let meta = obj.meta(); - Source { - kind, - name: meta.name.clone().unwrap_or_default(), - generation: meta.generation, - created: meta.creation_timestamp.as_ref().map(|t| t.0.to_string()), - fields: fields.clone(), - } -} - -impl Stores { - fn sources(&self) -> Vec { - let mut out = Vec::new(); - out.extend( - self.provider_keys - .state() - .iter() - .map(|o| source(Kind::ProviderKey, o.as_ref(), &o.spec.fields)), - ); - out.extend( - self.models - .state() - .iter() - .map(|o| source(Kind::Model, o.as_ref(), &o.spec.fields)), - ); - out.extend( - self.api_keys - .state() - .iter() - .map(|o| source(Kind::APIKey, o.as_ref(), &o.spec.fields)), - ); - out - } -} - -/// Watch everything the document depends on and reconcile on every change, -/// plus a periodic resync. -pub async fn run(client: Client, settings: Settings) -> anyhow::Result<()> { - let ns = settings.namespace.as_str(); - let notify = Arc::new(Notify::new()); - - let (pk_store, pk_writer) = reflector::store(); - let (model_store, model_writer) = reflector::store(); - let (key_store, key_writer) = reflector::store(); - let (secret_store, secret_writer) = reflector::store(); - let (deploy_store, deploy_writer) = reflector::store(); - - let cfg = watcher::Config::default; - let deploy_cfg = cfg().fields(&format!("metadata.name={}", settings.deployment_name)); - - let streams = vec![ - watcher(Api::::namespaced(client.clone(), ns), cfg()) - .default_backoff() - .reflect(pk_writer) - .map(|_| ()) - .boxed(), - watcher(Api::::namespaced(client.clone(), ns), cfg()) - .default_backoff() - .reflect(model_writer) - .map(|_| ()) - .boxed(), - watcher(Api::::namespaced(client.clone(), ns), cfg()) - .default_backoff() - .reflect(key_writer) - .map(|_| ()) - .boxed(), - watcher(Api::::namespaced(client.clone(), ns), cfg()) - .default_backoff() - .reflect(secret_writer) - .map(|_| ()) - .boxed(), - watcher( - Api::::namespaced(client.clone(), ns), - deploy_cfg, - ) - .default_backoff() - .reflect(deploy_writer) - .map(|_| ()) - .boxed(), - ]; - let trigger = notify.clone(); - tokio::spawn(async move { - let mut events = stream::select_all(streams); - while events.next().await.is_some() { - trigger.notify_one(); - } - }); - - let stores = Stores { - provider_keys: pk_store, - models: model_store, - api_keys: key_store, - secrets: secret_store, - deployments: deploy_store, - }; - stores.provider_keys.wait_until_ready().await?; - stores.models.wait_until_ready().await?; - stores.api_keys.wait_until_ready().await?; - stores.secrets.wait_until_ready().await?; - stores.deployments.wait_until_ready().await?; - tracing::info!(namespace = ns, secret = %settings.secret_name, deployment = %settings.deployment_name, "caches synced"); - - loop { - // Coalesce bursts of events into one pass. - tokio::time::sleep(Duration::from_millis(300)).await; - if let Err(e) = reconcile(&client, &settings, &stores).await { - tracing::warn!(error = %format!("{e:#}"), "reconcile failed"); - tokio::time::sleep(Duration::from_secs(5)).await; - continue; - } - tokio::select! { - _ = notify.notified() => {} - _ = tokio::time::sleep(settings.resync) => {} - } - } -} - -/// State of the gateway rollout for the document hash it should serve. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Rollout { - Ready, - Progressing(String), - Missing, -} - -pub fn rollout(deployment: Option<&Deployment>, want: &str) -> Rollout { - let Some(d) = deployment else { - return Rollout::Missing; - }; - let stamped = d - .spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.annotations.as_ref()) - .and_then(|a| a.get(HASH_ANNOTATION)); - if stamped.map(String::as_str) != Some(want) { - return Rollout::Progressing( - "the gateway Deployment does not carry this configuration yet".into(), - ); - } - let replicas = d.spec.as_ref().and_then(|s| s.replicas).unwrap_or(1); - let Some(status) = d.status.as_ref() else { - return Rollout::Progressing("the gateway Deployment reports no status yet".into()); - }; - let observed = status.observed_generation.unwrap_or(0) >= d.metadata.generation.unwrap_or(0); - let updated = status.updated_replicas.unwrap_or(0); - let available = status.available_replicas.unwrap_or(0); - let total = status.replicas.unwrap_or(0); - if observed && updated == replicas && available == replicas && total == replicas { - Rollout::Ready - } else { - Rollout::Progressing(format!( - "rolling out: {updated}/{replicas} updated, {available}/{replicas} available" - )) - } -} - -async fn reconcile(client: &Client, settings: &Settings, stores: &Stores) -> anyhow::Result<()> { - let ns = settings.namespace.as_str(); - let secrets: Vec = stores - .secrets - .state() - .iter() - .map(|s| s.as_ref().clone()) - .collect(); - let current = secrets - .iter() - .find(|s| s.metadata.name.as_deref() == Some(settings.secret_name.as_str())) - .and_then(|s| s.data.as_ref()) - .and_then(|d| d.get(&settings.secret_key)) - .and_then(|b| String::from_utf8(b.0.clone()).ok()); - - let sources = stores.sources(); - let outcome = assemble( - render_all(&sources, &secrets_from(&secrets)), - &served_entries(current.as_deref()), - ); - if let Some(e) = &outcome.error { - tracing::warn!(error = %e, "resources file rejected; keeping the served version"); - } - - // A gateway needs a file to start, so seed an empty one. - let desired = match (&outcome.document, ¤t) { - (Some(doc), _) => Some(doc.clone()), - (None, None) => Some(document(&[])), - (None, Some(_)) => None, - }; - let served = match desired { - Some(doc) if current.as_deref() != Some(doc.as_str()) => { - write_secret(client, settings, &doc).await?; - tracing::info!(hash = %&hash(&doc)[..12], "resources file written"); - doc - } - Some(doc) => doc, - None => current.clone().unwrap_or_default(), - }; - let served_hash = hash(&served); - - let deployment = stores - .deployments - .state() - .into_iter() - .find(|d| d.metadata.name.as_deref() == Some(settings.deployment_name.as_str())); - let mut state = rollout(deployment.as_deref(), &served_hash); - if matches!(state, Rollout::Progressing(_)) - && deployment - .as_deref() - .is_some_and(|d| !stamped(d, &served_hash)) - { - stamp_deployment(client, settings, &served_hash).await?; - tracing::info!(hash = %&served_hash[..12], "gateway rollout started"); - state = Rollout::Progressing("rollout started".into()); - } - - let in_served = outcome.document.as_deref() == Some(served.as_str()); - for s in &sources { - let previous = status_of(stores, s); - let status = desired_status(s, &outcome, in_served, &state, previous.as_ref()); - if previous.as_ref() != Some(&status) { - patch_status(client, ns, &s.key(), &status).await?; - } - } - Ok(()) -} - -fn stamped(d: &Deployment, want: &str) -> bool { - d.spec - .as_ref() - .and_then(|s| s.template.metadata.as_ref()) - .and_then(|m| m.annotations.as_ref()) - .and_then(|a| a.get(HASH_ANNOTATION)) - .is_some_and(|h| h == want) -} - -async fn write_secret(client: &Client, settings: &Settings, doc: &str) -> anyhow::Result<()> { - let api: Api = Api::namespaced(client.clone(), &settings.namespace); - let secret = Secret { - metadata: ObjectMeta { - name: Some(settings.secret_name.clone()), - labels: Some(BTreeMap::from([( - "app.kubernetes.io/managed-by".to_string(), - MANAGER.to_string(), - )])), - annotations: Some(BTreeMap::from([(HASH_ANNOTATION.to_string(), hash(doc))])), - ..Default::default() - }, - data: Some(BTreeMap::from([( - settings.secret_key.clone(), - ByteString(doc.as_bytes().to_vec()), - )])), - type_: Some("Opaque".into()), - ..Default::default() - }; - let params = PatchParams::apply(MANAGER).force(); - api.patch(&settings.secret_name, ¶ms, &Patch::Apply(&secret)) - .await - .with_context(|| format!("writing Secret {}", settings.secret_name))?; - Ok(()) -} - -async fn stamp_deployment(client: &Client, settings: &Settings, hash: &str) -> anyhow::Result<()> { - let api: Api = Api::namespaced(client.clone(), &settings.namespace); - let patch = - json!({"spec": {"template": {"metadata": {"annotations": {HASH_ANNOTATION: hash}}}}}); - api.patch( - &settings.deployment_name, - &PatchParams::default(), - &Patch::Merge(&patch), - ) - .await - .with_context(|| format!("stamping Deployment {}", settings.deployment_name))?; - Ok(()) -} - -fn status_of(stores: &Stores, s: &Source) -> Option { - let find = |name: &str| -> Option> { - match s.kind { - Kind::ProviderKey => stores - .provider_keys - .state() - .into_iter() - .find(|o| o.name_any() == name) - .map(|o| o.status.clone()), - Kind::Model => stores - .models - .state() - .into_iter() - .find(|o| o.name_any() == name) - .map(|o| o.status.clone()), - Kind::APIKey => stores - .api_keys - .state() - .into_iter() - .find(|o| o.name_any() == name) - .map(|o| o.status.clone()), - } - }; - find(&s.name).flatten() -} - -fn condition( - type_: &str, - status: &str, - reason: &str, - message: String, - generation: Option, - previous: Option<&ResourceStatus>, -) -> Condition { - let prior = previous.and_then(|p| p.conditions.iter().find(|c| c.type_ == type_)); - let last_transition_time = match prior { - Some(c) if c.status == status => c.last_transition_time.clone(), - _ => Time(k8s_openapi::jiff::Timestamp::now()), - }; - Condition { - type_: type_.into(), - status: status.into(), - reason: reason.into(), - message, - observed_generation: generation, - last_transition_time, - } -} - -/// Conditions for one resource. `Accepted`: its current spec is valid. -/// `Programmed`: the gateway serves its current spec. -pub fn desired_status( - s: &Source, - outcome: &Outcome, - in_served: bool, - rollout: &Rollout, - previous: Option<&ResourceStatus>, -) -> ResourceStatus { - let g = s.generation; - let disposition = outcome.dispositions.get(&s.key()); - let (accepted, programmed) = match (disposition, &outcome.error) { - (Some(Disposition::Applied), None) => { - let accepted = condition("Accepted", "True", "Accepted", "valid".into(), g, previous); - let programmed = match (in_served, rollout) { - (true, Rollout::Ready) => condition( - "Programmed", - "True", - "Programmed", - "served by the gateway".into(), - g, - previous, - ), - (_, Rollout::Missing) => condition( - "Programmed", - "False", - "GatewayNotFound", - "the gateway Deployment does not exist".into(), - g, - previous, - ), - (_, Rollout::Progressing(m)) => { - condition("Programmed", "False", "Pending", m.clone(), g, previous) - } - (false, Rollout::Ready) => condition( - "Programmed", - "False", - "Pending", - "waiting for the resources file to be written".into(), - g, - previous, - ), - }; - (accepted, programmed) - } - (Some(Disposition::Retained(m)), None) => ( - condition("Accepted", "False", "Invalid", m.clone(), g, previous), - condition( - "Programmed", - "False", - "Invalid", - "the previously accepted version keeps serving".into(), - g, - previous, - ), - ), - (Some(Disposition::Rejected(m)), None) => ( - condition("Accepted", "False", "Invalid", m.clone(), g, previous), - condition( - "Programmed", - "False", - "Invalid", - "not served".into(), - g, - previous, - ), - ), - (Some(Disposition::Rejected(m)), Some(_)) | (Some(Disposition::Retained(m)), Some(_)) => ( - condition("Accepted", "False", "Invalid", m.clone(), g, previous), - condition( - "Programmed", - "False", - "Invalid", - "not served".into(), - g, - previous, - ), - ), - (_, Some(e)) => ( - condition( - "Accepted", - "Unknown", - "Pending", - "the resources file as a whole is invalid".into(), - g, - previous, - ), - condition( - "Programmed", - "False", - "ConfigurationRejected", - e.clone(), - g, - previous, - ), - ), - (None, None) => ( - condition( - "Accepted", - "Unknown", - "Pending", - "not reconciled yet".into(), - g, - previous, - ), - condition( - "Programmed", - "Unknown", - "Pending", - "not reconciled yet".into(), - g, - previous, - ), - ), - }; - ResourceStatus { - observed_generation: g, - conditions: vec![accepted, programmed], - } -} - -async fn patch_status( - client: &Client, - ns: &str, - key: &SourceKey, - status: &ResourceStatus, -) -> anyhow::Result<()> { - let patch = Patch::Merge(json!({"status": status})); - let params = PatchParams::default(); - let name = key.name.as_str(); - let result = match key.kind { - Kind::ProviderKey => Api::::namespaced(client.clone(), ns) - .patch_status(name, ¶ms, &patch) - .await - .map(|_| ()), - Kind::Model => Api::::namespaced(client.clone(), ns) - .patch_status(name, ¶ms, &patch) - .await - .map(|_| ()), - Kind::APIKey => Api::::namespaced(client.clone(), ns) - .patch_status(name, ¶ms, &patch) - .await - .map(|_| ()), - }; - match result { - Ok(()) => Ok(()), - // Deleted between the read and the write. - Err(kube::Error::Api(e)) if e.code == 404 => Ok(()), - Err(e) => Err(e).with_context(|| format!("updating status of {key}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use k8s_openapi::api::apps::v1::{DeploymentSpec, DeploymentStatus}; - use k8s_openapi::api::core::v1::PodTemplateSpec; - - fn deployment(hash: Option<&str>, updated: i32, available: i32) -> Deployment { - Deployment { - metadata: ObjectMeta { - generation: Some(2), - ..Default::default() - }, - spec: Some(DeploymentSpec { - replicas: Some(2), - template: PodTemplateSpec { - metadata: Some(ObjectMeta { - annotations: hash.map(|h| { - BTreeMap::from([(HASH_ANNOTATION.to_string(), h.to_string())]) - }), - ..Default::default() - }), - spec: None, - }, - ..Default::default() - }), - status: Some(DeploymentStatus { - observed_generation: Some(2), - replicas: Some(2), - updated_replicas: Some(updated), - available_replicas: Some(available), - ..Default::default() - }), - } - } - - #[test] - fn rollout_states() { - assert_eq!(rollout(None, "h"), Rollout::Missing); - assert!(matches!( - rollout(Some(&deployment(None, 2, 2)), "h"), - Rollout::Progressing(_) - )); - assert!(matches!( - rollout(Some(&deployment(Some("old"), 2, 2)), "h"), - Rollout::Progressing(_) - )); - assert!(matches!( - rollout(Some(&deployment(Some("h"), 1, 2)), "h"), - Rollout::Progressing(_) - )); - assert_eq!( - rollout(Some(&deployment(Some("h"), 2, 2)), "h"), - Rollout::Ready - ); - } - - fn src() -> Source { - Source { - kind: Kind::Model, - name: "m".into(), - generation: Some(3), - created: None, - fields: Default::default(), - } - } - - fn outcome(d: Disposition, error: Option<&str>) -> Outcome { - Outcome { - document: Some("{}".into()), - dispositions: BTreeMap::from([(src().key(), d)]), - error: error.map(String::from), - } - } - - fn get<'a>(s: &'a ResourceStatus, t: &str) -> &'a Condition { - s.conditions.iter().find(|c| c.type_ == t).unwrap() - } - - #[test] - fn applied_and_rolled_out_is_programmed() { - let s = desired_status( - &src(), - &outcome(Disposition::Applied, None), - true, - &Rollout::Ready, - None, - ); - assert_eq!(get(&s, "Accepted").status, "True"); - assert_eq!(get(&s, "Programmed").status, "True"); - assert_eq!(s.observed_generation, Some(3)); - assert_eq!(get(&s, "Programmed").observed_generation, Some(3)); - } - - #[test] - fn applied_while_rolling_is_pending() { - let s = desired_status( - &src(), - &outcome(Disposition::Applied, None), - true, - &Rollout::Progressing("x".into()), - None, - ); - assert_eq!(get(&s, "Programmed").reason, "Pending"); - } - - #[test] - fn retained_is_not_accepted() { - let s = desired_status( - &src(), - &outcome(Disposition::Retained("bad".into()), None), - true, - &Rollout::Ready, - None, - ); - assert_eq!(get(&s, "Accepted").status, "False"); - assert_eq!(get(&s, "Accepted").message, "bad"); - assert!(get(&s, "Programmed") - .message - .contains("previously accepted")); - } - - #[test] - fn transition_time_is_kept_when_status_is_unchanged() { - let first = desired_status( - &src(), - &outcome(Disposition::Applied, None), - true, - &Rollout::Ready, - None, - ); - let second = desired_status( - &src(), - &outcome(Disposition::Applied, None), - true, - &Rollout::Ready, - Some(&first), - ); - assert_eq!(first, second); - } -} diff --git a/src/render.rs b/src/render.rs deleted file mode 100644 index 5ab2d73..0000000 --- a/src/render.rs +++ /dev/null @@ -1,377 +0,0 @@ -//! Custom resources + Secrets -> resources-file entries. -//! -//! Each CR becomes one entry of the gateway's `resources.yaml` collection for -//! its kind. Reference fields are resolved here; everything else passes -//! through untouched for the gateway pipeline to validate. - -use std::collections::BTreeMap; - -use aisix_core::models::ApiKey; -use k8s_openapi::api::core::v1::Secret; -use serde_json::{Map, Value}; - -use crate::crd::{Kind, API_KEY_SECRET_REF, IDENTITY_FIELD, KEY_SECRET_REF, PROVIDER_KEY_REF}; - -/// One custom resource, reduced to what rendering needs. -#[derive(Clone, Debug, PartialEq)] -pub struct Source { - pub kind: Kind, - pub name: String, - pub generation: Option, - /// Sort key for duplicate identities: the older resource wins. - pub created: Option, - pub fields: Map, -} - -impl Source { - pub fn key(&self) -> SourceKey { - SourceKey { - kind: self.kind, - name: self.name.clone(), - } - } - - /// AISIX identity: `display_name`, defaulting to `metadata.name`. - pub fn identity(&self) -> String { - self.fields - .get(IDENTITY_FIELD) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .map(String::from) - .unwrap_or_else(|| self.name.clone()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct SourceKey { - pub kind: Kind, - pub name: String, -} - -impl std::fmt::Display for SourceKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}/{}", self.kind.name(), self.name) - } -} - -/// Secret data by Secret name, then key. -pub type Secrets = BTreeMap>>; - -pub fn secrets_from(list: &[Secret]) -> Secrets { - list.iter() - .filter_map(|s| { - let name = s.metadata.name.clone()?; - let data = s - .data - .as_ref() - .map(|d| d.iter().map(|(k, v)| (k.clone(), v.0.clone())).collect()) - .unwrap_or_default(); - Some((name, data)) - }) - .collect() -} - -/// A rendered entry, already escaped for `${VAR}` interpolation. -pub type Entry = Value; - -/// Render every source. Sources come back in file order: by kind, then by -/// creation time and name. -pub fn render_all(sources: &[Source], secrets: &Secrets) -> Vec<(Source, Result)> { - let mut ordered: Vec = sources.to_vec(); - ordered.sort_by(|a, b| (a.kind, &a.created, &a.name).cmp(&(b.kind, &b.created, &b.name))); - - let provider_identities: BTreeMap = ordered - .iter() - .filter(|s| s.kind == Kind::ProviderKey) - .map(|s| (s.name.clone(), s.identity())) - .collect(); - - let mut seen: BTreeMap<(Kind, String), String> = BTreeMap::new(); - ordered - .into_iter() - .map(|s| { - let result = match seen.get(&(s.kind, s.identity())) { - Some(owner) => Err(format!( - "{IDENTITY_FIELD} {:?} is already used by {}/{owner}", - s.identity(), - s.kind.name() - )), - None => { - seen.insert((s.kind, s.identity()), s.name.clone()); - render_one(&s, secrets, &provider_identities) - } - }; - (s, result) - }) - .collect() -} - -fn render_one( - source: &Source, - secrets: &Secrets, - provider_identities: &BTreeMap, -) -> Result { - let mut fields = source.fields.clone(); - fields.insert(IDENTITY_FIELD.into(), Value::String(source.identity())); - - match source.kind { - Kind::ProviderKey => { - for inline in ["api_key", "secret"] { - if fields.contains_key(inline) { - return Err(format!( - "set the credential through {API_KEY_SECRET_REF}, not `{inline}`" - )); - } - } - let reference = fields - .remove(API_KEY_SECRET_REF) - .ok_or_else(|| format!("{API_KEY_SECRET_REF} is required"))?; - let value = secret_value(&reference, secrets, API_KEY_SECRET_REF)?; - fields.insert("api_key".into(), Value::String(value)); - } - Kind::Model => { - if fields.contains_key("provider_key_id") { - return Err(format!( - "reference the provider key through {PROVIDER_KEY_REF}" - )); - } - if let Some(reference) = fields.remove(PROVIDER_KEY_REF) { - let name = reference - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| format!("{PROVIDER_KEY_REF}.name is required"))?; - let identity = provider_identities.get(name).ok_or_else(|| { - format!("{PROVIDER_KEY_REF} names ProviderKey {name:?}, which does not exist") - })?; - fields.insert("provider_key".into(), Value::String(identity.clone())); - } - } - Kind::APIKey => { - if fields.contains_key("key_hash") { - return Err(format!( - "set the key through {KEY_SECRET_REF}, not `key_hash`" - )); - } - let reference = fields - .remove(KEY_SECRET_REF) - .ok_or_else(|| format!("{KEY_SECRET_REF} is required"))?; - let plaintext = secret_value(&reference, secrets, KEY_SECRET_REF)?; - fields.insert( - "key_hash".into(), - Value::String(ApiKey::hash_bearer(&plaintext)), - ); - } - } - - let mut entry = Value::Object(fields); - escape_dollars(&mut entry); - Ok(entry) -} - -fn secret_value(reference: &Value, secrets: &Secrets, field: &str) -> Result { - let name = reference - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| format!("{field}.name is required"))?; - let key = reference - .get("key") - .and_then(Value::as_str) - .ok_or_else(|| format!("{field}.key is required"))?; - let data = secrets - .get(name) - .ok_or_else(|| format!("{field}: Secret {name:?} not found"))?; - let bytes = data - .get(key) - .ok_or_else(|| format!("{field}: Secret {name:?} has no key {key:?}"))?; - let value = String::from_utf8(bytes.clone()) - .map_err(|_| format!("{field}: Secret {name:?} key {key:?} is not UTF-8"))?; - let value = value.trim_end_matches(['\r', '\n']).to_string(); - if value.is_empty() { - return Err(format!("{field}: Secret {name:?} key {key:?} is empty")); - } - Ok(value) -} - -/// The resources file interpolates `${VAR}` in every string scalar. Values -/// from custom resources are literal, so every `$` is written as `$$`. -pub fn escape_dollars(value: &mut Value) { - match value { - Value::String(s) if s.contains('$') => *s = s.replace('$', "$$"), - Value::Array(items) => items.iter_mut().for_each(escape_dollars), - Value::Object(map) => map.values_mut().for_each(escape_dollars), - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn source(kind: Kind, name: &str, fields: Value) -> Source { - Source { - kind, - name: name.into(), - generation: Some(1), - created: Some("2026-01-01T00:00:00Z".into()), - fields: fields.as_object().unwrap().clone(), - } - } - - fn secrets() -> Secrets { - let mut s = Secrets::new(); - s.insert( - "creds".into(), - BTreeMap::from([ - ("openai".into(), b"sk-$abc\n".to_vec()), - ("caller".into(), b"caller-key".to_vec()), - ("empty".into(), Vec::new()), - ]), - ); - s - } - - fn rendered(sources: &[Source]) -> BTreeMap> { - render_all(sources, &secrets()) - .into_iter() - .map(|(s, r)| (s.key().to_string(), r)) - .collect() - } - - #[test] - fn provider_key_takes_credential_from_secret_and_escapes_it() { - let out = rendered(&[source( - Kind::ProviderKey, - "openai", - json!({"provider": "openai", "api_base": "http://x/${not_env}", "api_key_secret_ref": {"name": "creds", "key": "openai"}}), - )]); - let entry = out["ProviderKey/openai"].as_ref().unwrap(); - assert_eq!(entry["api_key"], json!("sk-$$abc")); - assert_eq!(entry["api_base"], json!("http://x/$${not_env}")); - assert_eq!(entry["display_name"], json!("openai")); - assert!(entry.get(API_KEY_SECRET_REF).is_none()); - } - - #[test] - fn model_reference_resolves_to_provider_identity() { - let out = rendered(&[ - source( - Kind::ProviderKey, - "pk", - json!({"display_name": "Main OpenAI", "api_key_secret_ref": {"name": "creds", "key": "openai"}}), - ), - source( - Kind::Model, - "gpt", - json!({"display_name": "gpt-4o", "provider": "openai", "model_name": "gpt-4o", "provider_key_ref": {"name": "pk"}}), - ), - ]); - let entry = out["Model/gpt"].as_ref().unwrap(); - assert_eq!(entry["provider_key"], json!("Main OpenAI")); - assert_eq!(entry["display_name"], json!("gpt-4o")); - assert!(entry.get(PROVIDER_KEY_REF).is_none()); - } - - #[test] - fn api_key_is_hashed() { - let out = rendered(&[source( - Kind::APIKey, - "caller", - json!({"allowed_models": ["*"], "key_secret_ref": {"name": "creds", "key": "caller"}}), - )]); - let entry = out["APIKey/caller"].as_ref().unwrap(); - assert_eq!(entry["key_hash"], json!(ApiKey::hash_bearer("caller-key"))); - assert_eq!(entry["key_hash"].as_str().unwrap().len(), 64); - assert!(!entry.to_string().contains("caller-key")); - } - - #[test] - fn missing_references_are_errors() { - let out = rendered(&[ - source( - Kind::ProviderKey, - "a", - json!({"api_key_secret_ref": {"name": "nope", "key": "k"}}), - ), - source( - Kind::ProviderKey, - "b", - json!({"api_key_secret_ref": {"name": "creds", "key": "nope"}}), - ), - source( - Kind::ProviderKey, - "c", - json!({"api_key_secret_ref": {"name": "creds", "key": "empty"}}), - ), - source( - Kind::Model, - "m", - json!({"provider_key_ref": {"name": "ghost"}}), - ), - source(Kind::APIKey, "k", json!({})), - ]); - assert!(out["ProviderKey/a"] - .as_ref() - .unwrap_err() - .contains("not found")); - assert!(out["ProviderKey/b"] - .as_ref() - .unwrap_err() - .contains("has no key")); - assert!(out["ProviderKey/c"].as_ref().unwrap_err().contains("empty")); - assert!(out["Model/m"] - .as_ref() - .unwrap_err() - .contains("does not exist")); - assert!(out["APIKey/k"].as_ref().unwrap_err().contains("required")); - } - - #[test] - fn inline_credentials_are_refused() { - let out = rendered(&[source( - Kind::ProviderKey, - "a", - json!({"api_key": "sk", "api_key_secret_ref": {"name": "creds", "key": "openai"}}), - )]); - assert!(out["ProviderKey/a"] - .as_ref() - .unwrap_err() - .contains(API_KEY_SECRET_REF)); - } - - #[test] - fn duplicate_identity_loses_to_older_resource() { - let mut older = source(Kind::Model, "zeta", json!({"display_name": "same"})); - older.created = Some("2025-01-01T00:00:00Z".into()); - let newer = source(Kind::Model, "alpha", json!({"display_name": "same"})); - let out = rendered(&[newer, older]); - assert!(out["Model/zeta"].is_ok()); - assert!(out["Model/alpha"] - .as_ref() - .unwrap_err() - .contains("already used by Model/zeta")); - } - - #[test] - fn order_is_kind_then_age_then_name() { - let out = render_all( - &[ - source( - Kind::APIKey, - "k", - json!({"key_secret_ref": {"name": "creds", "key": "caller"}}), - ), - source(Kind::Model, "b", json!({})), - source(Kind::Model, "a", json!({})), - source( - Kind::ProviderKey, - "p", - json!({"api_key_secret_ref": {"name": "creds", "key": "openai"}}), - ), - ], - &secrets(), - ); - let names: Vec = out.iter().map(|(s, _)| s.key().to_string()).collect(); - assert_eq!(names, ["ProviderKey/p", "Model/a", "Model/b", "APIKey/k"]); - } -} diff --git a/src/schema.rs b/src/schema.rs deleted file mode 100644 index 7a33d95..0000000 --- a/src/schema.rs +++ /dev/null @@ -1,892 +0,0 @@ -//! CRD generation from aisix-core's strict resource schemas. -//! -//! aisix-core publishes draft-07 JSON Schemas (`resource_root_schema`). A -//! CRD needs a structural OpenAPI v3 schema, so this module: -//! -//! 1. shapes each resource for Kubernetes: drops the inline credential and -//! id-form references, and adds the reference fields the controller -//! resolves (`crd.rs`); -//! 2. converts the result to a structural schema: `$ref`s inlined, nullable -//! types folded, composition keywords merged or dropped. -//! -//! The conversion keeps types, required fields, enums, ranges, lengths and -//! patterns. It drops what a structural schema cannot carry (exclusive -//! `oneOf` branches, `if`/`then`, `not`, defaults, formats). Those rules are -//! still enforced: the controller validates every document with the -//! gateway's own pipeline before writing it. - -use std::collections::BTreeSet; - -use aisix_core::filesource::{mcp_ref_id_fields, model_ref_id_fields}; -use aisix_core::models::schema::resource_root_schema; -use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::{ - CustomResourceColumnDefinition, CustomResourceDefinition, CustomResourceValidation, - JSONSchemaProps, -}; -use kube::CustomResourceExt; -use serde_json::{json, Map, Value}; - -use crate::crd::{ - APIKey, Kind, Model, ProviderKey, API_KEY_SECRET_REF, IDENTITY_FIELD, KEY_SECRET_REF, - PROVIDER_KEY_REF, -}; - -/// Fields removed from a kind's spec, at any depth. -fn removed_fields(kind: Kind) -> BTreeSet<&'static str> { - let mut out: BTreeSet<&'static str> = BTreeSet::new(); - match kind { - Kind::ProviderKey => { - out.extend(["api_key", "secret"]); - } - Kind::Model => { - out.insert("provider_key_id"); - } - Kind::APIKey => { - out.insert("key_hash"); - } - } - // The resources file refuses id-form references; they are written by name. - for f in model_ref_id_fields(kind.collection()) { - out.insert(f.field); - } - for f in mcp_ref_id_fields(kind.collection()) { - out.insert(f.field); - } - out -} - -fn secret_key_ref(description: &str) -> Value { - json!({ - "type": "object", - "description": description, - "required": ["name", "key"], - "properties": { - "name": {"type": "string", "minLength": 1, "description": "Secret name, in the resource's namespace."}, - "key": {"type": "string", "minLength": 1, "description": "Key within the Secret."} - } - }) -} - -/// Reference fields the controller resolves, with whether each is required. -fn added_fields(kind: Kind) -> Vec<(&'static str, Value, bool)> { - match kind { - Kind::ProviderKey => vec![( - API_KEY_SECRET_REF, - secret_key_ref("Secret key holding the upstream provider credential. The controller writes it as `api_key`."), - true, - )], - Kind::Model => vec![( - PROVIDER_KEY_REF, - json!({ - "type": "object", - "description": "ProviderKey, in the same namespace, this model authenticates with. Direct models need it; routing, ensemble and semantic models leave it unset.", - "required": ["name"], - "properties": { - "name": {"type": "string", "minLength": 1, "description": "ProviderKey `metadata.name`."} - } - }), - false, - )], - Kind::APIKey => vec![( - KEY_SECRET_REF, - secret_key_ref("Secret key holding the caller's plaintext API key. The controller writes only its SHA-256 hash."), - true, - )], - } -} - -/// The aisix-core strict schema for a kind, shaped for Kubernetes. -/// Still draft-07 (with `definitions`); see [`to_structural`]. -pub fn shaped_schema(kind: Kind) -> Value { - let mut root = resource_root_schema(kind.schema_resource(), true); - let removed = removed_fields(kind); - strip_fields(&mut root, &removed); - - let obj = root.as_object_mut().expect("resource schema is an object"); - // Root compositions only encode cross-field rules (credential spelling, - // model shape), which the gateway pipeline enforces. - for k in ["anyOf", "oneOf", "allOf", "if", "then", "else", "not"] { - obj.remove(k); - } - - let mut required: Vec = obj - .get("required") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .into_iter() - .filter(|v| v.as_str() != Some(IDENTITY_FIELD)) - .collect(); - - let props = obj - .entry("properties") - .or_insert_with(|| json!({})) - .as_object_mut() - .expect("properties is an object"); - if let Some(Value::Object(identity)) = props.get_mut(IDENTITY_FIELD) { - let base = identity - .get("description") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - identity.insert( - "description".into(), - Value::String( - format!("{base} Defaults to `metadata.name`.",) - .trim() - .to_string(), - ), - ); - } - for (name, schema, is_required) in added_fields(kind) { - props.insert(name.to_string(), schema); - if is_required { - required.push(Value::String(name.to_string())); - } - } - if required.is_empty() { - obj.remove("required"); - } else { - obj.insert("required".into(), Value::Array(required)); - } - root -} - -/// Remove the named properties everywhere: in `properties`, `required`, and -/// inside definitions and compositions. -fn strip_fields(node: &mut Value, removed: &BTreeSet<&str>) { - match node { - Value::Object(map) => { - if let Some(Value::Object(props)) = map.get_mut("properties") { - props.retain(|k, _| !removed.contains(k.as_str())); - } - if let Some(Value::Array(req)) = map.get_mut("required") { - req.retain(|v| v.as_str().is_none_or(|s| !removed.contains(s))); - } - for (_, v) in map.iter_mut() { - strip_fields(v, removed); - } - } - Value::Array(items) => { - for v in items { - strip_fields(v, removed); - } - } - _ => {} - } -} - -const PRESERVE: &str = "x-kubernetes-preserve-unknown-fields"; -const INT_OR_STRING: &str = "x-kubernetes-int-or-string"; - -/// Keywords copied as-is onto a structural node. -const KEPT: &[&str] = &[ - "description", - "enum", - "minimum", - "maximum", - "minLength", - "maxLength", - "pattern", - "minItems", - "maxItems", - "minProperties", - "maxProperties", - "required", -]; - -fn preserve(description: Option<&Value>) -> Value { - let mut m = Map::new(); - if let Some(d) = description { - m.insert("description".into(), d.clone()); - } - m.insert(PRESERVE.into(), Value::Bool(true)); - Value::Object(m) -} - -/// Convert a draft-07 schema (with its root `definitions`) to a structural -/// OpenAPI v3 schema. -pub fn to_structural(root: &Value) -> Value { - let defs = root - .get("definitions") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let mut stack = Vec::new(); - let mut out = convert(root, &defs, &mut stack); - if let Value::Object(m) = &mut out { - // A spec is always an object. - m.remove(PRESERVE); - m.insert("type".into(), json!("object")); - } - out -} - -/// True when a composition branch only constrains values (required fields, -/// negations) and adds no shape of its own. -fn is_constraint_only(branch: &Value) -> bool { - match branch { - Value::Object(m) => m.keys().all(|k| { - matches!( - k.as_str(), - "required" | "not" | "title" | "description" | "if" | "then" | "else" - ) - }), - _ => false, - } -} - -fn is_null_branch(branch: &Value) -> bool { - branch.get("type").and_then(Value::as_str) == Some("null") -} - -/// Resolve `$ref` and single-branch `allOf` into one flat schema object, -/// keeping the referring node's own keywords (its description wins). -fn flatten( - node: &Map, - defs: &Map, - stack: &mut Vec, -) -> Option> { - let mut out = node.clone(); - if let Some(r) = out.remove("$ref") { - let name = r.as_str()?.strip_prefix("#/definitions/")?.to_string(); - if stack.contains(&name) { - return None; - } - let target = defs.get(&name)?.as_object()?.clone(); - stack.push(name); - let resolved = flatten(&target, defs, stack); - stack.pop(); - let mut merged = resolved?; - for (k, v) in out { - merged.insert(k, v); - } - out = merged; - } - if let Some(Value::Array(all)) = out.get("allOf").cloned() { - let shaped: Vec<&Value> = all.iter().filter(|b| !is_constraint_only(b)).collect(); - if shaped.len() == 1 { - out.remove("allOf"); - let branch = shaped[0].as_object()?; - let mut merged = flatten(branch, defs, stack)?; - for (k, v) in out { - merged.insert(k, v); - } - out = merged; - } - } - Some(out) -} - -fn convert(node: &Value, defs: &Map, stack: &mut Vec) -> Value { - let map = match node { - Value::Bool(true) => return preserve(None), - Value::Object(m) if m.is_empty() => return preserve(None), - Value::Object(m) => m, - _ => return preserve(None), - }; - let Some(mut m) = flatten(map, defs, stack) else { - return preserve(map.get("description")); - }; - - let mut nullable = false; - - // anyOf / oneOf / multi-branch allOf. - for key in ["anyOf", "oneOf", "allOf"] { - let Some(Value::Array(branches)) = m.remove(key) else { - continue; - }; - let mut shaped = Vec::new(); - for b in branches { - if is_null_branch(&b) { - nullable = true; - } else if !is_constraint_only(&b) { - shaped.push(b); - } - } - if shaped.is_empty() { - continue; - } - let converted: Vec = shaped.iter().map(|b| convert(b, defs, stack)).collect(); - match merge_branches(&converted, key == "allOf") { - Some(merged) => { - for (k, v) in merged { - if k == "description" && m.contains_key("description") { - continue; - } - if k == "properties" { - let props = m.entry("properties").or_insert_with(|| json!({})); - if let (Value::Object(dst), Value::Object(src)) = (props, v) { - for (pk, pv) in src { - dst.entry(pk).or_insert(pv); - } - } - continue; - } - m.entry(k).or_insert(v); - } - } - None => { - let mut p = preserve(m.get("description")).as_object().cloned().unwrap(); - if converted.len() == 2 - && converted - .iter() - .any(|c| c.get("type") == Some(&json!("integer"))) - && converted - .iter() - .any(|c| c.get("type") == Some(&json!("string"))) - { - p.remove(PRESERVE); - p.insert(INT_OR_STRING.into(), Value::Bool(true)); - } - if nullable { - p.insert("nullable".into(), Value::Bool(true)); - } - return Value::Object(p); - } - } - } - - // type: [X, "null"] - let mut ty: Option = None; - match m.remove("type") { - Some(Value::String(t)) if t == "null" => nullable = true, - Some(Value::String(t)) => ty = Some(t), - Some(Value::Array(ts)) => { - let mut rest: Vec = Vec::new(); - for t in ts { - match t.as_str() { - Some("null") => nullable = true, - Some(s) => rest.push(s.to_string()), - None => {} - } - } - match rest.len() { - 0 => {} - 1 => ty = rest.pop(), - _ => { - let mut p = preserve(m.get("description")).as_object().cloned().unwrap(); - if rest.len() == 2 - && rest.contains(&"integer".to_string()) - && rest.contains(&"string".to_string()) - { - p.remove(PRESERVE); - p.insert(INT_OR_STRING.into(), Value::Bool(true)); - } - if nullable { - p.insert("nullable".into(), Value::Bool(true)); - } - return Value::Object(p); - } - } - } - _ => {} - } - - let mut out = Map::new(); - for k in KEPT { - if let Some(v) = m.get(*k) { - out.insert((*k).to_string(), v.clone()); - } - } - if let Some(c) = m.get("const") { - out.insert("enum".into(), json!([c])); - } - // draft-07 exclusive bounds are numbers; OpenAPI v3.0 uses booleans. - for (ex, base) in [ - ("exclusiveMinimum", "minimum"), - ("exclusiveMaximum", "maximum"), - ] { - if let Some(n) = m.get(ex).filter(|v| v.is_number()) { - out.insert(base.into(), n.clone()); - out.insert(ex.into(), Value::Bool(true)); - } - } - if let Some(Value::Array(values)) = out.get_mut("enum") { - if values.iter().any(Value::is_null) { - nullable = true; - values.retain(|v| !v.is_null()); - } - if values.is_empty() { - out.remove("enum"); - } - } - - let props = m.get("properties").and_then(Value::as_object); - let additional = m.get("additionalProperties"); - let items = m.get("items"); - - if ty.is_none() { - ty = if props.is_some() || additional.is_some() { - Some("object".into()) - } else if items.is_some() { - Some("array".into()) - } else if let Some(Value::Array(values)) = out.get("enum") { - infer_enum_type(values) - } else { - None - }; - } - - match ty.as_deref() { - Some("object") => { - let additional_schema = match additional { - Some(Value::Object(a)) if !a.is_empty() => { - Some(convert(additional.unwrap(), defs, stack)) - } - Some(Value::Bool(true)) | Some(Value::Object(_)) => Some(preserve(None)), - _ => None, - }; - let props_nonempty = props.is_some_and(|p| !p.is_empty()); - if let (Some(a), false) = (&additional_schema, props_nonempty) { - out.insert("additionalProperties".into(), a.clone()); - } else if props_nonempty { - let mut converted = Map::new(); - for (k, v) in props.unwrap() { - if k.is_empty() { - continue; - } - converted.insert(k.clone(), convert(v, defs, stack)); - } - out.insert("properties".into(), Value::Object(converted)); - } else if let Some(a) = additional_schema { - out.insert("additionalProperties".into(), a); - } else { - out.insert(PRESERVE.into(), Value::Bool(true)); - } - out.insert("type".into(), json!("object")); - prune_required(&mut out); - } - Some("array") => { - let item = match items { - Some(Value::Array(_)) | None => preserve(None), - Some(i) => convert(i, defs, stack), - }; - out.insert("items".into(), item); - out.insert("type".into(), json!("array")); - out.remove("required"); - } - Some(t @ ("string" | "integer" | "number" | "boolean")) => { - out.insert("type".into(), json!(t)); - out.remove("required"); - if let Some(Value::Array(values)) = out.get("enum") { - if !values.iter().all(|v| matches_type(v, t)) { - out.remove("enum"); - } - } - } - _ => { - let mut p = preserve(m.get("description")).as_object().cloned().unwrap(); - if nullable { - p.insert("nullable".into(), Value::Bool(true)); - } - return Value::Object(p); - } - } - if nullable { - out.insert("nullable".into(), Value::Bool(true)); - } - Value::Object(out) -} - -/// Keep only `required` entries that name a declared property. -fn prune_required(out: &mut Map) { - let declared: BTreeSet = out - .get("properties") - .and_then(Value::as_object) - .map(|p| p.keys().cloned().collect()) - .unwrap_or_default(); - if let Some(Value::Array(req)) = out.get_mut("required") { - req.retain(|v| v.as_str().is_some_and(|s| declared.contains(s))); - if req.is_empty() { - out.remove("required"); - } - } -} - -fn matches_type(v: &Value, t: &str) -> bool { - match t { - "string" => v.is_string(), - "integer" => v.is_i64() || v.is_u64(), - "number" => v.is_number(), - "boolean" => v.is_boolean(), - _ => false, - } -} - -fn infer_enum_type(values: &[Value]) -> Option { - for t in ["string", "integer", "number", "boolean"] { - if !values.is_empty() && values.iter().all(|v| matches_type(v, t)) { - return Some(t.into()); - } - } - None -} - -/// Merge converted branches into one shape, or `None` when their types differ. -/// `all` (allOf) keeps the union of `required`; anyOf/oneOf drop it. -fn merge_branches(branches: &[Value], all: bool) -> Option> { - let types: BTreeSet> = branches - .iter() - .map(|b| b.get("type").and_then(Value::as_str)) - .collect(); - if types.len() != 1 || types.contains(&None) { - return None; - } - let ty = types.into_iter().next().flatten()?; - let mut out = Map::new(); - out.insert("type".into(), json!(ty)); - match ty { - "object" => { - let mut props = Map::new(); - let mut required: BTreeSet = BTreeSet::new(); - let mut any_preserve = false; - for b in branches { - if let Some(Value::Object(p)) = b.get("properties") { - for (k, v) in p { - props.entry(k.clone()).or_insert_with(|| v.clone()); - } - } - if b.get(PRESERVE) == Some(&Value::Bool(true)) - || b.get("additionalProperties").is_some() - { - any_preserve = true; - } - if all { - if let Some(Value::Array(r)) = b.get("required") { - required.extend(r.iter().filter_map(|v| v.as_str().map(String::from))); - } - } - } - if any_preserve { - // Branch shapes disagree on openness; keep everything. - out.insert(PRESERVE.into(), Value::Bool(true)); - } - if !props.is_empty() { - out.insert("properties".into(), Value::Object(props)); - } - if !required.is_empty() { - out.insert( - "required".into(), - Value::Array(required.into_iter().map(Value::String).collect()), - ); - } - } - "array" => { - out.insert("items".into(), preserve(None)); - } - _ => { - let mut values: Vec = Vec::new(); - let mut all_enum = true; - for b in branches { - match b.get("enum") { - Some(Value::Array(e)) => { - for v in e { - if !values.contains(v) { - values.push(v.clone()); - } - } - } - _ => all_enum = false, - } - } - if all_enum && !values.is_empty() { - out.insert("enum".into(), Value::Array(values)); - } - } - } - Some(out) -} - -fn status_schema() -> Value { - json!({ - "type": "object", - "properties": { - "observedGeneration": {"type": "integer", "format": "int64"}, - "conditions": { - "type": "array", - "x-kubernetes-list-type": "map", - "x-kubernetes-list-map-keys": ["type"], - "items": { - "type": "object", - "required": ["type", "status", "lastTransitionTime", "reason", "message"], - "properties": { - "type": {"type": "string"}, - "status": {"type": "string", "enum": ["True", "False", "Unknown"]}, - "observedGeneration": {"type": "integer", "format": "int64"}, - "lastTransitionTime": {"type": "string", "format": "date-time"}, - "reason": {"type": "string"}, - "message": {"type": "string"} - } - } - } - } - }) -} - -/// The full structural schema for a kind's custom resource. -pub fn resource_schema(kind: Kind) -> Value { - let spec = to_structural(&shaped_schema(kind)); - json!({ - "type": "object", - "description": format!("AISIX {} resource.", kind.name()), - "required": ["spec"], - "properties": { - "apiVersion": {"type": "string"}, - "kind": {"type": "string"}, - "metadata": {"type": "object"}, - "spec": spec, - "status": status_schema() - } - }) -} - -fn condition_column(name: &str) -> CustomResourceColumnDefinition { - CustomResourceColumnDefinition { - name: name.to_string(), - type_: "string".to_string(), - json_path: format!(".status.conditions[?(@.type==\"{name}\")].status"), - ..Default::default() - } -} - -/// The CustomResourceDefinition for a kind. -pub fn crd(kind: Kind) -> CustomResourceDefinition { - let mut crd = match kind { - Kind::ProviderKey => ProviderKey::crd(), - Kind::Model => Model::crd(), - Kind::APIKey => APIKey::crd(), - }; - let props: JSONSchemaProps = - serde_json::from_value(resource_schema(kind)).expect("structural schema deserializes"); - for v in &mut crd.spec.versions { - v.schema = Some(CustomResourceValidation { - open_api_v3_schema: Some(props.clone()), - }); - v.additional_printer_columns = Some(vec![ - condition_column("Accepted"), - condition_column("Programmed"), - CustomResourceColumnDefinition { - name: "Age".into(), - type_: "date".into(), - json_path: ".metadata.creationTimestamp".into(), - ..Default::default() - }, - ]); - } - crd -} - -/// CRD manifest as YAML, with a generated-file header. -pub fn crd_yaml(kind: Kind) -> String { - let body = serde_yaml::to_string(&crd(kind)).expect("CRD serializes"); - format!("# Generated by `aisix-controller crds` from aisix-core. Do not edit.\n{body}") -} - -/// File name a kind's CRD is written to. -pub fn crd_file_name(kind: Kind) -> String { - let crd = crd(kind); - format!("{}.yaml", crd.metadata.name.unwrap_or_default()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn walk<'a>(node: &'a Value, path: String, f: &mut dyn FnMut(&str, &'a Map)) { - if let Value::Object(m) = node { - f(&path, m); - if let Some(Value::Object(p)) = m.get("properties") { - for (k, v) in p { - walk(v, format!("{path}.{k}"), f); - } - } - if let Some(i) = m.get("items") { - walk(i, format!("{path}[]"), f); - } - if let Some(a @ Value::Object(_)) = m.get("additionalProperties") { - walk(a, format!("{path}{{}}"), f); - } - } - } - - /// The subset of apiextensions' structural rules a generator can break. - fn assert_structural(schema: &Value) { - let forbidden = [ - "$ref", - "$schema", - "definitions", - "oneOf", - "anyOf", - "allOf", - "not", - "if", - "then", - "else", - "const", - "default", - "title", - "propertyNames", - "patternProperties", - "uniqueItems", - "examples", - ]; - walk(schema, "spec".into(), &mut |path, m| { - for k in forbidden { - assert!(!m.contains_key(k), "{path}: forbidden keyword {k}"); - } - let open = m.get(PRESERVE) == Some(&Value::Bool(true)) - || m.get(INT_OR_STRING) == Some(&Value::Bool(true)); - assert!(open || m.contains_key("type"), "{path}: node without type"); - if m.contains_key("properties") { - assert!( - !m.contains_key("additionalProperties"), - "{path}: properties with additionalProperties" - ); - assert_eq!( - m.get("type"), - Some(&json!("object")), - "{path}: properties on a non-object" - ); - } - if m.get("type") == Some(&json!("array")) { - assert!(m.contains_key("items"), "{path}: array without items"); - } - if let Some(Value::Bool(_)) = m.get("additionalProperties") { - panic!("{path}: boolean additionalProperties"); - } - if let Some(ex) = m.get("exclusiveMinimum") { - assert!(ex.is_boolean(), "{path}: numeric exclusiveMinimum"); - } - }); - } - - fn spec(kind: Kind) -> Value { - resource_schema(kind)["properties"]["spec"].clone() - } - - fn has_property_anywhere(node: &Value, name: &str) -> bool { - let mut found = false; - walk(node, String::new(), &mut |_, m| { - if let Some(Value::Object(p)) = m.get("properties") { - found |= p.contains_key(name); - } - }); - found - } - - #[test] - fn every_spec_is_structural() { - for kind in Kind::ALL { - assert_structural(&spec(kind)); - } - } - - #[test] - fn credentials_never_appear_inline() { - let pk = spec(Kind::ProviderKey); - assert!(!has_property_anywhere(&pk, "api_key")); - assert!(!has_property_anywhere(&pk, "secret")); - assert!(pk["properties"][API_KEY_SECRET_REF].is_object()); - assert!(pk["required"] - .as_array() - .unwrap() - .contains(&json!(API_KEY_SECRET_REF))); - - let key = spec(Kind::APIKey); - assert!(!has_property_anywhere(&key, "key_hash")); - assert!(key["required"] - .as_array() - .unwrap() - .contains(&json!(KEY_SECRET_REF))); - } - - #[test] - fn id_form_references_are_removed() { - let model = spec(Kind::Model); - for f in [ - "provider_key_id", - "model_id", - "target_id", - "embedding_model_id", - "default_id", - ] { - assert!(!has_property_anywhere(&model, f), "model still has {f}"); - } - assert!(model["properties"][PROVIDER_KEY_REF].is_object()); - assert!(has_property_anywhere(&model, "routing")); - - let key = spec(Kind::APIKey); - for f in [ - "allowed_model_ids", - "mcp_rate_limits_by_id", - "allow_ids", - "deny_ids", - ] { - assert!(!has_property_anywhere(&key, f), "api key still has {f}"); - } - assert!(has_property_anywhere(&key, "allowed_models")); - } - - #[test] - fn identity_is_optional() { - for kind in Kind::ALL { - let s = spec(kind); - let required = s - .get("required") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - assert!(!required.contains(&json!(IDENTITY_FIELD)), "{kind:?}"); - assert!(s["properties"][IDENTITY_FIELD].is_object(), "{kind:?}"); - } - } - - #[test] - fn nullable_and_refs_convert() { - let root = json!({ - "definitions": {"Inner": {"type": "object", "properties": {"a": {"type": "integer", "minimum": 0.0}}}}, - "type": "object", - "properties": { - "opt": {"type": ["string", "null"]}, - "inner": {"anyOf": [{"$ref": "#/definitions/Inner"}, {"type": "null"}]}, - "wrapped": {"allOf": [{"$ref": "#/definitions/Inner"}], "description": "kept"}, - "mixed": {"anyOf": [{"type": "string"}, {"type": "object", "properties": {"x": {"type": "string"}}}]}, - "ios": {"type": ["integer", "string"]}, - "c": {"const": "fixed"}, - "map": {"type": "object", "additionalProperties": {"type": "string"}}, - "free": {"type": "object"}, - "ex": {"type": "number", "exclusiveMinimum": 0.0} - } - }); - let s = to_structural(&root); - let p = &s["properties"]; - assert_eq!(p["opt"], json!({"type": "string", "nullable": true})); - assert_eq!(p["inner"]["nullable"], json!(true)); - assert_eq!(p["inner"]["properties"]["a"]["type"], json!("integer")); - assert_eq!(p["wrapped"]["description"], json!("kept")); - assert_eq!(p["wrapped"]["properties"]["a"]["minimum"], json!(0.0)); - assert_eq!(p["mixed"][PRESERVE], json!(true)); - assert_eq!(p["ios"][INT_OR_STRING], json!(true)); - assert_eq!(p["c"], json!({"type": "string", "enum": ["fixed"]})); - assert_eq!(p["map"]["additionalProperties"], json!({"type": "string"})); - assert_eq!(p["free"][PRESERVE], json!(true)); - assert_eq!(p["ex"]["exclusiveMinimum"], json!(true)); - assert_structural(&s); - } - - #[test] - fn committed_crds_match_generator() { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("deploy/crds"); - for kind in Kind::ALL { - let path = dir.join(crd_file_name(kind)); - let committed = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("{}: {e}; run `make crds`", path.display())); - assert!( - committed == crd_yaml(kind), - "{} is stale; run `make crds`", - path.display() - ); - } - } -} diff --git a/tests/e2e/manifests/mock-openai.yaml b/tests/e2e/manifests/mock-openai.yaml deleted file mode 100644 index 3b772ff..0000000 --- a/tests/e2e/manifests/mock-openai.yaml +++ /dev/null @@ -1,76 +0,0 @@ -# OpenAI-compatible upstream that echoes what the gateway sent it. -apiVersion: v1 -kind: ConfigMap -metadata: - name: mock-openai -data: - server.py: | - import json - from http.server import BaseHTTPRequestHandler, HTTPServer - - class Handler(BaseHTTPRequestHandler): - def _send(self, code, body): - data = json.dumps(body).encode() - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def do_GET(self): - self._send(200, {"ok": True}) - - def do_POST(self): - length = int(self.headers.get("Content-Length") or 0) - req = json.loads(self.rfile.read(length) or b"{}") - model = req.get("model", "") - auth = self.headers.get("Authorization", "") - self._send(200, { - "id": "chatcmpl-e2e", - "object": "chat.completion", - "created": 0, - "model": model, - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": f"upstream-model={model} auth={auth}"}, - "finish_reason": "stop", - }], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, - }) - - def log_message(self, *args): - pass - - HTTPServer(("0.0.0.0", 8080), Handler).serve_forever() ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mock-openai -spec: - replicas: 1 - selector: - matchLabels: {app: mock-openai} - template: - metadata: - labels: {app: mock-openai} - spec: - containers: - - name: mock - image: python:3.12-alpine - command: ["python", "-u", "/app/server.py"] - ports: [{containerPort: 8080}] - readinessProbe: - httpGet: {path: /healthz, port: 8080} - volumeMounts: [{name: app, mountPath: /app}] - volumes: - - name: app - configMap: {name: mock-openai} ---- -apiVersion: v1 -kind: Service -metadata: - name: mock-openai -spec: - selector: {app: mock-openai} - ports: [{port: 8080, targetPort: 8080}] diff --git a/tests/e2e/manifests/resources.yaml b/tests/e2e/manifests/resources.yaml deleted file mode 100644 index ff71e2c..0000000 --- a/tests/e2e/manifests/resources.yaml +++ /dev/null @@ -1,43 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: e2e-creds -type: Opaque -stringData: - # A literal `$` must reach the upstream unchanged. - upstream: "sk-e2e-$dollar" - caller: "caller-e2e-key" ---- -apiVersion: aisix.api7.ai/v1alpha1 -kind: ProviderKey -metadata: - name: mock -spec: - provider: openai - adapter: openai - api_base: http://mock-openai:8080/v1 - api_key_secret_ref: - name: e2e-creds - key: upstream ---- -apiVersion: aisix.api7.ai/v1alpha1 -kind: Model -metadata: - name: chat -spec: - # Not a valid Kubernetes name, so it has to come from display_name. - display_name: e2e/chat - provider: openai - model_name: upstream-a - provider_key_ref: - name: mock ---- -apiVersion: aisix.api7.ai/v1alpha1 -kind: APIKey -metadata: - name: caller -spec: - allowed_models: ["*"] - key_secret_ref: - name: e2e-creds - key: caller diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh deleted file mode 100755 index 17c3d02..0000000 --- a/tests/e2e/run.sh +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env bash -# End-to-end test: kind cluster, AISIX gateway from its Helm chart, the -# controller, and a mock OpenAI upstream. -# -# tests/e2e/run.sh # creates and deletes its own kind cluster -# KEEP=1 tests/e2e/run.sh # leaves the cluster up for inspection -set -euo pipefail -cd "$(dirname "$0")/../.." - -CLUSTER=${CLUSTER:-aisix-controller-e2e} -NS=${NS:-aisix-e2e} -IMAGE=${IMAGE:-aisix-controller:dev} -AISIX_CHART_VERSION=${AISIX_CHART_VERSION:-1.9.0} -KEEP=${KEEP:-0} -MANIFESTS=tests/e2e/manifests -CREATED_CLUSTER=0 - -log() { printf '\n==> %s\n' "$*"; } -fail() { echo "FAIL: $*" >&2; exit 1; } - -dump() { - echo "---- diagnostics ----" - kubectl -n "$NS" get pods,deploy,secrets -o wide || true - kubectl -n "$NS" get providerkeys,models,apikeys -o yaml || true - kubectl -n "$NS" logs deploy/aisix-controller --tail=200 || true - kubectl -n "$NS" logs deploy/aisix --tail=100 || true -} - -cleanup() { - local rc=$? - if [ "$rc" -ne 0 ]; then dump; fi - if [ "$KEEP" != 1 ] && [ "$CREATED_CLUSTER" = 1 ]; then - kind delete cluster --name "$CLUSTER" >/dev/null 2>&1 || true - fi - exit "$rc" -} -trap cleanup EXIT - -k() { kubectl -n "$NS" "$@"; } - -# wait_condition : the condition holds for -# the resource's current generation. -wait_condition() { - local res=$1 name=$2 type=$3 want=$4 gen got - for _ in $(seq 1 120); do - gen=$(k get "$res" "$name" -o jsonpath='{.metadata.generation}') - got=$(k get "$res" "$name" -o jsonpath="{.status.conditions[?(@.type==\"$type\")].status}/{.status.conditions[?(@.type==\"$type\")].observedGeneration}") - if [ "$got" = "$want/$gen" ]; then return 0; fi - sleep 2 - done - k get "$res" "$name" -o yaml - fail "$res/$name: $type never became $want for generation $gen (last: $got)" -} - -# chat : prints " " for a request through the gateway. -chat() { - local out - out=$(k exec curl -- curl -s -w '\n%{http_code}' \ - -H "Authorization: Bearer $1" -H 'Content-Type: application/json' \ - -d '{"model":"e2e/chat","messages":[{"role":"user","content":"hi"}]}' \ - http://aisix/v1/chat/completions 2>/dev/null) || true - printf '%s %s\n' "${out##*$'\n'}" "${out%$'\n'*}" -} - -# expect_chat [substring]: retries while the gateway rolls. -expect_chat() { - local key=$1 code=$2 want=${3:-} out - for _ in $(seq 1 60); do - out=$(chat "$key") - if [ "${out%% *}" = "$code" ] && { [ -z "$want" ] || [[ "$out" == *"$want"* ]]; }; then - echo "ok: $out" - return 0 - fi - sleep 2 - done - fail "expected $code ${want:+containing '$want'}, got: $out" -} - -log "kind cluster $CLUSTER" -if ! kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then - kind create cluster --name "$CLUSTER" --wait 120s - CREATED_CLUSTER=1 -fi -kubectl config use-context "kind-$CLUSTER" >/dev/null - -log "build and load $IMAGE" -docker build -t "$IMAGE" . -kind load docker-image "$IMAGE" --name "$CLUSTER" - -log "install CRDs and controller" -kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - -kubectl apply --server-side -k deploy/crds -kubectl wait --for=condition=Established crd/providerkeys.aisix.api7.ai crd/models.aisix.api7.ai crd/apikeys.aisix.api7.ai --timeout=60s -k apply -k deploy/controller -k set image deploy/aisix-controller controller="$IMAGE" -k rollout status deploy/aisix-controller --timeout=120s - -log "controller seeds the resources Secret" -for _ in $(seq 1 60); do k get secret aisix-resources >/dev/null 2>&1 && break; sleep 1; done -k get secret aisix-resources >/dev/null || fail "aisix-resources Secret was not created" - -log "mock upstream and curl client" -k apply -f "$MANIFESTS/mock-openai.yaml" -k run curl --image=curlimages/curl:8.11.1 --restart=Never --dry-run=client -o yaml --command -- sleep infinity | k apply -f - -k rollout status deploy/mock-openai --timeout=120s -k wait --for=condition=Ready pod/curl --timeout=120s - -log "AISIX $AISIX_CHART_VERSION from its Helm chart, standalone mode" -helm repo add api7 https://charts.api7.ai >/dev/null 2>&1 || true -helm repo update api7 >/dev/null -# AISIX_CHART may point at a local chart .tgz; otherwise it is downloaded. -CHART=${AISIX_CHART:-} -if [ -z "$CHART" ]; then - CHART_DIR=$(mktemp -d) - CHART="$CHART_DIR/aisix-$AISIX_CHART_VERSION.tgz" - for i in 1 2 3; do - helm pull api7/aisix --version "$AISIX_CHART_VERSION" -d "$CHART_DIR" && break - # The chart index points at GitHub release assets; fetch through the API instead. - if command -v gh >/dev/null && gh release download "aisix-$AISIX_CHART_VERSION" \ - --repo api7/api7-helm-chart -p "aisix-$AISIX_CHART_VERSION.tgz" -D "$CHART_DIR" --clobber; then - break - fi - [ "$i" = 3 ] && fail "could not download the aisix chart" - sleep $((i * 5)) - done -fi -helm upgrade --install aisix "$CHART" -n "$NS" \ - --set controlPlane.enabled=false \ - --set standalone.existingSecret=aisix-resources \ - --set fullnameOverride=aisix \ - --set replicaCount=1 \ - --wait --timeout 5m - -log "1. resources are applied and served" -k apply -f "$MANIFESTS/resources.yaml" -for r in providerkey/mock model/chat apikey/caller; do - wait_condition "${r%%/*}" "${r##*/}" Accepted True - wait_condition "${r%%/*}" "${r##*/}" Programmed True -done -expect_chat caller-e2e-key 200 'upstream-model=upstream-a auth=Bearer sk-e2e-$dollar' - -log "2. an update reaches the gateway" -k patch model chat --type=merge -p '{"spec":{"model_name":"upstream-b"}}' -wait_condition model chat Programmed True -expect_chat caller-e2e-key 200 'upstream-model=upstream-b' - -log "3. the CRD schema rejects unknown fields" -if k apply -f - 2>/tmp/e2e-err <<'YAML' -apiVersion: aisix.api7.ai/v1alpha1 -kind: Model -metadata: - name: bogus -spec: - no_such_field: 1 -YAML -then - fail "unknown field was accepted" -fi -grep -q 'no_such_field' /tmp/e2e-err || fail "unexpected error: $(cat /tmp/e2e-err)" -echo "ok: $(cat /tmp/e2e-err)" - -log "4. a change the gateway rejects keeps the served version" -# Passes the CRD schema; the gateway refuses a direct model that also routes. -k patch model chat --type=merge -p '{"spec":{"routing":{"targets":[{"model":"e2e/chat"}]}}}' -wait_condition model chat Accepted False -k get model chat -o jsonpath='{.status.conditions[?(@.type=="Accepted")].message}'; echo -expect_chat caller-e2e-key 200 'upstream-model=upstream-b' -k patch model chat --type=json -p '[{"op":"remove","path":"/spec/routing"}]' -wait_condition model chat Accepted True -wait_condition model chat Programmed True - -log "5. a dangling reference is reported" -k patch model chat --type=merge -p '{"spec":{"provider_key_ref":{"name":"ghost"}}}' -wait_condition model chat Accepted False -k get model chat -o jsonpath='{.status.conditions[?(@.type=="Accepted")].message}' | grep -q 'ghost' \ - || fail "Accepted message does not name the missing ProviderKey" -expect_chat caller-e2e-key 200 'upstream-model=upstream-b' -k patch model chat --type=merge -p '{"spec":{"provider_key_ref":{"name":"mock"}}}' -wait_condition model chat Programmed True - -log "6. deleting the APIKey revokes it" -k delete apikey caller -expect_chat caller-e2e-key 401 - -log "PASS"