diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index d93732c1..2fb6a559 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -189,3 +189,91 @@ jobs: # Run API7 E2E tests - name: Run E2E tests run: npx nx run backend-api7:test + api7-rust: + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'test/api7') || github.event_name == 'push' + permissions: + contents: read + packages: read + strategy: + fail-fast: false + matrix: + version: [3.5.5, 3.6.1, 3.7.8, 3.8.23, 3.9.14, 3.10.1, dev] + steps: + - name: Determine API7 image and license + run: | + if [ "${{ matrix.version }}" = "dev" ]; then + echo "BACKEND_API7_VERSION=999.999.999" >> $GITHUB_ENV + echo "API7_DASHBOARD_IMAGE=ghcr.io/api7/api7-ee-3-integrated" >> $GITHUB_ENV + echo "API7_IMAGE_TAG=dev" >> $GITHUB_ENV + { + echo "BACKEND_API7_LICENSE<> $GITHUB_ENV + else + echo "BACKEND_API7_VERSION=${{ matrix.version }}" >> $GITHUB_ENV + echo "API7_DASHBOARD_IMAGE=api7/api7-ee-3-integrated" >> $GITHUB_ENV + echo "API7_IMAGE_TAG=v${{ matrix.version }}" >> $GITHUB_ENV + { + echo "BACKEND_API7_LICENSE<> $GITHUB_ENV + fi + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Login to GHCR + if: matrix.version == 'dev' + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Runs on its own dashboard instance, independent of the `api7` job's + # (rather than reusing that job's live instance after the TS suite runs) + # — the Rust e2e suite's `common::client()` performs its own admin + # login / password rotation / license activation / token minting on + # first use (see adc-backend-api7's tests/common/mod.rs), which would + # collide with the TS suite doing the same dance against a shared + # instance. + - name: Setup API7 Instance via Docker Compose + working-directory: ./libs/backend-api7/e2e/assets + run: | + if [ "${{ matrix.version }}" = "dev" ]; then docker compose pull; fi + docker compose up -d + + # Run the Rust port's E2E tests + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: rust -> target + # Each test file below is its own process with no state shared + # between them, but they all talk to the one dashboard instance + # started above — so the admin login / password rotation / license + # activation / token minting dance runs here exactly once, and its + # result is shared with every other test file as `TOKEN` (see + # tests/e2e_init.rs), instead of each independently repeating it + # against a dashboard the first one to run already mutated. + - name: Bootstrap a shared API7 token + working-directory: ./rust + run: | + rustup update stable + rustup default stable + cargo test -p adc-backend-api7 --test e2e_init -- --ignored + - name: Run Rust E2E tests + working-directory: ./rust + run: | + rustup update stable + rustup default stable + cargo test -p adc-backend-api7 -- --ignored --test-threads=1 + # Only useful when the step above fails: `BackendError::Api`'s + # message is empty for a 500 with no response body, which the + # dashboard's own logs can actually explain. + - name: Dump API7 dashboard logs + if: failure() + working-directory: ./libs/backend-api7/e2e/assets + run: docker compose logs --no-color diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9db00d24..9e87f722 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adc-backend-api7" +version = "0.29.0" +dependencies = [ + "adc-backend-api7", + "adc-backend-core", + "adc-differ", + "adc-sdk", + "async-trait", + "axum", + "reqwest", + "semver", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "adc-backend-apisix" version = "0.29.0" @@ -41,6 +59,7 @@ dependencies = [ name = "adc-cli" version = "0.29.0" dependencies = [ + "adc-backend-api7", "adc-backend-apisix", "adc-backend-core", "adc-differ", @@ -105,9 +124,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -432,6 +451,35 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -530,6 +578,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.11.3" @@ -552,6 +606,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1018,9 +1081,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1072,6 +1135,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "log" version = "0.4.33" @@ -1131,6 +1200,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1213,6 +1288,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1222,6 +1303,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1353,9 +1450,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1376,6 +1473,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "futures-core", "http", "http-body", @@ -1715,6 +1814,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1979,6 +2108,12 @@ 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 = "vt100" version = "0.16.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4e7658d0..ea64b2bc 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/adc-differ", "crates/adc-backend-core", "crates/adc-backend-apisix", + "crates/adc-backend-api7", "crates/adc-sync-bench", "crates/adc-mock-server", "crates/adc-cli", diff --git a/rust/crates/adc-backend-api7/Cargo.toml b/rust/crates/adc-backend-api7/Cargo.toml new file mode 100644 index 00000000..0613153d --- /dev/null +++ b/rust/crates/adc-backend-api7/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "adc-backend-api7" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true + +[features] +# Exposes `adc_backend_api7::tests`, the internal building blocks this +# crate's own `tests/*.rs` integration tests reach into — never meant to be +# enabled by a real consumer. Off by default so it doesn't leak into the +# crate's normal public API surface; the dev-dependency below turns it back +# on for the crate's own test builds. +test-utils = [] + +[dependencies] +adc-sdk = { path = "../adc-sdk" } +adc-backend-core = { path = "../adc-backend-core" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +semver = { workspace = true } +tokio = { workspace = true, features = ["macros", "sync"] } +tracing = { workspace = true } + +[dev-dependencies] +adc-backend-api7 = { path = ".", features = ["test-utils"] } +adc-differ = { path = "../adc-differ" } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies"] } +axum = "0.8" diff --git a/rust/crates/adc-backend-api7/src/backend.rs b/rust/crates/adc-backend-api7/src/backend.rs new file mode 100644 index 00000000..8bc48476 --- /dev/null +++ b/rust/crates/adc-backend-api7/src/backend.rs @@ -0,0 +1,172 @@ +use adc_backend_core::{HttpClient, Method, ResourceFilter}; +use adc_sdk::resources::Configuration; +use adc_sdk::{ + BackendError, BackendMetadata, BackendSyncOptions, BackendSyncResult, BackendValidateResult, + DefaultValue, Event, +}; +use async_trait::async_trait; +use semver::Version; +use tokio::sync::OnceCell; + +use crate::default_value; +use crate::fetcher::Fetcher; +use crate::gateway_group::GatewayGroupResolver; +use crate::operator::Operator; +use crate::typing; +use crate::validator::Validator; + +pub struct Backend { + client: HttpClient, + gateway_group: GatewayGroupResolver, + filter: ResourceFilter, + version: OnceCell, + default_value: OnceCell, +} + +impl Backend { + pub fn new( + client: HttpClient, + gateway_group_name: String, + token: &str, + filter: ResourceFilter, + ) -> Self { + let client = client.with_log_scope(vec!["API7".to_string()]); + let gateway_group = GatewayGroupResolver::new(client.clone(), gateway_group_name, token); + Self { + client, + gateway_group, + filter, + version: OnceCell::new(), + default_value: OnceCell::new(), + } + } + + /// Fetched once and cached for this `Backend`'s lifetime. `"dev"` is a + /// known placeholder for an unreleased build and maps to a version + /// high enough to unlock every version-gated feature; any other value + /// that doesn't coerce to a semver is unexpected and falls back to + /// `0.0.0` instead — deliberately the *conservative* direction, since + /// assuming the oldest possible version is safer than assuming the + /// newest when the actual version genuinely can't be determined. + async fn resolved_version(&self) -> Result { + let version = self + .version + .get_or_try_init(|| async { + let request = self.client.request(Method::GET, "/api/version")?; + let body: typing::ValueResponse = self.client.send_json(request).await?; + Ok::<_, BackendError>(if body.value == "dev" { + Version::new(999, 999, 999) + } else { + coerce_version(&body.value).unwrap_or_else(|| Version::new(0, 0, 0)) + }) + }) + .await?; + Ok(version.clone()) + } +} + +#[async_trait] +impl adc_sdk::Backend for Backend { + fn metadata(&self) -> BackendMetadata { + BackendMetadata { + log_scope: vec!["API7".to_string()], + } + } + + async fn ping(&self) -> Result<(), BackendError> { + let request = self.client.request(Method::GET, "/api/gateway_groups")?; + self.client.send(request).await?; + Ok(()) + } + + async fn version(&self) -> Result { + self.resolved_version().await + } + + /// Fetched once and cached for this `Backend`'s lifetime. See + /// `crate::default_value` for the actual derivation. + async fn default_value(&self) -> Result { + let value = self + .default_value + .get_or_try_init(|| default_value::fetch(&self.client)) + .await?; + Ok(value.clone()) + } + + async fn dump(&self) -> Result { + let version = self.resolved_version().await?; + let gateway_group_id = self.gateway_group.resolve().await?; + Fetcher::new( + self.client.clone(), + version, + gateway_group_id, + self.filter.clone(), + ) + .dump() + .await + } + + async fn sync( + &self, + events: Vec, + opts: BackendSyncOptions, + ) -> Result, BackendError> { + let gateway_group_id = self.gateway_group.resolve().await?; + Operator::new(self.client.clone(), gateway_group_id) + .sync(events, opts) + .await + } + + async fn validate(&self, events: &[Event]) -> Result { + let version = self.resolved_version().await?; + let gateway_group_id = self.gateway_group.resolve().await?; + Validator::new(self.client.clone(), version, gateway_group_id) + .validate(events) + .await + } +} + +/// A lenient version parser for values [`Version::parse`] rejects outright: +/// extracts the first run of digits after any non-digit prefix +/// (`"v3.9.10"` -> `3.9.10`) and tolerates a short `major[.minor[.patch]]` +/// (missing components default to `0`) — the dashboard's `/api/version` +/// endpoint has been observed returning both a `v`-prefixed value and the +/// literal string `"dev"` (handled separately, before this is called). +fn coerce_version(value: &str) -> Option { + let digits_and_dots: String = value + .chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let mut parts = digits_and_dots.splitn(3, '.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let patch = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + Some(Version::new(major, minor, patch)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coerces_a_clean_version_string() { + assert_eq!(coerce_version("3.9.10"), Some(Version::new(3, 9, 10))); + } + + #[test] + fn coerces_a_version_string_with_a_leading_prefix() { + assert_eq!(coerce_version("v3.9.10"), Some(Version::new(3, 9, 10))); + } + + #[test] + fn fills_in_missing_minor_and_patch_components() { + assert_eq!(coerce_version("3"), Some(Version::new(3, 0, 0))); + assert_eq!(coerce_version("3.9"), Some(Version::new(3, 9, 0))); + } + + #[test] + fn returns_none_for_a_value_with_no_digits_at_all() { + assert_eq!(coerce_version("dev"), None); + } +} diff --git a/rust/crates/adc-backend-api7/src/default_value.rs b/rust/crates/adc-backend-api7/src/default_value.rs new file mode 100644 index 00000000..c88ac59f --- /dev/null +++ b/rust/crates/adc-backend-api7/src/default_value.rs @@ -0,0 +1,480 @@ +//! Deriving per-resource-type default values from API7's `/api/schema/core` +//! JSON Schema: every field the schema declares a `default` for gets +//! extracted, then run through the same read-direction transform the +//! fetcher uses on a real fetched resource, so a value matching the +//! backend's own default doesn't show up as a spurious diff against local +//! config that simply omitted it. + +use std::collections::HashMap; + +use adc_backend_core::{HttpClient, Method}; +use adc_sdk::resources::{self as adc}; +use adc_sdk::{BackendError, DefaultValue, ResourceType}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +use crate::typing; + +pub async fn fetch(client: &HttpClient) -> Result { + let request = client.request(Method::GET, "/api/schema/core")?; + let body: typing::ValueResponse> = client.send_json(request).await?; + let mut schema = body.value; + patch_missing_upstream_schema(&mut schema); + + let mut core = HashMap::new(); + for (type_name, schema_entry) in schema { + let Some(resource_type) = resource_type_from_str(&type_name) else { + continue; + }; + let merged = match schema_entry.get("allOf").and_then(Value::as_array) { + Some(all_of) => merge_all_of(all_of.clone()), + None => schema_entry, + }; + let data = extract_object_default(&merged).unwrap_or_else(|| json!({})); + if let Some(transformed) = transform_default(resource_type, data) { + core.insert(resource_type, transformed); + } + } + + Ok(DefaultValue { + core, + plugins: HashMap::new(), + }) +} + +/// Older API7 releases have no top-level `upstream` schema entry at all — +/// only a service's own nested `upstream` property. Synthesizes one from +/// that so upstream defaults still get extracted on those versions too. +fn patch_missing_upstream_schema(schema: &mut Map) { + if schema.contains_key("upstream") { + return; + } + let Some(mut upstream) = schema + .get("service") + .and_then(|s| s.get("properties")) + .and_then(|p| p.get("upstream")) + .cloned() + else { + return; + }; + if let Value::Object(map) = &mut upstream { + map.insert("type".to_string(), Value::String("object".to_string())); + } + schema.insert("upstream".to_string(), upstream); +} + +/// Merges an `allOf` schema composition's `properties` into one object — +/// only `properties` are merged, not other JSON Schema keywords. A +/// composition with no `object`-typed member at all merges to an empty +/// object rather than attempting to merge non-object schemas. +fn merge_all_of(mut items: Vec) -> Value { + if items.len() < 2 { + return items.pop().unwrap_or(Value::Null); + } + if !items + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some("object")) + { + return json!({}); + } + + let mut iter = items.into_iter(); + let Some(Value::Object(mut first)) = iter.next() else { + return json!({}); + }; + if !matches!(first.get("properties"), Some(Value::Object(_))) { + first.insert("properties".to_string(), json!({})); + } + for item in iter { + let Some(Value::Object(props)) = item.get("properties").cloned() else { + continue; + }; + let Some(Value::Object(merged_properties)) = first.get_mut("properties") else { + unreachable!("just ensured `properties` is an object above"); + }; + merged_properties.extend(props); + } + Value::Object(first) +} + +/// Recursively walks a JSON Schema object's `properties`, extracting each +/// field's declared default. Three cases, in order — an array field that +/// *isn't* an array-of-objects falls through to the plain-default case +/// rather than being dropped: +/// 1. An object-typed field recurses into its own nested defaults. +/// 2. An array field whose (non-tuple) item schema is itself object-typed +/// (e.g. `upstream.nodes`) becomes a one-element array of that item's +/// extracted defaults. +/// 3. Everything else (including a plain array with no such item schema) +/// takes the field's own declared `default` verbatim. +/// +/// A field with nothing to contribute (no `default`, and neither case 1 +/// nor 2 applies) is absent from the result, not `null`. +fn extract_object_default(schema: &Value) -> Option { + if schema.get("type").and_then(Value::as_str) != Some("object") { + return None; + } + let properties = schema.get("properties")?.as_object()?; + + let mut defaults = Map::new(); + for (key, field) in properties { + let field_type = field.get("type").and_then(Value::as_str); + let is_object_array_item = field_type == Some("array") + && !matches!(field.get("items"), Some(Value::Array(_))) + && field + .get("items") + .and_then(|items| items.get("type")) + .and_then(Value::as_str) + == Some("object"); + + let value = if field_type == Some("object") { + extract_object_default(field) + } else if is_object_array_item { + field + .get("items") + .and_then(extract_object_default) + .map(|item_default| Value::Array(vec![item_default])) + } else { + field.get("default").cloned() + }; + + if let Some(value) = value { + defaults.insert(key.clone(), value); + } + } + Some(Value::Object(defaults)) +} + +fn resource_type_from_str(value: &str) -> Option { + Some(match value { + "route" => ResourceType::Route, + "service" => ResourceType::Service, + "upstream" => ResourceType::Upstream, + "ssl" => ResourceType::Ssl, + "global_rule" => ResourceType::GlobalRule, + "plugin_config" => ResourceType::PluginConfig, + "plugin_metadata" => ResourceType::PluginMetadata, + "consumer" => ResourceType::Consumer, + "consumer_group" => ResourceType::ConsumerGroup, + "consumer_credential" => ResourceType::ConsumerCredential, + "stream_route" => ResourceType::StreamRoute, + "stream_service" => ResourceType::InternalStreamService, + _ => return None, + }) +} + +/// A schema-derived default `nodes` entry commonly declares only +/// `priority` (`{"priority": 0}`) — there's no sensible universal default +/// for `host`/`port`/`weight`, so the schema never populates them. +/// `adc_sdk::resources::UpstreamNode` requires all three (deliberately, for +/// real fetched/authored data), so deserializing straight into it fails on +/// exactly this partial shape. This lenient stand-in exists only to absorb +/// that one gap: real fetched upstream data always has complete nodes and +/// never needs it. +#[derive(Deserialize)] +struct LenientUpstreamNode { + #[serde(default)] + host: String, + #[serde(default)] + port: u32, + #[serde(default)] + weight: i64, + #[serde(default)] + priority: i64, + #[serde(default)] + metadata: Option>, +} + +impl From for adc::UpstreamNode { + fn from(node: LenientUpstreamNode) -> Self { + adc::UpstreamNode { + host: node.host, + port: node.port, + weight: node.weight, + priority: node.priority, + metadata: node.metadata, + } + } +} + +/// Rewrites `upstream["nodes"]` in place through [`LenientUpstreamNode`], so +/// the strict typed deserialization downstream in [`transform_default`] +/// sees a structurally complete (if zero-valued) node instead of a bare +/// `{"priority": 0}`. A no-op if `upstream` has no `nodes` array, or if a +/// specific entry doesn't even parse as the lenient shape (left as-is, +/// letting the caller's own strict deserialization fail and drop that +/// resource type's default the way it already does for any other +/// unrecoverable shape). +fn repair_upstream_nodes(upstream: &mut Value) { + let Some(nodes) = upstream.get_mut("nodes").and_then(Value::as_array_mut) else { + return; + }; + for node in nodes { + let Ok(lenient) = serde_json::from_value::(node.clone()) else { + continue; + }; + if let Ok(repaired) = serde_json::to_value(adc::UpstreamNode::from(lenient)) { + *node = repaired; + } + } +} + +/// A schema-derived default `client` entry commonly declares only `depth` +/// (`{"depth": 1}`) — there's no sensible universal default for `ca` (a CA +/// certificate). `adc_sdk::resources::SslClient` requires it (deliberately, +/// for real fetched/authored data), so deserializing straight into it fails +/// on exactly this partial shape. This lenient stand-in exists only to +/// absorb that one gap: real fetched SSL data always has a complete +/// `client` block and never needs it. +#[derive(Deserialize)] +struct LenientSslClient { + #[serde(default)] + ca: String, + #[serde(default = "default_client_depth")] + depth: u32, + #[serde(default)] + skip_mtls_uri_regex: Option>, +} + +/// Matches `adc_sdk::resources::SslClient`'s own default for this field. +fn default_client_depth() -> u32 { + 1 +} + +impl From for adc::SslClient { + fn from(client: LenientSslClient) -> Self { + adc::SslClient { + ca: client.ca, + depth: client.depth, + skip_mtls_uri_regex: client.skip_mtls_uri_regex, + } + } +} + +/// Rewrites `ssl["client"]` in place through [`LenientSslClient`], the same +/// way [`repair_upstream_nodes`] does for a partial upstream node. +fn repair_ssl_client(ssl: &mut Value) { + let Some(client) = ssl.get("client") else { + return; + }; + let Ok(lenient) = serde_json::from_value::(client.clone()) else { + return; + }; + if let Ok(repaired) = serde_json::to_value(adc::SslClient::from(lenient)) { + ssl["client"] = repaired; + } +} + +/// Runs an extracted default object through the same read-direction +/// transform the fetcher applies to a real fetched resource, by treating +/// it as a (partial) API7 wire-shape object. +/// +/// A default schema entry rarely populates every field a full resource +/// would (a certificate has no schema-level default, an id is never +/// defaulted, ...), so a conversion failure here contributes no default at +/// all for that resource type rather than failing the whole call — a +/// resource type without a usable default just doesn't show up in the +/// result, and every other resource type's default is unaffected. +fn transform_default(resource_type: ResourceType, mut data: Value) -> Option { + match resource_type { + ResourceType::Route => { + let route: typing::Route = serde_json::from_value(data).ok()?; + serde_json::to_value(adc::Route::try_from(route).ok()?).ok() + } + ResourceType::Service | ResourceType::InternalStreamService => { + if let Some(upstream) = data.get_mut("upstream") { + repair_upstream_nodes(upstream); + } + let service: typing::Service = serde_json::from_value(data).ok()?; + serde_json::to_value(adc::Service::try_from(service).ok()?).ok() + } + ResourceType::Ssl => { + repair_ssl_client(&mut data); + let ssl: typing::Ssl = serde_json::from_value(data).ok()?; + serde_json::to_value(adc::SSL::from(ssl)).ok() + } + ResourceType::Consumer => { + let consumer: typing::Consumer = serde_json::from_value(data).ok()?; + serde_json::to_value(adc::Consumer::from(consumer)).ok() + } + ResourceType::Upstream => { + repair_upstream_nodes(&mut data); + let upstream: typing::Upstream = serde_json::from_value(data).ok()?; + serde_json::to_value(adc::Upstream::from(upstream)).ok() + } + _ => Some(data), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_a_plain_field_default() { + let schema = json!({ "type": "object", "properties": { "retries": { "type": "integer", "default": 3 } } }); + assert_eq!( + extract_object_default(&schema), + Some(json!({ "retries": 3 })) + ); + } + + #[test] + fn omits_a_field_with_no_default_at_all() { + let schema = json!({ "type": "object", "properties": { "name": { "type": "string" } } }); + assert_eq!(extract_object_default(&schema), Some(json!({}))); + } + + #[test] + fn recurses_into_a_nested_object_field() { + let schema = json!({ + "type": "object", + "properties": { + "checks": { "type": "object", "properties": { "active": { "type": "object", "properties": { "timeout": { "type": "integer", "default": 5 } } } } } + } + }); + assert_eq!( + extract_object_default(&schema), + Some(json!({ "checks": { "active": { "timeout": 5 } } })) + ); + } + + #[test] + fn wraps_an_array_of_objects_items_default_in_a_one_element_array() { + let schema = json!({ + "type": "object", + "properties": { + "nodes": { "type": "array", "items": { "type": "object", "properties": { "weight": { "type": "integer", "default": 1 } } } } + } + }); + assert_eq!( + extract_object_default(&schema), + Some(json!({ "nodes": [{ "weight": 1 }] })) + ); + } + + #[test] + fn a_plain_array_field_falls_through_to_its_own_default() { + // Not an array-of-objects (items has no `type: object`), so this + // must fall through to the field's own `default` rather than + // disappearing. + let schema = json!({ + "type": "object", + "properties": { + "http_statuses": { "type": "array", "items": { "type": "integer" }, "default": [200, 302] } + } + }); + assert_eq!( + extract_object_default(&schema), + Some(json!({ "http_statuses": [200, 302] })) + ); + } + + #[test] + fn a_non_object_schema_has_no_extractable_default() { + assert_eq!(extract_object_default(&json!({ "type": "string" })), None); + } + + #[test] + fn merges_all_of_properties_from_every_member_with_later_keys_winning() { + let items = vec![ + json!({ "type": "object", "properties": { "a": { "default": 1 } } }), + json!({ "type": "object", "properties": { "a": { "default": 2 }, "b": { "default": 3 } } }), + ]; + let merged = merge_all_of(items); + assert_eq!(merged["properties"]["a"], json!({ "default": 2 })); + assert_eq!(merged["properties"]["b"], json!({ "default": 3 })); + } + + #[test] + fn merges_all_of_to_an_empty_object_when_no_member_is_object_typed() { + let items = vec![json!({ "type": "string" }), json!({ "type": "integer" })]; + assert_eq!(merge_all_of(items), json!({})); + } + + #[test] + fn patches_a_missing_top_level_upstream_schema_from_the_services_own_property() { + let mut schema = Map::new(); + schema.insert("service".to_string(), json!({ "properties": { "upstream": { "properties": { "retries": { "default": 3 } } } } })); + patch_missing_upstream_schema(&mut schema); + assert_eq!( + schema["upstream"], + json!({ "properties": { "retries": { "default": 3 } }, "type": "object" }) + ); + } + + #[test] + fn leaves_an_existing_top_level_upstream_schema_alone() { + let mut schema = Map::new(); + schema.insert("upstream".to_string(), json!({ "type": "object" })); + schema.insert("service".to_string(), json!({ "properties": { "upstream": { "properties": { "retries": { "default": 99 } } } } })); + patch_missing_upstream_schema(&mut schema); + assert_eq!(schema["upstream"], json!({ "type": "object" })); + } + + #[test] + fn recognizes_every_wire_resource_type_name() { + assert_eq!(resource_type_from_str("route"), Some(ResourceType::Route)); + assert_eq!( + resource_type_from_str("stream_service"), + Some(ResourceType::InternalStreamService) + ); + assert_eq!(resource_type_from_str("not_a_real_type"), None); + } + + #[test] + fn repair_upstream_nodes_fills_in_a_partial_node_with_zero_values() { + let mut upstream = json!({ "nodes": [{ "priority": 0 }] }); + repair_upstream_nodes(&mut upstream); + assert_eq!( + upstream["nodes"], + json!([{ "host": "", "port": 0, "weight": 0, "priority": 0 }]) + ); + } + + #[test] + fn repair_upstream_nodes_is_a_no_op_with_no_nodes_field() { + let mut upstream = json!({ "scheme": "http" }); + repair_upstream_nodes(&mut upstream); + assert_eq!(upstream, json!({ "scheme": "http" })); + } + + #[test] + fn a_service_with_only_a_partial_default_upstream_node_still_produces_a_default() { + let data = json!({ + "strip_path_prefix": true, + "upstream": { "nodes": [{ "priority": 0 }], "scheme": "http" }, + }); + let transformed = transform_default(ResourceType::Service, data).unwrap(); + assert_eq!(transformed["upstream"]["nodes"][0]["host"], ""); + assert_eq!(transformed["strip_path_prefix"], true); + } + + #[test] + fn repair_ssl_client_fills_in_a_partial_client_with_zero_values() { + let mut ssl = json!({ "client": { "depth": 1 } }); + repair_ssl_client(&mut ssl); + assert_eq!(ssl["client"], json!({ "ca": "", "depth": 1 })); + } + + #[test] + fn repair_ssl_client_defaults_a_missing_depth_to_one_not_zero() { + let mut ssl = json!({ "client": {} }); + repair_ssl_client(&mut ssl); + assert_eq!(ssl["client"]["depth"], 1); + } + + #[test] + fn repair_ssl_client_is_a_no_op_with_no_client_field() { + let mut ssl = json!({ "type": "server" }); + repair_ssl_client(&mut ssl); + assert_eq!(ssl, json!({ "type": "server" })); + } + + #[test] + fn an_ssl_with_only_a_partial_default_client_still_produces_a_default() { + let data = json!({ "client": { "depth": 1 } }); + let transformed = transform_default(ResourceType::Ssl, data).unwrap(); + assert_eq!(transformed["client"]["depth"], 1); + } +} diff --git a/rust/crates/adc-backend-api7/src/fetcher.rs b/rust/crates/adc-backend-api7/src/fetcher.rs new file mode 100644 index 00000000..4f46667c --- /dev/null +++ b/rust/crates/adc-backend-api7/src/fetcher.rs @@ -0,0 +1,386 @@ +//! Cascading resource queries against a gateway group's admin API: a +//! service's named upstreams and routes/stream_routes live under their own +//! collection endpoints, so listing services fans out into per-service +//! follow-up requests. Fetches wire-shape structs (`crate::typing`) — +//! converting those into ADC's model is a separate concern, layered on top +//! of this. + +use adc_backend_core::{ + HttpClient, Method, RequestBuilder, ResourceFilter, concurrent_map_until_err, +}; +use adc_sdk::BackendError; +use adc_sdk::ResourceType; +use adc_sdk::resources::{self as adc, Configuration}; +use semver::Version; +use serde::de::DeserializeOwned; + +use crate::typing; + +pub struct Fetcher { + client: HttpClient, + version: Version, + gateway_group_id: Option, + filter: ResourceFilter, +} + +impl Fetcher { + pub fn new( + client: HttpClient, + version: Version, + gateway_group_id: Option, + filter: ResourceFilter, + ) -> Self { + Self { + client, + version, + gateway_group_id, + filter, + } + } + + fn request(&self, method: Method, path: &str) -> Result { + let mut builder = self.client.request(method, path)?; + if let Some(id) = &self.gateway_group_id { + builder = builder.query(&[("gateway_group_id", id)]); + } + Ok(builder) + } + + /// A top-level collection request: unlike [`Fetcher::request`], this + /// also carries `--label-selector`'s query params — the cascading + /// per-service (upstreams/routes) and per-consumer (credentials) + /// follow-up requests below don't call this, matching the dashboard's + /// own admin API, which only accepts a label filter on a top-level + /// collection endpoint. + fn collection_request(&self, path: &str) -> Result { + let builder = self.request(Method::GET, path)?; + Ok(self.filter.attach_label_selector(builder)) + } + + async fn list(&self, path: &str) -> Result, BackendError> { + let builder = self.collection_request(path)?; + let body: typing::ListResponse = self.client.send_json(builder).await?; + Ok(body.list) + } + + pub async fn list_services(&self) -> Result, BackendError> { + if self.filter.is_skip(ResourceType::Service) { + return Ok(Vec::new()); + } + let services: Vec = self.list("/apisix/admin/services").await?; + concurrent_map_until_err(services, None, |service| { + self.with_upstreams_and_routes(service) + }) + .await + } + + /// A service below 3.5.0 has no `/upstreams` sub-collection at all — + /// only its own inline default `upstream` — so the fetch is skipped + /// rather than attempted and failed. Above that, a non-2xx response is + /// tolerated as "no named upstreams" rather than a hard error — + /// deliberately lenient here specifically, unlike the routes/ + /// stream_routes fetch below. + async fn with_upstreams_and_routes( + &self, + mut service: typing::Service, + ) -> Result { + let id = service.id.clone().ok_or_else(|| { + BackendError::Serialization("a fetched service is missing its id".into()) + })?; + + if self.version >= Version::new(3, 5, 0) { + let builder = self.request( + Method::GET, + &format!("/apisix/admin/services/{id}/upstreams"), + )?; + let response = self.client.execute(builder).await?; + if response.status().is_success() { + let body: typing::ListResponse = + response.json().await.map_err(|e| { + BackendError::Serialization(format!( + "decoding response from /apisix/admin/services/{id}/upstreams: {e}" + )) + })?; + service.upstreams = Some(body.list); + } + } + + if service.ty.as_deref() == Some("stream") { + let builder = self + .request(Method::GET, "/apisix/admin/stream_routes")? + .query(&[("service_id", &id)]); + let body: typing::ListResponse = + self.client.send_json(builder).await?; + service.stream_routes = Some(body.list); + } else { + let builder = self + .request(Method::GET, "/apisix/admin/routes")? + .query(&[("service_id", &id)]); + let body: typing::ListResponse = self.client.send_json(builder).await?; + service.routes = Some(body.list); + } + + Ok(service) + } + + pub async fn list_consumers(&self) -> Result, BackendError> { + if self.filter.is_skip(ResourceType::Consumer) { + return Ok(Vec::new()); + } + let consumers: Vec = self.list("/apisix/admin/consumers").await?; + concurrent_map_until_err(consumers, None, |consumer| self.with_credentials(consumer)).await + } + + async fn with_credentials( + &self, + mut consumer: typing::Consumer, + ) -> Result { + let path = format!("/apisix/admin/consumers/{}/credentials", consumer.username); + let builder = self.request(Method::GET, &path)?; + // Purpose isn't obvious from the URL alone in a `--verbose 2` dump + // of N concurrent credential fetches. + let response = self + .client + .execute_described( + builder, + &format!("Get credentials of consumer \"{}\"", consumer.username), + ) + .await?; + let response = HttpClient::require_success(response).await?; + let body: typing::ListResponse = + response.json().await.map_err(|e| { + BackendError::Serialization(format!("decoding response from {path}: {e}")) + })?; + consumer.credentials = Some(body.list); + Ok(consumer) + } + + pub async fn list_ssls(&self) -> Result, BackendError> { + if self.filter.is_skip(ResourceType::Ssl) { + return Ok(Vec::new()); + } + self.list("/apisix/admin/ssls").await + } + + pub async fn list_global_rules(&self) -> Result, BackendError> { + if self.filter.is_skip(ResourceType::GlobalRule) { + return Ok(Vec::new()); + } + self.list("/apisix/admin/global_rules").await + } + + pub async fn list_plugin_metadata(&self) -> Result { + if self.filter.is_skip(ResourceType::PluginMetadata) { + return Ok(typing::PluginMetadata::default()); + } + let builder = self.collection_request("/apisix/admin/plugin_metadata")?; + let body: typing::ValueResponse = + self.client.send_json(builder).await?; + Ok(body.value) + } + + /// Fetches every resource type (concurrently) and converts them into a + /// single ADC `Configuration`. Unlike APISIX, a service's routes/ + /// stream_routes are already nested under it by [`Fetcher::list_services`] + /// before this runs, so there's no separate bucketing/attaching pass — + /// each service just needs its own wire-shape `routes`/`stream_routes` + /// converted and reattached as ADC's `ServiceRoutes`. + pub async fn dump(&self) -> Result { + let (services, consumers, ssls, global_rules, plugin_metadata) = tokio::try_join!( + self.list_services(), + self.list_consumers(), + self.list_ssls(), + self.list_global_rules(), + self.list_plugin_metadata(), + )?; + + let services = services + .into_iter() + .map(|mut service| { + let routes = service.routes.take(); + let stream_routes = service.stream_routes.take(); + let mut service: adc::Service = + service.try_into().map_err(BackendError::Serialization)?; + if let Some(routes) = routes { + let routes = routes + .into_iter() + .map(adc::Route::try_from) + .collect::, _>>() + .map_err(BackendError::Serialization)?; + service.routes = Some(adc::ServiceRoutes::Http { routes }); + } else if let Some(stream_routes) = stream_routes { + let stream_routes = stream_routes + .into_iter() + .map(adc::StreamRoute::from) + .collect(); + service.routes = Some(adc::ServiceRoutes::Stream { stream_routes }); + } + Ok(service) + }) + .collect::, BackendError>>()?; + + let mut merged_global_rules = adc::Plugins::new(); + for rule in global_rules { + merged_global_rules.extend(rule.plugins); + } + + Ok(Configuration { + services: (!services.is_empty()).then_some(services), + ssls: (!ssls.is_empty()).then(|| ssls.into_iter().map(adc::SSL::from).collect()), + consumers: (!consumers.is_empty()) + .then(|| consumers.into_iter().map(Into::into).collect()), + // Not fetched: this crate has no notion of consumer groups yet, + // the same gap noted in `adc_backend_apisix::fetcher`'s own doc + // comment. + consumer_groups: None, + global_rules: (!merged_global_rules.is_empty()).then_some(merged_global_rules), + plugin_metadata: (!plugin_metadata.is_empty()).then_some(plugin_metadata), + }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use adc_backend_core::{HttpClientConfig, TlsConfig}; + + use super::*; + + /// Never resolves a connection, so any request made through it fails + /// immediately — used below to prove `is_skip` short-circuits *before* + /// a request is built, not just that the response gets discarded. + fn unreachable_client() -> HttpClient { + HttpClient::new(HttpClientConfig { + server: "http://0.0.0.0".to_string(), + token: "test-token".to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap() + } + + #[tokio::test] + async fn dump_makes_no_request_at_all_once_every_resource_type_is_excluded() { + let exclude = HashSet::from([ + ResourceType::Service, + ResourceType::Consumer, + ResourceType::Ssl, + ResourceType::GlobalRule, + ResourceType::PluginMetadata, + ]); + let filter = ResourceFilter { + include: HashSet::new(), + exclude, + label_selector: HashMap::new(), + }; + let fetcher = Fetcher::new( + unreachable_client(), + Version::new(999, 999, 999), + Some("test".to_string()), + filter, + ); + + let configuration = fetcher.dump().await.unwrap(); + assert_eq!( + configuration, + Configuration { + services: None, + ssls: None, + consumers: None, + consumer_groups: None, + global_rules: None, + plugin_metadata: None, + } + ); + } + + /// A local server that records every path it's asked for and answers + /// generically (an empty list/value satisfies every resource type's + /// response shape without needing per-type fixtures) — used to prove a + /// specific endpoint was *never requested*, not just that its response + /// was discarded. + async fn spawn_recording_server() -> (String, std::sync::Arc>>) { + let seen = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())); + let seen_for_handler = seen.clone(); + let router = axum::Router::new().fallback(axum::routing::any( + move |request: axum::extract::Request| { + let seen = seen_for_handler.clone(); + async move { + seen.lock().await.push(request.uri().path().to_string()); + axum::Json(serde_json::json!({ "list": [], "value": {} })) + } + }, + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + (format!("http://{addr}"), seen) + } + + #[tokio::test] + async fn excluding_a_resource_type_means_its_endpoint_is_never_requested() { + let (server, seen) = spawn_recording_server().await; + let client = HttpClient::new(HttpClientConfig { + server, + token: "test-token".to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap(); + let filter = ResourceFilter { + include: HashSet::new(), + exclude: HashSet::from([ResourceType::Service]), + label_selector: HashMap::new(), + }; + let fetcher = Fetcher::new( + client, + Version::new(999, 999, 999), + Some("test".to_string()), + filter, + ); + + fetcher.dump().await.unwrap(); + + let seen = seen.lock().await; + assert!( + !seen.iter().any(|path| path == "/apisix/admin/services"), + "{seen:?}" + ); + assert!( + seen.iter().any(|path| path == "/apisix/admin/consumers"), + "{seen:?}" + ); + } + + #[test] + fn collection_request_carries_the_label_selector_but_a_nested_request_does_not() { + let filter = ResourceFilter { + include: HashSet::new(), + exclude: HashSet::new(), + label_selector: HashMap::from([("env".to_string(), "prod".to_string())]), + }; + let fetcher = Fetcher::new( + unreachable_client(), + Version::new(999, 999, 999), + None, + filter, + ); + + let collection = fetcher + .collection_request("/api/services") + .unwrap() + .build() + .unwrap(); + assert_eq!(collection.url().query(), Some("labels%5Benv%5D=prod")); + + let nested = fetcher + .request(Method::GET, "/api/services/svc/routes") + .unwrap() + .build() + .unwrap(); + assert_eq!(nested.url().query(), None); + } +} diff --git a/rust/crates/adc-backend-api7/src/gateway_group.rs b/rust/crates/adc-backend-api7/src/gateway_group.rs new file mode 100644 index 00000000..397dd2d5 --- /dev/null +++ b/rust/crates/adc-backend-api7/src/gateway_group.rs @@ -0,0 +1,107 @@ +//! Users configure a gateway group by its display name, but every +//! gateway-group-scoped admin API call carries a `gateway_group_id` query +//! param instead. [`GatewayGroupResolver`] is the one place that name gets +//! turned into the id, resolved lazily and cached for the resolver's +//! lifetime (mirroring how `adc-backend-apisix`'s `Backend` caches its +//! resolved server version). + +use adc_backend_core::{HttpClient, Method}; +use adc_sdk::BackendError; +use serde::Deserialize; +use tokio::sync::OnceCell; + +/// An `a7adm-` prefixed token is an admin token scoped across every +/// gateway group rather than one — requests made with it omit +/// `gateway_group_id` entirely instead of resolving one. +const ADMIN_TOKEN_PREFIX: &str = "a7adm-"; + +#[derive(Deserialize)] +struct GatewayGroupListResponse { + list: Vec, +} + +#[derive(Deserialize)] +struct GatewayGroupSummary { + id: String, + name: String, +} + +pub struct GatewayGroupResolver { + client: HttpClient, + name: String, + is_admin_token: bool, + id: OnceCell>, +} + +impl GatewayGroupResolver { + pub fn new(client: HttpClient, name: String, token: &str) -> Self { + Self { + client, + name, + is_admin_token: token.starts_with(ADMIN_TOKEN_PREFIX), + id: OnceCell::new(), + } + } + + /// Resolves to `None` for an admin token; otherwise looks up the + /// group by name and errors if none matches. + pub async fn resolve(&self) -> Result, BackendError> { + let id = self + .id + .get_or_try_init(|| async { + if self.is_admin_token { + return Ok::<_, BackendError>(None); + } + + let request = self + .client + .request(Method::GET, "/api/gateway_groups")? + .query(&[("search", self.name.as_str()), ("name", self.name.as_str())]); + let response: GatewayGroupListResponse = self.client.send_json(request).await?; + + find_exact_match(response.list, &self.name) + .ok_or_else(|| { + BackendError::Other( + format!("Gateway group \"{}\" does not exist", self.name).into(), + ) + }) + .map(|group| Some(group.id)) + }) + .await?; + Ok(id.clone()) + } +} + +/// `search`/`name` on `/api/gateway_groups` is a substring/fuzzy filter, +/// not an exact-match lookup — a request for `"prod"` can come back with +/// `"prod"`, `"prod-2"`, and `"non-prod"` all in the same `list`. Picks the +/// one entry whose `name` matches exactly, rather than assuming the first +/// result returned is the one that was asked for. +fn find_exact_match(groups: Vec, name: &str) -> Option { + groups.into_iter().find(|group| group.name == name) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn group(id: &str, name: &str) -> GatewayGroupSummary { + GatewayGroupSummary { + id: id.to_string(), + name: name.to_string(), + } + } + + #[test] + fn picks_the_entry_whose_name_matches_exactly() { + let groups = vec![group("id-1", "prod-2"), group("id-2", "prod"), group("id-3", "non-prod")]; + let matched = find_exact_match(groups, "prod").unwrap(); + assert_eq!(matched.id, "id-2"); + } + + #[test] + fn no_exact_match_among_fuzzy_results_returns_none() { + let groups = vec![group("id-1", "prod-2"), group("id-3", "non-prod")]; + assert!(find_exact_match(groups, "prod").is_none()); + } +} diff --git a/rust/crates/adc-backend-api7/src/lib.rs b/rust/crates/adc-backend-api7/src/lib.rs new file mode 100644 index 00000000..d74d6b0e --- /dev/null +++ b/rust/crates/adc-backend-api7/src/lib.rs @@ -0,0 +1,27 @@ +mod backend; +mod default_value; +mod fetcher; +mod gateway_group; +mod operator; +mod transformer; +mod typing; +mod utils; +mod validator; + +pub use backend::Backend; + +#[cfg(feature = "test-utils")] +#[doc(hidden)] +pub mod tests { + pub use crate::fetcher::Fetcher; + pub use crate::gateway_group::GatewayGroupResolver; + pub use crate::operator::Operator; + pub use crate::validator::Validator; + + pub mod transformer { + pub use crate::transformer::*; + } + pub mod typing { + pub use crate::typing::*; + } +} diff --git a/rust/crates/adc-backend-api7/src/operator.rs b/rust/crates/adc-backend-api7/src/operator.rs new file mode 100644 index 00000000..fdadb2cf --- /dev/null +++ b/rust/crates/adc-backend-api7/src/operator.rs @@ -0,0 +1,510 @@ +//! Applying a differ's `Event`s to a live API7 Enterprise gateway group: +//! `sync`. +//! +//! Unlike APISIX, a service's default upstream is embedded directly in its +//! own body (see `transformer::transform_service`'s doc comment) — a +//! `SERVICE` event is always exactly one request, not up to two. Named +//! (non-default) upstreams for canary release still address their own +//! nested collection (`/apisix/admin/services/{parent}/upstreams/{id}`). +//! +//! No retry wrapping here — unlike `adc_backend_apisix::Operator`, a +//! failed request is never retried. +//! +//! Before applying, events go through preprocessing: a route/stream_route/ +//! upstream/credential `DELETE` whose parent is *also* being deleted in +//! this same batch is dropped (the parent's delete cascades it already), +//! then events are grouped by `(resource_type, event_type)`, preserving +//! relative order within and across groups — events within one group run +//! concurrently (bounded by `BackendSyncOptions::concurrent`), groups run +//! sequentially. + +use std::collections::HashSet; + +use adc_backend_core::{ + HttpClient, Method, RequestBuilder, concurrent_map, concurrent_map_until_err, + encode_path_segment, +}; +use adc_sdk::resources::{self as adc}; +use adc_sdk::{ + BackendError, BackendSyncOptions, BackendSyncResult, Event, EventType, ResourceType, + SYNC_EVENT_SPAN_NAME, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::transformer; +use crate::typing; +use crate::utils::resource_type_to_api_name; + +pub struct Operator { + client: HttpClient, + gateway_group_id: Option, +} + +impl Operator { + pub fn new(client: HttpClient, gateway_group_id: Option) -> Self { + Self { + client, + gateway_group_id, + } + } + + /// An actual `operate` (HTTP) failure aborts the whole call as an + /// `Err` when `exit_on_failure` is set (the default), discarding + /// results accumulated so far. Events already dispatched within that + /// group still run to completion (no cheap way to cancel an in-flight + /// request), but anything still queued behind the group's concurrency + /// limit is dropped and never dispatched. + pub async fn sync( + &self, + events: Vec, + opts: BackendSyncOptions, + ) -> Result, BackendError> { + let exit_on_failure = opts.exit_on_failure.unwrap_or(true); + + let mut results = Vec::new(); + for group in group_events(preprocess_events(events)) { + let concurrent = group_concurrency(&group, opts.concurrent); + if exit_on_failure { + let group_results = + concurrent_map_until_err(group, concurrent, |event| self.apply(event)) + .await + .map_err(|(_, error)| error)?; + results.extend(group_results); + } else { + let group_results = + concurrent_map(group, concurrent, |event| self.apply(event)).await; + for outcome in group_results { + match outcome { + Ok(result) => results.push(result), + Err((event, error)) => results.push(BackendSyncResult { + success: false, + event, + error: Some(error), + server: None, + }), + } + } + } + } + Ok(results) + } + + /// Wrapped in a real `SYNC_EVENT_SPAN_NAME` span for the event's whole + /// lifetime, not a synthetic start/finish pair — `success`/`error` are + /// recorded just before it closes. + #[tracing::instrument( + name = SYNC_EVENT_SPAN_NAME, + skip_all, + fields( + resource_type = %event.resource_type.as_str(), + resource_name = %event.resource_name, + event_type = ?event.event_type(), + success = tracing::field::Empty, + error = tracing::field::Empty, + ) + )] + async fn apply(&self, event: Event) -> Result { + let outcome = self.operate(&event).await; + + let span = tracing::Span::current(); + match &outcome { + Ok(()) => span.record("success", true), + Err(error) => { + span.record("success", false); + span.record("error", error.to_string()) + } + }; + + match outcome { + Ok(()) => Ok(BackendSyncResult { + success: true, + event, + error: None, + server: None, + }), + Err(error) => Err((event, error)), + } + } + + async fn operate(&self, event: &Event) -> Result<(), BackendError> { + let path = build_path(event)?; + let is_delete = event.event_type() == EventType::Delete; + + let mut builder = self.request( + if is_delete { + Method::DELETE + } else { + Method::PUT + }, + &path, + )?; + if !is_delete { + builder = builder.json(&request_body(event)?); + } + self.client.send(builder).await.map(|_| ()) + } + + fn request(&self, method: Method, path: &str) -> Result { + let mut builder = self.client.request(method, path)?; + if let Some(id) = &self.gateway_group_id { + builder = builder.query(&[("gateway_group_id", id)]); + } + Ok(builder) + } +} + +/// Buckets events by `(resource_type, event_type)`, preserving each +/// bucket's internal relative order and ordering buckets themselves by +/// first appearance — grouping, not just chunking consecutive runs, so an +/// event can join an earlier bucket even if later events of a different +/// type came between it and its match. +fn group_events(events: Vec) -> Vec> { + let mut groups: Vec<(ResourceType, EventType, Vec)> = Vec::new(); + 'events: for event in events { + let key = (event.resource_type, event.event_type()); + for group in &mut groups { + if (group.0, group.1) == key { + group.2.push(event); + continue 'events; + } + } + groups.push((key.0, key.1, vec![event])); + } + groups.into_iter().map(|(_, _, events)| events).collect() +} + +/// The concurrency to run one group's events at. Normally just passes +/// `requested` (`opts.concurrent`) straight through, except for +/// `GlobalRule`: some dashboard versions store a gateway group's entire +/// global-rules collection as one shared etcd document, read-modify-written +/// on every PUT — two concurrent global-rule writes race on that document's +/// revision and one of them gets rejected outright. Capping this one +/// resource type to 1 makes syncing multiple global rules in the same +/// batch reliable regardless of dashboard version, at the cost of losing +/// any parallelism between them (a batch is typically small enough that +/// this doesn't matter in practice). +fn group_concurrency(group: &[Event], requested: Option) -> Option { + if group.first().is_some_and(|event| event.resource_type == ResourceType::GlobalRule) { + Some(1) + } else { + requested + } +} + +/// Drops a route/stream_route/upstream `DELETE` whose parent service is +/// also being deleted in this same batch, and a credential `DELETE` whose +/// parent consumer is — the parent's own delete cascades it, so applying +/// it separately would either be redundant or race the parent's delete. +fn preprocess_events(events: Vec) -> Vec { + let deleted_service_ids: HashSet = events + .iter() + .filter(|e| e.resource_type == ResourceType::Service && e.event_type() == EventType::Delete) + .map(|e| e.resource_id.clone()) + .collect(); + let deleted_consumer_ids: HashSet = events + .iter() + .filter(|e| { + e.resource_type == ResourceType::Consumer && e.event_type() == EventType::Delete + }) + .map(|e| e.resource_id.clone()) + .collect(); + + events + .into_iter() + .filter(|event| { + let is_delete = event.event_type() == EventType::Delete; + let cascaded_by_service = is_delete + && matches!( + event.resource_type, + ResourceType::Route | ResourceType::StreamRoute | ResourceType::Upstream + ) + && event + .parent_id + .as_deref() + .is_some_and(|id| deleted_service_ids.contains(id)); + let cascaded_by_consumer = is_delete + && event.resource_type == ResourceType::ConsumerCredential + && event + .parent_id + .as_deref() + .is_some_and(|id| deleted_consumer_ids.contains(id)); + !cascaded_by_service && !cascaded_by_consumer + }) + .collect() +} + +fn missing_parent(event: &Event) -> BackendError { + BackendError::Other( + format!( + "{:?} event for resource {:?} is missing a parent_id", + event.resource_type, event.resource_id + ) + .into(), + ) +} + +fn deserialize_event_value(value: &Value) -> Result { + serde_json::from_value(value.clone()) + .map_err(|e| BackendError::Serialization(format!("decoding event payload: {e}"))) +} + +fn to_request_body(value: T) -> Result { + serde_json::to_value(value) + .map_err(|e| BackendError::Serialization(format!("encoding request body: {e}"))) +} + +fn build_path(event: &Event) -> Result { + let resource_id = encode_path_segment(&event.resource_id)?; + match event.resource_type { + ResourceType::ConsumerCredential => { + let parent_id = encode_path_segment( + event + .parent_id + .as_deref() + .ok_or_else(|| missing_parent(event))?, + )?; + Ok(format!( + "/apisix/admin/consumers/{parent_id}/credentials/{resource_id}" + )) + } + ResourceType::Upstream => { + let parent_id = encode_path_segment( + event + .parent_id + .as_deref() + .ok_or_else(|| missing_parent(event))?, + )?; + Ok(format!( + "/apisix/admin/services/{parent_id}/upstreams/{resource_id}" + )) + } + _ => { + let api_name = resource_type_to_api_name(event.resource_type).ok_or_else(|| { + BackendError::Unsupported(format!( + "{:?} has no top-level admin API collection", + event.resource_type + )) + })?; + Ok(format!("/apisix/admin/{api_name}/{resource_id}")) + } + } +} + +fn request_body(event: &Event) -> Result { + let new_value = event + .kind + .new_value() + .ok_or_else(|| BackendError::Other("create/update event is missing new_value".into()))?; + + match event.resource_type { + ResourceType::Consumer => { + to_request_body(typing::Consumer::from(deserialize_event_value::< + adc::Consumer, + >(new_value)?)) + } + ResourceType::GlobalRule => { + Ok(serde_json::json!({ "plugins": { event.resource_id.clone(): new_value.clone() } })) + } + ResourceType::PluginMetadata => Ok(new_value.clone()), + ResourceType::Service => { + let mut service: adc::Service = deserialize_event_value(new_value)?; + service.id = Some(event.resource_id.clone()); + to_request_body(transformer::transform_service(service)) + } + ResourceType::Route => { + let mut route: adc::Route = deserialize_event_value(new_value)?; + route.id = Some(event.resource_id.clone()); + let parent_id = event + .parent_id + .clone() + .ok_or_else(|| missing_parent(event))?; + to_request_body(transformer::transform_route(route, parent_id)) + } + ResourceType::StreamRoute => { + let mut route: adc::StreamRoute = deserialize_event_value(new_value)?; + route.id = Some(event.resource_id.clone()); + let parent_id = event + .parent_id + .clone() + .ok_or_else(|| missing_parent(event))?; + to_request_body(transformer::transform_stream_route(route, parent_id)) + } + ResourceType::Ssl => { + let mut ssl: adc::SSL = deserialize_event_value(new_value)?; + ssl.id = Some(event.resource_id.clone()); + to_request_body(typing::Ssl::try_from(ssl).map_err(BackendError::Serialization)?) + } + ResourceType::ConsumerCredential => { + let mut credential: adc::ConsumerCredential = deserialize_event_value(new_value)?; + credential.id = Some(event.resource_id.clone()); + to_request_body(typing::ConsumerCredential::from(credential)) + } + ResourceType::Upstream => { + let upstream: adc::Upstream = deserialize_event_value(new_value)?; + to_request_body(typing::Upstream::from(upstream)) + } + ResourceType::ConsumerGroup + | ResourceType::PluginConfig + | ResourceType::InternalStreamService => Err(BackendError::Unsupported(format!( + "{:?} is not directly syncable by the api7 backend", + event.resource_type + ))), + } +} + +#[cfg(test)] +mod tests { + use adc_sdk::EventKind; + use serde_json::json; + + use super::*; + + fn event(rt: ResourceType, kind: EventKind, id: &str) -> Event { + Event::new(rt, kind, id, id) + } + + fn create(rt: ResourceType, id: &str) -> Event { + event( + rt, + EventKind::Create { + new_value: json!({}), + }, + id, + ) + } + + fn delete(rt: ResourceType, id: &str) -> Event { + event( + rt, + EventKind::Delete { + old_value: json!({}), + }, + id, + ) + } + + #[test] + fn groups_by_resource_and_event_type_preserving_first_seen_order() { + let route1 = create(ResourceType::Route, "r1"); + let consumer = create(ResourceType::Consumer, "c1"); + let route2 = create(ResourceType::Route, "r2"); + let ssl_delete = delete(ResourceType::Ssl, "s1"); + + let groups = group_events(vec![route1, consumer, route2, ssl_delete]); + + assert_eq!(groups.len(), 3); + assert_eq!(groups[0].len(), 2); + assert_eq!(groups[0][0].resource_id, "r1"); + assert_eq!(groups[0][1].resource_id, "r2"); + assert_eq!(groups[1].len(), 1); + assert_eq!(groups[1][0].resource_type, ResourceType::Consumer); + assert_eq!(groups[2].len(), 1); + assert_eq!(groups[2][0].event_type(), EventType::Delete); + } + + #[test] + fn global_rule_groups_are_forced_to_a_concurrency_of_one() { + let group = vec![create(ResourceType::GlobalRule, "g1"), create(ResourceType::GlobalRule, "g2")]; + assert_eq!(group_concurrency(&group, None), Some(1)); + assert_eq!(group_concurrency(&group, Some(10)), Some(1)); + } + + #[test] + fn other_resource_types_pass_the_requested_concurrency_through_unchanged() { + let group = vec![create(ResourceType::Route, "r1"), create(ResourceType::Route, "r2")]; + assert_eq!(group_concurrency(&group, None), None); + assert_eq!(group_concurrency(&group, Some(10)), Some(10)); + } + + #[test] + fn an_empty_group_passes_the_requested_concurrency_through_unchanged() { + assert_eq!(group_concurrency(&[], Some(5)), Some(5)); + } + + #[test] + fn a_route_delete_whose_parent_service_is_also_deleted_is_dropped() { + let mut route_delete = delete(ResourceType::Route, "r1"); + route_delete.parent_id = Some("svc1".to_string()); + let service_delete = delete(ResourceType::Service, "svc1"); + + let remaining = preprocess_events(vec![route_delete, service_delete.clone()]); + + assert_eq!(remaining, vec![service_delete]); + } + + #[test] + fn a_credential_delete_whose_parent_consumer_is_also_deleted_is_dropped() { + let mut credential_delete = delete(ResourceType::ConsumerCredential, "cred1"); + credential_delete.parent_id = Some("user1".to_string()); + let consumer_delete = delete(ResourceType::Consumer, "user1"); + + let remaining = preprocess_events(vec![credential_delete, consumer_delete.clone()]); + + assert_eq!(remaining, vec![consumer_delete]); + } + + #[test] + fn a_route_delete_whose_parent_service_is_not_deleted_is_kept() { + let mut route_delete = delete(ResourceType::Route, "r1"); + route_delete.parent_id = Some("svc1".to_string()); + + let remaining = preprocess_events(vec![route_delete.clone()]); + + assert_eq!(remaining, vec![route_delete]); + } + + #[test] + fn a_non_delete_event_for_a_deleted_services_route_is_kept() { + // Only a DELETE cascades; e.g. an UPDATE for a route belonging to + // a service that's simultaneously being deleted is unusual but not + // this preprocessing step's concern. + let mut route_update = event( + ResourceType::Route, + EventKind::Update { + old_value: json!({}), + new_value: json!({}), + diff: None, + }, + "r1", + ); + route_update.parent_id = Some("svc1".to_string()); + let service_delete = delete(ResourceType::Service, "svc1"); + + let remaining = preprocess_events(vec![route_update.clone(), service_delete.clone()]); + + assert_eq!(remaining, vec![route_update, service_delete]); + } + + #[test] + fn resource_id_containing_a_path_separator_is_percent_encoded_not_split() { + let mut route_event = create(ResourceType::Route, "a/../b"); + route_event.parent_id = Some("svc1".to_string()); + + let path = build_path(&route_event).unwrap(); + + assert!(path.starts_with("/apisix/admin/routes/"), "{path}"); + assert!(!path.contains("/../"), "{path}"); + } + + #[test] + fn a_consumer_credential_path_nests_under_its_parent_consumer() { + let mut credential_event = create(ResourceType::ConsumerCredential, "cred1"); + credential_event.parent_id = Some("user1".to_string()); + + let path = build_path(&credential_event).unwrap(); + + assert_eq!(path, "/apisix/admin/consumers/user1/credentials/cred1"); + } + + #[test] + fn a_named_upstream_path_nests_under_its_parent_service() { + let mut upstream_event = create(ResourceType::Upstream, "up1"); + upstream_event.parent_id = Some("svc1".to_string()); + + let path = build_path(&upstream_event).unwrap(); + + assert_eq!(path, "/apisix/admin/services/svc1/upstreams/up1"); + } +} diff --git a/rust/crates/adc-backend-api7/src/transformer.rs b/rust/crates/adc-backend-api7/src/transformer.rs new file mode 100644 index 00000000..16b3e7de --- /dev/null +++ b/rust/crates/adc-backend-api7/src/transformer.rs @@ -0,0 +1,515 @@ +//! Converting between API7's wire shapes (`crate::typing`) and ADC's +//! resource model (`adc_sdk::resources`). +//! +//! Read direction (API7 -> ADC, used by the fetcher): `TryFrom`/`From` on +//! the ADC type, so a caller can write either `adc::Route::try_from(route)` +//! or `route.try_into()`. Write direction (ADC -> API7, used by the +//! operator/validator): plain `From` on the wire type, since nothing here +//! can fail the way parsing a live server's response can — except `SSL`, +//! whose `certificates` list can be empty (nothing stops a locally-authored +//! config from omitting it, and the read direction deliberately tolerates +//! that on the way *in*), but there's no wire representation of "no +//! certificate at all" to send back *out*: the server would just reject an +//! empty `cert`/`key` string outright. `TryFrom` rejects that case here, +//! before a doomed request is ever built. +//! `Service`/`Route`/`StreamRoute`'s write-direction id fields +//! (`service_id`/`route_id`/`stream_route_id`) and the two conversions that +//! need more than the resource itself (a route/stream route needs its +//! parent service's id) are free functions instead of `From` impls, since +//! `From` only takes one argument. +//! +//! [`transform_service`] always converts a service's embedded default +//! upstream through [`typing::Upstream`]'s own `From` impl, exactly like a +//! standalone named upstream — never assembled directly from the ADC +//! shape's own fields, since those (`description`, `type` for the +//! balancer) don't line up with what the wire format expects (`desc`, +//! `type`), and passing them through unrenamed would silently drop the +//! description. + +use std::collections::HashMap; + +use adc_sdk::resources::{self as adc, LabelValue}; +use serde_json::Value; + +use crate::typing; + +fn parse_http_method(method: String) -> Result { + serde_json::from_value(Value::String(method.clone())) + .map_err(|_| format!("unrecognized HTTP method {method:?}")) +} + +fn http_method_to_string(method: adc::HttpMethod) -> String { + match serde_json::to_value(method).expect("HttpMethod serialization is infallible") { + Value::String(s) => s, + other => unreachable!("HttpMethod must serialize to a JSON string, got {other:?}"), + } +} + +// --- Labels: every API7 resource's wire `labels` is a plain string map, +// unlike ADC's own string-or-array `Labels` — a multi-value label +// round-trips through a JSON-array-shaped string rather than a nested JSON +// array. + +/// A value that's valid JSON *and* decodes to a string array round-trips +/// back to `LabelValue::Multiple`; anything else (not JSON, not an array, +/// an array with non-string elements) stays a plain string. +fn transform_labels_from_wire(labels: Option>) -> Option { + labels.map(|labels| { + labels + .into_iter() + .map(|(key, value)| { + let label_value = serde_json::from_str::>(&value) + .map(LabelValue::Multiple) + .unwrap_or(LabelValue::Single(value)); + (key, label_value) + }) + .collect() + }) +} + +fn stringify_label_value(value: LabelValue) -> String { + match value { + LabelValue::Single(s) => s, + LabelValue::Multiple(items) => serde_json::to_string(&items).unwrap_or_default(), + } +} + +fn transform_labels_to_wire(labels: Option) -> Option> { + labels.map(|labels| { + labels + .into_iter() + .map(|(key, value)| (key, stringify_label_value(value))) + .collect() + }) +} + +// --- Read direction: API7 -> ADC --- + +impl TryFrom for adc::Route { + type Error = String; + + fn try_from(route: typing::Route) -> Result { + let methods = route + .methods + .map(|methods| { + methods + .into_iter() + .map(parse_http_method) + .collect::, _>>() + }) + .transpose()?; + let id = route.id; + + Ok(adc::Route { + name: route.name.unwrap_or_else(|| id.clone().unwrap_or_default()), + id, + description: route.desc, + labels: transform_labels_from_wire(route.labels), + + hosts: None, + uris: route.paths.unwrap_or_default(), + priority: route.priority, + timeout: route.timeout, + vars: route.vars, + methods, + enable_websocket: route.enable_websocket, + remote_addrs: None, + plugins: route.plugins, + filter_func: None, + }) + } +} + +impl From for adc::Upstream { + fn from(upstream: typing::Upstream) -> Self { + adc::Upstream { + id: upstream.id, + name: upstream.name, + description: upstream.desc, + labels: transform_labels_from_wire(upstream.labels), + + r#type: upstream.ty.unwrap_or_default(), + hash_on: upstream.hash_on, + key: upstream.key, + checks: upstream.checks, + nodes: upstream.nodes, + scheme: upstream.scheme.unwrap_or_default(), + retries: upstream.retries, + retry_timeout: upstream.retry_timeout, + timeout: upstream.timeout, + tls: upstream.tls, + keepalive_pool: upstream.keepalive_pool, + pass_host: upstream.pass_host.unwrap_or_default(), + upstream_host: upstream.upstream_host, + + service_name: upstream.service_name, + discovery_type: upstream.discovery_type, + discovery_args: upstream.discovery_args.and_then(|v| v.as_object().cloned()), + } + } +} + +impl TryFrom for adc::Service { + type Error = String; + + fn try_from(service: typing::Service) -> Result { + let id = service.id; + let upstream = service.upstream.map(adc::Upstream::from); + let upstreams = service.upstreams.map(|list| { + list.into_iter() + // Ignore the default upstream if the named-upstreams + // collection happens to echo it back too. + .filter(|u| u.id != id) + .map(adc::Upstream::from) + .collect() + }); + + Ok(adc::Service { + name: service + .name + .unwrap_or_else(|| id.clone().unwrap_or_default()), + id, + description: service.desc, + labels: transform_labels_from_wire(service.labels), + + upstream, + upstreams, + plugins: service.plugins, + // Not an API7 wire concept on read: these only exist on + // ADC-authored config. + path_prefix: service.path_prefix, + strip_path_prefix: service.strip_path_prefix, + hosts: service.hosts, + + // Attached later, once route/stream_route fetch results are + // available to nest under their parent service. + routes: None, + }) + } +} + +impl From for adc::SSL { + fn from(ssl: typing::Ssl) -> Self { + // Only the first certificate/key pair is ever read back, and the + // key is always empty — a gateway server never echoes a private + // key on read, and additional entries in `certs`/`keys` aren't + // recovered here either (unlike `adc_backend_apisix`'s own SSL + // read conversion, which does merge them back in). A missing + // certificate isn't an error — it just means no certificate to + // report, so `certificates` comes back empty instead. + let certificates = ssl + .cert + .map(|certificate| { + vec![adc::SSLCertificate { + certificate, + key: String::new(), + }] + }) + .unwrap_or_default(); + + adc::SSL { + id: ssl.id, + labels: transform_labels_from_wire(ssl.labels), + + r#type: ssl.ty.unwrap_or_default(), + snis: ssl.snis.unwrap_or_default(), + certificates, + client: ssl.client, + ssl_protocols: None, + } + } +} + +/// A credential's `type`/`config` come from its single plugin entry (API7 +/// models a credential as a one-plugin `Plugins` map, same as APISIX). +/// Unlike `adc_backend_apisix`'s transformer, there's no allow-list of +/// recognized credential plugin names here — whatever single plugin entry +/// is present is accepted as-is. +impl TryFrom for adc::ConsumerCredential { + type Error = String; + + fn try_from(credential: typing::ConsumerCredential) -> Result { + let plugins = credential + .plugins + .filter(|p| !p.is_empty()) + .ok_or("credential has no plugin configured")?; + let (plugin_name, config) = plugins.into_iter().next().expect("checked non-empty above"); + let Value::Object(config) = config else { + return Err(format!( + "credential plugin {plugin_name:?} config is not an object" + )); + }; + + Ok(adc::ConsumerCredential { + id: credential.id, + name: credential.name.unwrap_or_default(), + description: credential.desc, + labels: transform_labels_from_wire(credential.labels), + r#type: plugin_name, + config, + }) + } +} + +impl From for adc::Consumer { + fn from(consumer: typing::Consumer) -> Self { + // Present-but-empty stays present-but-empty; absent stays absent — + // matches `adc_backend_apisix::transformer`'s reasoning. + let credentials = consumer.credentials.map(|creds| { + creds + .into_iter() + .filter_map(|c| adc::ConsumerCredential::try_from(c).ok()) + .collect() + }); + + adc::Consumer { + username: consumer.username, + description: consumer.desc, + labels: transform_labels_from_wire(consumer.labels), + plugins: consumer.plugins, + credentials, + } + } +} + +impl From for adc::StreamRoute { + fn from(route: typing::StreamRoute) -> Self { + let id = route.id; + adc::StreamRoute { + name: route.name.unwrap_or_else(|| id.clone().unwrap_or_default()), + id, + description: route.desc, + labels: transform_labels_from_wire(route.labels), + plugins: route.plugins, + remote_addr: route.remote_addr, + server_addr: route.server_addr, + server_port: route.server_port.map(|port| port as u32), + sni: None, + } + } +} + +// --- Write direction: ADC -> API7 --- + +pub fn transform_route(route: adc::Route, parent_id: String) -> typing::Route { + typing::Route { + id: None, + route_id: route.id, + name: Some(route.name), + desc: route.description, + labels: transform_labels_to_wire(route.labels), + service_id: Some(parent_id), + + plugins: route.plugins, + + paths: Some(route.uris), + methods: route + .methods + .map(|methods| methods.into_iter().map(http_method_to_string).collect()), + vars: route.vars, + + enable_websocket: route.enable_websocket, + priority: route.priority, + timeout: route.timeout, + } +} + +pub fn transform_stream_route(route: adc::StreamRoute, parent_id: String) -> typing::StreamRoute { + typing::StreamRoute { + id: None, + stream_route_id: route.id, + name: Some(route.name), + desc: route.description, + labels: transform_labels_to_wire(route.labels), + service_id: Some(parent_id), + + plugins: route.plugins, + + server_addr: route.server_addr, + server_port: route.server_port.map(i64::from), + remote_addr: route.remote_addr, + } +} + +impl From for typing::Upstream { + fn from(upstream: adc::Upstream) -> Self { + typing::Upstream { + id: upstream.id, + name: upstream.name, + desc: upstream.description, + labels: transform_labels_to_wire(upstream.labels), + + nodes: upstream.nodes, + scheme: Some(upstream.scheme), + ty: Some(upstream.r#type), + hash_on: upstream.hash_on, + key: upstream.key, + checks: upstream.checks, + + discovery_type: upstream.discovery_type, + service_name: upstream.service_name, + discovery_args: upstream.discovery_args.map(Value::Object), + + pass_host: Some(upstream.pass_host), + upstream_host: upstream.upstream_host, + retries: upstream.retries, + retry_timeout: upstream.retry_timeout, + timeout: upstream.timeout, + tls: upstream.tls, + keepalive_pool: upstream.keepalive_pool, + } + } +} + +/// Builds a service's wire body, including its embedded default upstream +/// (see the module doc comment for why this always goes through +/// `typing::Upstream`'s `From` impl rather than being assembled directly). +/// `type` is derived from the upstream's scheme: a `tcp`/`udp`/`tls` +/// upstream makes the service a `stream` service, anything else `http`. +pub fn transform_service(service: adc::Service) -> typing::Service { + let ty = match service.upstream.as_ref().map(|u| u.scheme) { + Some(adc::UpstreamScheme::Tcp | adc::UpstreamScheme::Udp | adc::UpstreamScheme::Tls) => { + "stream" + } + _ => "http", + }; + + typing::Service { + id: None, + service_id: service.id, + name: Some(service.name), + desc: service.description, + labels: transform_labels_to_wire(service.labels), + ty: Some(ty.to_string()), + + hosts: service.hosts, + upstream: service.upstream.map(typing::Upstream::from), + plugins: service.plugins, + path_prefix: service.path_prefix, + strip_path_prefix: service.strip_path_prefix, + + routes: None, + stream_routes: None, + upstreams: None, + } +} + +impl From for typing::Consumer { + fn from(consumer: adc::Consumer) -> Self { + typing::Consumer { + username: consumer.username, + desc: consumer.description, + labels: transform_labels_to_wire(consumer.labels), + plugins: consumer.plugins, + // Credentials sync as their own independent events, not nested + // in the consumer body. + credentials: None, + } + } +} + +impl From for typing::ConsumerCredential { + fn from(credential: adc::ConsumerCredential) -> Self { + let mut plugins = adc::Plugins::new(); + plugins.insert(credential.r#type, Value::Object(credential.config)); + + typing::ConsumerCredential { + id: credential.id, + name: Some(credential.name), + desc: credential.description, + labels: transform_labels_to_wire(credential.labels), + plugins: Some(plugins), + } + } +} + +impl TryFrom for typing::Ssl { + type Error = String; + + fn try_from(ssl: adc::SSL) -> Result { + let mut certificates = ssl.certificates.into_iter(); + let Some(first) = certificates.next() else { + return Err(format!( + "SSL {:?} has no certificates to write", + ssl.id.as_deref().unwrap_or("") + )); + }; + let (certs, keys): (Vec, Vec) = + certificates.map(|c| (c.certificate, c.key)).unzip(); + + Ok(typing::Ssl { + id: ssl.id, + labels: transform_labels_to_wire(ssl.labels), + + ty: Some(ssl.r#type), + cert: Some(first.certificate), + certs: (!certs.is_empty()).then_some(certs), + key: Some(first.key), + keys: (!keys.is_empty()).then_some(keys), + client: ssl.client, + snis: Some(ssl.snis), + + status: Some(1), + }) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn ip_restriction_plugins() -> adc::Plugins { + json!({ "ip-restriction": { "blacklist": ["0.0.0.0/0"] } }) + .as_object() + .unwrap() + .clone() + } + + /// Regression: the write-direction stream route conversion used to + /// drop `plugins`, so a synced stream route's plugins never actually + /// reached the wire. + #[test] + fn transform_stream_route_writes_plugins() { + let route = adc::StreamRoute { + id: Some("sr1".to_string()), + name: "sr1".to_string(), + description: Some("desc".to_string()), + labels: None, + plugins: Some(ip_restriction_plugins()), + remote_addr: None, + server_addr: None, + server_port: None, + sni: None, + }; + + let wire = transform_stream_route(route, "svc1".to_string()); + + assert_eq!(wire.plugins, Some(ip_restriction_plugins())); + } + + /// Regression: the read-direction stream route conversion used to drop + /// `plugins`, so dumping a stream route always came back without them — + /// the differ could then never detect a plugin removal (local empty === + /// remote empty), leaving stale plugins on the gateway. + #[test] + fn stream_route_from_wire_preserves_plugins_on_dump() { + let wire = typing::StreamRoute { + id: Some("sr1".to_string()), + stream_route_id: Some("sr1".to_string()), + name: Some("sr1".to_string()), + desc: Some("desc".to_string()), + labels: None, + service_id: Some("svc1".to_string()), + plugins: Some(ip_restriction_plugins()), + server_addr: Some("1.1.1.1".to_string()), + server_port: Some(80), + remote_addr: None, + }; + + let route = adc::StreamRoute::from(wire); + + assert_eq!(route.plugins, Some(ip_restriction_plugins())); + } +} diff --git a/rust/crates/adc-backend-api7/src/typing.rs b/rust/crates/adc-backend-api7/src/typing.rs new file mode 100644 index 00000000..2d5a0670 --- /dev/null +++ b/rust/crates/adc-backend-api7/src/typing.rs @@ -0,0 +1,289 @@ +//! API7 Enterprise Dashboard admin API wire shapes — what actually comes +//! back from (and gets sent to) `/apisix/admin/*` when scoped to a gateway +//! group, as opposed to `adc_sdk::resources::*` (ADC's own resource model). +//! +//! `Deserialize` stays permissive (no `deny_unknown_fields`) since it's +//! decoding a live, evolving third-party API, not validating user-authored +//! config. `Serialize` (for building sync/validate request bodies) omits +//! `None` fields via `skip_serializing_if` rather than sending explicit +//! `null`s, matching `adc-backend-apisix::typing`'s own convention. +//! +//! Nested shapes structurally identical to APISIX's own admin API (health +//! checks, node lists, timeouts, plugin maps, TLS) are reused directly from +//! `adc_sdk::resources` rather than duplicated — API7 Enterprise's +//! per-gateway-group admin API is APISIX-compatible for everything below +//! the resource envelope. `labels` is the one exception: every resource's +//! wire `labels` is a plain string map (unlike ADC's own string-or-array +//! `Labels`), with a multi-value label round-tripped as a JSON-array-shaped +//! string rather than a nested JSON array — see `transformer`'s label +//! conversion functions. +//! +//! `Route`/`Service`/`StreamRoute` read a resource's own id back as `id`, +//! but write it under a differently-named field instead +//! (`route_id`/`service_id`/`stream_route_id`) — the dashboard's admin API +//! quirk, not a modeling choice made here. `Route.service_id` is a second, +//! unrelated field with the same name: the *parent* service's id, present +//! in both directions. + +use std::collections::HashMap; + +use adc_sdk::resources::{ + Expr, Plugins, SslClient, SslType, Timeout, UpstreamBalancer, UpstreamHealthCheck, + UpstreamKeepalivePool, UpstreamNode, UpstreamPassHost, UpstreamScheme, UpstreamTls, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Route { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Write-only alias for this route's own id — see the module doc + /// comment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub route_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + /// The *parent* service's id — present on both read and write, unlike + /// `route_id`/`id` above. See the module doc comment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_id: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub paths: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub methods: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vars: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_websocket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct StreamRoute { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Write-only alias for this stream route's own id — see the module + /// doc comment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_route_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_id: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_addr: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_addr: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Service { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Write-only alias for this service's own id — see the module doc + /// comment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + /// Whether this service's traffic is `http` or `stream` — decides + /// whether the fetcher's cascading query fetches `routes` or + /// `stream_routes` for it on read, and is derived from the upstream's + /// scheme on write. Absent on read means `http`, matching APISIX. + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub ty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hosts: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strip_path_prefix: Option, + /// This service's own default upstream, embedded inline — unlike + /// APISIX, API7 has no separate top-level admin-API resource for it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + + /// Populated by [`crate::fetcher::Fetcher`], not by the dashboard API + /// itself, from a nested cascading query keyed on this service's id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub routes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_routes: Option>, + /// Named (non-default) upstreams for canary release, fetched from (and + /// written to) a separate `/services/{id}/upstreams` collection — + /// distinct from this service's own inline `upstream` field above. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstreams: Option>, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ConsumerCredential { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Consumer { + /// `#[serde(default)]` even though a real consumer always has one: a + /// schema-derived default value object (see `crate::default_value`) + /// never declares one, and without this, deserializing that object + /// into `Consumer` would fail outright and drop consumers from the + /// default-value set entirely instead of contributing an empty `{}`. + #[serde(default)] + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + + /// Populated by [`crate::fetcher::Fetcher`] from a separate + /// `/consumers/{username}/credentials` collection; never written back + /// as part of a consumer's own body — credentials sync as their own + /// independent events. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials: Option>, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Ssl { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub ty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keys: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snis: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +/// No id field: unlike every other resource, a global rule's identity is +/// its single plugin's name within `plugins`, not a separate field — the +/// URL path alone addresses it on write. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct GlobalRule { + #[serde(default)] + pub plugins: Plugins, +} + +pub type PluginMetadata = Plugins; + +/// Shared between the top-level `/apisix/admin/upstreams` list entry and an +/// upstream inlined directly into a service/route body — mirrors +/// `adc_backend_apisix::typing::Upstream`'s own reasoning for treating `id` +/// as optional on one shared shape rather than splitting read/write structs. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Upstream { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nodes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub ty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hash_on: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checks: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discovery_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discovery_args: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pass_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retries: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "adc_sdk::resources::serialize_optional_whole_number_as_integer" + )] + pub retry_timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keepalive_pool: Option, +} + +/// The list envelope every API7 admin collection endpoint returns — +/// `total` isn't modeled since nothing in this crate reads it. +#[derive(Debug, Clone, Deserialize)] +pub struct ListResponse { + pub list: Vec, +} + +/// The envelope a handful of *singular* admin endpoints return instead +/// (`/apisix/admin/plugin_metadata`, `/api/version`, `/api/schema/core`) — +/// one object, not a list. +#[derive(Debug, Clone, Deserialize)] +pub struct ValueResponse { + pub value: T, +} diff --git a/rust/crates/adc-backend-api7/src/utils.rs b/rust/crates/adc-backend-api7/src/utils.rs new file mode 100644 index 00000000..8ad697ec --- /dev/null +++ b/rust/crates/adc-backend-api7/src/utils.rs @@ -0,0 +1,14 @@ +use adc_backend_core::resource_type_collection_name; +use adc_sdk::ResourceType; + +/// `Upstream` and `ConsumerCredential` have no top-level admin API +/// collection at all — they live nested under their parent's own path, +/// which callers build themselves (see `operator::build_path`) — so +/// they're `None` here rather than a made-up path fragment a new caller +/// could accidentally use as-is. +pub fn resource_type_to_api_name(resource_type: ResourceType) -> Option { + match resource_type { + ResourceType::Upstream | ResourceType::ConsumerCredential => None, + other => Some(resource_type_collection_name(other)), + } +} diff --git a/rust/crates/adc-backend-api7/src/validator.rs b/rust/crates/adc-backend-api7/src/validator.rs new file mode 100644 index 00000000..160ace08 --- /dev/null +++ b/rust/crates/adc-backend-api7/src/validator.rs @@ -0,0 +1,350 @@ +//! Pre-flight validation against API7's `/apisix/admin/configs/validate` +//! endpoint: batches every create/update event's wire body by resource +//! type and asks the dashboard to check it without actually applying +//! anything, then maps any reported errors back onto the `Event`s that +//! produced them. +//! +//! Unlike `adc_backend_apisix::Validator`, a version too old to support +//! this endpoint at all is rejected by a client-side check before any +//! request is made, rather than by interpreting a 404 response. + +use std::collections::HashMap; + +use adc_backend_core::{HttpClient, Method}; +use adc_sdk::resources::{self as adc}; +use adc_sdk::{ + BackendError, BackendValidateResult, BackendValidationError, Event, EventType, ResourceType, +}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::transformer; +use crate::typing; + +const MINIMUM_VALIDATE_VERSION: Version = Version::new(3, 9, 10); + +pub struct Validator { + client: HttpClient, + version: Version, + gateway_group_id: Option, +} + +/// One entry per group API7's validate endpoint recognizes — deliberately +/// not every `ResourceType`: consumer credentials, consumer groups, plugin +/// configs and standalone upstream events never appear in this payload +/// (unlike `adc_backend_apisix`'s validator, there's no `upstreams` group +/// at all here — a service's default upstream travels embedded in its own +/// body). +#[derive(Debug, Default, Serialize)] +struct ValidateRequestBody { + routes: Vec, + services: Vec, + consumers: Vec, + ssls: Vec, + global_rules: Vec, + stream_routes: Vec, + plugin_metadata: Vec, +} + +/// Per group, the `(resource_name, Event)` that produced each entry, in the +/// same order they were pushed — the validate response reports errors by +/// `(resource_type, index)`, and this is what turns that back into a name +/// and an `Event` for `BackendValidationError`. +type ValidateIndex = HashMap<&'static str, Vec<(String, Event)>>; + +#[derive(Debug, Deserialize)] +struct ValidateErrorResponse { + error_msg: Option, + #[serde(default)] + errors: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawValidationError { + resource_type: String, + resource_id: Option, + index: usize, + error: String, +} + +impl Validator { + pub fn new(client: HttpClient, version: Version, gateway_group_id: Option) -> Self { + Self { + client, + version, + gateway_group_id, + } + } + + pub async fn validate(&self, events: &[Event]) -> Result { + if self.version < MINIMUM_VALIDATE_VERSION { + return Err(BackendError::Unsupported(format!( + "validate is not supported by the current backend version ({}). Please upgrade to a newer version.", + self.version + ))); + } + + let (body, index) = build_request(events)?; + let mut request = self + .client + .request(Method::POST, "/apisix/admin/configs/validate")? + .json(&body); + if let Some(id) = &self.gateway_group_id { + request = request.query(&[("gateway_group_id", id)]); + } + let response = self.client.execute(request).await?; + + match response.status().as_u16() { + 200..=299 => Ok(BackendValidateResult { + success: true, + error_message: None, + errors: vec![], + }), + 400 => { + let payload: ValidateErrorResponse = response.json().await.map_err(|e| { + BackendError::Serialization(format!("decoding validate error response: {e}")) + })?; + let errors = payload + .errors + .into_iter() + .map(|raw| enrich(raw, &index)) + .collect(); + Ok(BackendValidateResult { + success: false, + error_message: payload.error_msg, + errors, + }) + } + status => match HttpClient::require_success(response).await { + Err(error) => Err(error), + // `require_success` only accepts a 2xx status, and this arm + // is only reached for one that's neither 2xx nor 400 — so + // this is unreachable in practice, but a descriptive error + // beats a panic if that ever stops being true. + Ok(_) => Err(BackendError::Other( + format!("unexpected successful response with status {status} from validate").into(), + )), + }, + } + } +} + +fn enrich(raw: RawValidationError, index: &ValidateIndex) -> BackendValidationError { + let matched = index + .get(raw.resource_type.as_str()) + .and_then(|group| group.get(raw.index)); + BackendValidationError { + resource_type: raw.resource_type, + resource_id: raw.resource_id, + resource_name: matched.map(|(name, _)| name.clone()), + index: raw.index, + error: raw.error, + event: matched.map(|(_, event)| event.clone()), + } +} + +fn missing_parent(event: &Event) -> BackendError { + BackendError::Other( + format!( + "{:?} event for resource {:?} is missing a parent_id", + event.resource_type, event.resource_id + ) + .into(), + ) +} + +fn deserialize_event_value( + value: &Value, +) -> Result { + serde_json::from_value(value.clone()) + .map_err(|e| BackendError::Serialization(format!("decoding event payload: {e}"))) +} + +fn build_request(events: &[Event]) -> Result<(ValidateRequestBody, ValidateIndex), BackendError> { + let mut body = ValidateRequestBody::default(); + let mut index: ValidateIndex = HashMap::new(); + + for event in events { + if !matches!(event.event_type(), EventType::Create | EventType::Update) { + continue; + } + let new_value = event + .kind + .new_value() + .ok_or_else(|| BackendError::Other("create/update event missing new_value".into()))?; + let track = |index: &mut ValidateIndex, group: &'static str| { + index + .entry(group) + .or_default() + .push((event.resource_name.clone(), event.clone())); + }; + + match event.resource_type { + ResourceType::Service => { + let mut service: adc::Service = deserialize_event_value(new_value)?; + service.id = Some(event.resource_id.clone()); + body.services.push(transformer::transform_service(service)); + track(&mut index, "services"); + } + ResourceType::Route => { + let mut route: adc::Route = deserialize_event_value(new_value)?; + route.id = Some(event.resource_id.clone()); + let parent_id = event + .parent_id + .clone() + .ok_or_else(|| missing_parent(event))?; + body.routes + .push(transformer::transform_route(route, parent_id)); + track(&mut index, "routes"); + } + ResourceType::StreamRoute => { + let mut route: adc::StreamRoute = deserialize_event_value(new_value)?; + route.id = Some(event.resource_id.clone()); + let parent_id = event + .parent_id + .clone() + .ok_or_else(|| missing_parent(event))?; + body.stream_routes + .push(transformer::transform_stream_route(route, parent_id)); + track(&mut index, "stream_routes"); + } + ResourceType::Consumer => { + let consumer: adc::Consumer = deserialize_event_value(new_value)?; + body.consumers.push(typing::Consumer::from(consumer)); + track(&mut index, "consumers"); + } + ResourceType::Ssl => { + let mut ssl: adc::SSL = deserialize_event_value(new_value)?; + ssl.id = Some(event.resource_id.clone()); + body.ssls + .push(typing::Ssl::try_from(ssl).map_err(BackendError::Serialization)?); + track(&mut index, "ssls"); + } + ResourceType::GlobalRule => { + let mut plugins = adc::Plugins::new(); + plugins.insert(event.resource_id.clone(), new_value.clone()); + body.global_rules.push(typing::GlobalRule { plugins }); + track(&mut index, "global_rules"); + } + ResourceType::PluginMetadata => { + let mut value = new_value.clone(); + if let Value::Object(map) = &mut value { + map.insert("id".to_string(), Value::String(event.resource_id.clone())); + } + body.plugin_metadata.push(value); + track(&mut index, "plugin_metadata"); + } + ResourceType::ConsumerCredential + | ResourceType::ConsumerGroup + | ResourceType::PluginConfig + | ResourceType::Upstream + | ResourceType::InternalStreamService => { + // Not part of API7's validate payload — see + // `ValidateRequestBody`'s doc comment. + } + } + } + + Ok((body, index)) +} + +#[cfg(test)] +mod tests { + use adc_sdk::EventKind; + use serde_json::json; + + use super::*; + + #[test] + fn enrich_matches_a_known_resource_type_and_index() { + let event = Event::new( + ResourceType::Route, + EventKind::Create { + new_value: json!({}), + }, + "route-1", + "route-1", + ); + let mut index: ValidateIndex = HashMap::new(); + index.insert("routes", vec![("get-anything".to_string(), event.clone())]); + + let raw = RawValidationError { + resource_type: "routes".to_string(), + resource_id: None, + index: 0, + error: "bad route".to_string(), + }; + let result = enrich(raw, &index); + + assert_eq!(result.resource_type, "routes"); + assert_eq!(result.resource_name.as_deref(), Some("get-anything")); + assert_eq!(result.event, Some(event)); + } + + #[test] + fn enrich_handles_an_unrecognized_resource_type_without_panicking() { + let index: ValidateIndex = HashMap::new(); + let raw = RawValidationError { + resource_type: "unknown_type".to_string(), + resource_id: None, + index: 0, + error: "some error".to_string(), + }; + + let result = enrich(raw, &index); + + assert_eq!(result.resource_type, "unknown_type"); + assert!(result.resource_name.is_none()); + assert!(result.event.is_none()); + } + + #[test] + fn build_request_skips_delete_events() { + let delete_event = Event::new( + ResourceType::Route, + EventKind::Delete { + old_value: json!({}), + }, + "route-1", + "route-1", + ); + let (body, index) = build_request(&[delete_event]).unwrap(); + assert!(body.routes.is_empty()); + assert!(index.is_empty()); + } + + #[test] + fn build_request_omits_resource_types_with_no_validate_group() { + let mut credential_event = Event::new( + ResourceType::ConsumerCredential, + EventKind::Create { + new_value: json!({}), + }, + "cred-1", + "cred-1", + ); + credential_event.parent_id = Some("user1".to_string()); + let (body, index) = build_request(&[credential_event]).unwrap(); + assert_eq!( + serde_json::to_value(&body).unwrap(), + json!({"routes": [], "services": [], "consumers": [], "ssls": [], "global_rules": [], "stream_routes": [], "plugin_metadata": []}) + ); + assert!(index.is_empty()); + } + + #[test] + fn build_request_stamps_a_plugin_metadata_events_resource_id_as_its_id() { + let event = Event::new( + ResourceType::PluginMetadata, + EventKind::Create { + new_value: json!({ "log_format": {} }), + }, + "http-logger", + "http-logger", + ); + let (body, _index) = build_request(&[event]).unwrap(); + assert_eq!(body.plugin_metadata[0]["id"], "http-logger"); + assert_eq!(body.plugin_metadata[0]["log_format"], json!({})); + } +} diff --git a/rust/crates/adc-backend-api7/tests/common/mod.rs b/rust/crates/adc-backend-api7/tests/common/mod.rs new file mode 100644 index 00000000..4dd5a650 --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/common/mod.rs @@ -0,0 +1,605 @@ +//! Shared scaffolding for this crate's real-e2e test files: a live API7 +//! Enterprise dashboard. See `e2e_gateway_group.rs`'s module doc for how to +//! bring one up. Unlike APISIX's static admin key, a fresh API7 dashboard +//! needs a session login + password rotation + license activation + token +//! generation dance before any admin API call works at all — this module +//! ports that dance from the TS e2e suite's `e2e/support/global-setup.ts`, +//! so a bare `cargo test --ignored` against a freshly `docker compose up`'d +//! dashboard works standalone, without going through the TS suite first. +#![allow(dead_code)] + +use std::env; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use adc_backend_core::{HttpClient, HttpClientConfig, TlsConfig}; +use adc_sdk::resources::Configuration; +use adc_sdk::utils::generate_id; +use adc_sdk::{ + BackendError, BackendSyncOptions, BackendSyncResult, DefaultValue, Event, EventKind, + ResourceType, +}; +use serde_json::{Value, json}; +use tokio::sync::OnceCell; + +const BOOTSTRAP_PASSWORD: &str = "Admin12345!@#$%"; + +pub fn server() -> String { + env::var("SERVER").unwrap_or_else(|_| "https://localhost:7443".to_string()) +} + +pub fn gateway_group() -> String { + env::var("GATEWAY_GROUP").unwrap_or_else(|_| "default".to_string()) +} + +/// The dashboard version under test, from the same env var the CI matrix +/// sets — used to skip a test scenario that only applies above/below a +/// given release, the same role `semverCondition` plays in the TS suite. +/// Unset (a local run against whatever's in the compose file) is treated +/// as "newest", so every version-gated scenario runs by default. +pub fn server_version() -> semver::Version { + match env::var("BACKEND_API7_VERSION") { + Ok(v) => semver::Version::parse(&v) + .unwrap_or_else(|e| panic!("BACKEND_API7_VERSION={v:?} is not a valid semver: {e}")), + Err(_) => semver::Version::new(999, 999, 999), + } +} + +fn unique_name(prefix: &str) -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!( + "{prefix}-{nanos}-{}", + COUNTER.fetch_add(1, Ordering::Relaxed) + ) +} + +/// A session-cookie-authenticated client for the dashboard's own +/// (non-admin-API) endpoints used only during bootstrap (`/api/login`, +/// `/api/password`, `/api/license`, `/api/invites`, `/api/users/*`, +/// `/api/tokens`) — a separate concern from the `X-API-KEY`-authenticated +/// `HttpClient` the `GatewayGroupResolver` under test actually uses. +struct DashboardSession { + client: reqwest::Client, + base: String, +} + +impl DashboardSession { + fn new(base: String) -> Self { + let client = reqwest::Client::builder() + .cookie_store(true) + .danger_accept_invalid_certs(true) + .build() + .expect("building the dashboard session client"); + Self { client, base } + } + + /// Ports `global-setup.ts`'s `waitForDashboard`: polls until the + /// dashboard answers at all (any HTTP response, not a connection + /// error), rather than assuming it's ready right after `docker compose + /// up -d` returns. + async fn wait_ready(&self) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(120); + loop { + if self + .client + .get(format!("{}/api/status", self.base)) + .timeout(Duration::from_secs(2)) + .send() + .await + .is_ok() + { + return; + } + if tokio::time::Instant::now() >= deadline { + panic!("dashboard at {} was not ready within 120s", self.base); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + + async fn login(&self, username: &str, password: &str) { + assert!( + self.try_login(username, password).await, + "login as {username:?} failed" + ); + } + + /// Like [`Self::login`], but reports success/failure instead of + /// panicking — used where a non-2xx response is an expected, handled + /// outcome rather than a bootstrap failure. + async fn try_login(&self, username: &str, password: &str) -> bool { + let response = self + .client + .post(format!("{}/api/login", self.base)) + .json(&json!({ "username": username, "password": password })) + .send() + .await + .unwrap_or_else(|e| panic!("POST /api/login: {e}")); + response.status().is_success() + } + + async fn put(&self, path: &str, body: Value) { + let response = self + .client + .put(format!("{}{path}", self.base)) + .json(&body) + .send() + .await + .unwrap_or_else(|e| panic!("PUT {path}: {e}")); + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + assert!(status.is_success(), "PUT {path} failed: {status}: {text}"); + } + + async fn post(&self, path: &str, body: Value) -> Value { + let response = self + .client + .post(format!("{}{path}", self.base)) + .json(&body) + .send() + .await + .unwrap_or_else(|e| panic!("POST {path}: {e}")); + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + assert!(status.is_success(), "POST {path} failed: {status}: {text}"); + serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("decoding response from POST {path}: {e}: {text}")) + } +} + +async fn activate_license(session: &DashboardSession, license: &Option) { + if let Some(license) = license { + session + .put("/api/license", json!({ "data": license })) + .await; + } +} + +/// Ports `global-setup.ts`'s `initUser`: log in, then rotate the password — +/// a fresh account rejects everything else (including, it turns out, +/// minting an API token) until this happens, *even* for the throwaway +/// invited user `bootstrap_token` creates below, not just the built-in +/// `admin` account. `first_time` gates license activation only (TS's +/// `fisrtTime` param); the password rotation itself always runs. +async fn init_user( + session: &DashboardSession, + username: &str, + password: &str, + first_time: bool, + version: &semver::Version, + license: &Option, +) { + // Unlike the TS suite (a single Jest `globalSetup` run shared by every + // spec file), each of this crate's e2e test binaries is its own + // process and independently bootstraps against the same live + // dashboard. Whichever binary runs first rotates `admin`'s password + // for real; every one after that finds the original password already + // rejected — not a bootstrap failure, just evidence this already ran. + // In that case, skip straight to logging in with the already-rotated + // password (and skip re-activating the license) instead of repeating + // the dance a previous binary already completed. + if !session.try_login(username, password).await { + session.login(username, BOOTSTRAP_PASSWORD).await; + return; + } + // Mirrors the TS suite: on dashboards older than 3.2.15 the license + // must be uploaded before the password can be changed at all. + if first_time && *version < semver::Version::new(3, 2, 15) { + activate_license(session, license).await; + } + session + .put( + "/api/password", + json!({ "old_password": password, "new_password": BOOTSTRAP_PASSWORD }), + ) + .await; + session.login(username, BOOTSTRAP_PASSWORD).await; + if first_time && *version >= semver::Version::new(3, 2, 15) { + activate_license(session, license).await; + } +} + +/// Ports `global-setup.ts`'s `initUser` + `generateToken`: log in as the +/// default admin, rotate the password (a fresh dashboard rejects most +/// endpoints until this happens), activate the license, then provision a +/// throwaway super-admin user (also password-rotated) and mint an API +/// token from it — exactly the TS suite's own bootstrap, so a fresh +/// dashboard behaves identically regardless of which suite talks to it +/// first. +async fn bootstrap_token() -> String { + let session = DashboardSession::new(server()); + session.wait_ready().await; + + let version = server_version(); + let license = env::var("BACKEND_API7_LICENSE") + .ok() + .filter(|v| !v.is_empty()); + + init_user(&session, "admin", "admin", true, &version, &license).await; + + let username = unique_name("adc-rust-e2e"); + let invite = session + .post( + "/api/invites", + json!({ "username": username, "password": "test" }), + ) + .await; + let user_id = invite["value"]["id"] + .as_str() + .expect("invite response missing value.id") + .to_string(); + session + .put( + &format!("/api/users/{user_id}/assigned_roles"), + json!({ "roles": ["super_admin_id"] }), + ) + .await; + + init_user(&session, &username, "test", false, &version, &license).await; + + let token = session + .post( + "/api/tokens", + json!({ "expires_at": 0, "name": unique_name("adc-rust-e2e-token") }), + ) + .await; + token["value"]["token"] + .as_str() + .expect("token response missing value.token") + .to_string() +} + +static TOKEN: OnceCell = OnceCell::const_new(); + +/// A pre-minted `TOKEN` env var (e.g. handed off by a TS e2e run against +/// the same dashboard) short-circuits the dance; otherwise it runs once +/// per test binary and every test shares the result. +pub async fn token() -> String { + if let Ok(token) = env::var("TOKEN") { + return token; + } + TOKEN.get_or_init(bootstrap_token).await.clone() +} + +pub async fn client() -> HttpClient { + HttpClient::new(HttpClientConfig { + server: server(), + token: token().await, + timeout: None, + tls: TlsConfig { + skip_verify: true, + ..Default::default() + }, + }) + .unwrap() +} + +pub async fn backend() -> adc_backend_api7::Backend { + adc_backend_api7::Backend::new( + client().await, + gateway_group(), + &token().await, + adc_backend_core::ResourceFilter::default(), + ) +} + +pub async fn sync_events( + backend: &adc_backend_api7::Backend, + events: Vec, +) -> Result, BackendError> { + sync_events_with_opts(backend, events, BackendSyncOptions::default()).await +} + +pub async fn sync_events_with_opts( + backend: &adc_backend_api7::Backend, + events: Vec, + opts: BackendSyncOptions, +) -> Result, BackendError> { + use adc_sdk::Backend as _; + backend.sync(events, opts).await +} + +pub async fn dump_configuration( + backend: &adc_backend_api7::Backend, +) -> Result { + use adc_sdk::Backend as _; + backend.dump().await +} + +pub async fn get_default_value( + backend: &adc_backend_api7::Backend, +) -> Result { + use adc_sdk::Backend as _; + backend.default_value().await +} + +/// Runs the real differ (not a stand-in) between a desired `local` +/// configuration and the `remote` one a dump just returned, the same way +/// `adc-cli`'s own `pipeline::diff` does — so a test can build events by +/// stating the shape it wants rather than hand-assembling each `Event`. +pub fn diff( + local: &Configuration, + remote: &Configuration, + default_value: Option<&DefaultValue>, +) -> Vec { + fn to_diff_map(configuration: &Configuration) -> adc_sdk::InternalConfiguration { + match serde_json::to_value(configuration).expect("Configuration always serializes") { + Value::Object(map) => map, + _ => unreachable!("Configuration always serializes to a JSON object"), + } + } + adc_differ::DifferV4::diff( + &to_diff_map(local), + &to_diff_map(remote), + default_value, + None, + ) +} + +/// A resource's id is derived from its name (and parent, where nested) via +/// the same content hash the differ itself uses — an SSL's id is derived +/// from its SNIs instead, since `resource_name` for an SSL is, by this +/// whole suite's own convention, already the comma-joined SNI list (see +/// e.g. `sslName` in the test files that build one). +pub fn create_event( + resource_type: ResourceType, + resource_name: &str, + resource: Value, + parent_name: Option<&str>, +) -> Event { + let resource_id = derive_resource_id(resource_type, resource_name, parent_name); + let mut event = Event::new( + resource_type, + EventKind::Create { + new_value: resource, + }, + resource_id, + resource_name, + ); + event.parent_id = derive_parent_id(resource_type, parent_name); + event +} + +/// Same id derivation as [`create_event`], but carrying an `Update` — the +/// differ itself always attaches a real `old_value`/`diff`, but nothing +/// downstream of event construction in these tests reads either, so an +/// empty placeholder `old_value` stands in. +pub fn update_event( + resource_type: ResourceType, + resource_name: &str, + resource: Value, + parent_name: Option<&str>, +) -> Event { + let created = create_event(resource_type, resource_name, resource.clone(), parent_name); + Event { + kind: EventKind::Update { + old_value: json!({}), + new_value: resource, + diff: None, + }, + ..created + } +} + +pub fn delete_event( + resource_type: ResourceType, + resource_name: &str, + parent_name: Option<&str>, +) -> Event { + let resource_id = derive_resource_id_for_delete(resource_type, resource_name, parent_name); + let mut event = Event::new( + resource_type, + EventKind::Delete { + old_value: json!({}), + }, + resource_id, + resource_name, + ); + event.parent_id = derive_parent_id(resource_type, parent_name); + event +} + +pub fn override_event_resource_id( + mut event: Event, + resource_id: &str, + parent_id: Option<&str>, +) -> Event { + event.resource_id = resource_id.to_string(); + if let Some(parent_id) = parent_id { + event.parent_id = Some(parent_id.to_string()); + } + event +} + +fn derive_resource_id( + resource_type: ResourceType, + resource_name: &str, + parent_name: Option<&str>, +) -> String { + match resource_type { + ResourceType::Consumer | ResourceType::GlobalRule | ResourceType::PluginMetadata => { + resource_name.to_string() + } + ResourceType::Ssl => generate_id(resource_name), + _ => derive_resource_id_for_delete(resource_type, resource_name, parent_name), + } +} + +fn derive_resource_id_for_delete( + resource_type: ResourceType, + resource_name: &str, + parent_name: Option<&str>, +) -> String { + match resource_type { + ResourceType::Consumer | ResourceType::GlobalRule | ResourceType::PluginMetadata => { + resource_name.to_string() + } + _ => generate_id(&match parent_name { + Some(parent) => format!("{parent}.{resource_name}"), + None => resource_name.to_string(), + }), + } +} + +fn derive_parent_id(resource_type: ResourceType, parent_name: Option<&str>) -> Option { + parent_name.map(|parent| { + if resource_type == ResourceType::ConsumerCredential { + parent.to_string() + } else { + generate_id(parent) + } + }) +} + +fn assets_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../libs/backend-api7/e2e/assets") +} + +pub fn read_asset(name: &str) -> String { + let path = assets_dir().join(name); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +fn resource_type_from_fixture_str(value: &str) -> ResourceType { + match value { + "route" => ResourceType::Route, + "service" => ResourceType::Service, + "upstream" => ResourceType::Upstream, + "ssl" => ResourceType::Ssl, + "global_rule" => ResourceType::GlobalRule, + "plugin_config" => ResourceType::PluginConfig, + "plugin_metadata" => ResourceType::PluginMetadata, + "consumer" => ResourceType::Consumer, + "consumer_group" => ResourceType::ConsumerGroup, + "consumer_credential" => ResourceType::ConsumerCredential, + "stream_route" => ResourceType::StreamRoute, + other => panic!("unrecognized resourceType {other:?} in fixture"), + } +} + +/// Loads a `testdata/*.json` fixture — a plain JSON array of events in the +/// TS suite's own camelCase field names (`resourceType`/`resourceId`/...), +/// not `Event`'s own (deliberately snake_case, `Serialize`-only) wire +/// shape — so this reads the raw JSON structurally instead of deriving +/// `Deserialize` on `Event` just for this one fixture-loading path. +pub fn load_events_fixture(name: &str) -> Vec { + let raw: Value = serde_json::from_str(&read_asset(&format!("testdata/{name}"))) + .unwrap_or_else(|e| panic!("parsing fixture {name}: {e}")); + raw.as_array() + .unwrap_or_else(|| panic!("fixture {name} is not a JSON array")) + .iter() + .map(|item| { + let resource_type = resource_type_from_fixture_str( + item["resourceType"] + .as_str() + .expect("event missing resourceType"), + ); + let resource_id = item["resourceId"] + .as_str() + .expect("event missing resourceId") + .to_string(); + let resource_name = item["resourceName"] + .as_str() + .expect("event missing resourceName") + .to_string(); + let kind = match item["type"].as_str().expect("event missing type") { + "create" => EventKind::Create { + new_value: item["newValue"].clone(), + }, + "update" => EventKind::Update { + old_value: item.get("oldValue").cloned().unwrap_or(Value::Null), + new_value: item["newValue"].clone(), + diff: None, + }, + "delete" => EventKind::Delete { + old_value: item.get("oldValue").cloned().unwrap_or(Value::Null), + }, + other => panic!("unrecognized event type {other:?}"), + }; + let mut event = Event::new(resource_type, kind, resource_id, resource_name); + event.parent_id = item + .get("parentId") + .and_then(Value::as_str) + .map(String::from); + event + }) + .collect() +} + +/// A `serde_json::Value`-based stand-in for Jest's `toMatchObject`: every +/// key `expected` declares must be present in `actual` and itself match +/// (recursively, for nested objects); an array in `expected` must have the +/// same length as `actual`'s, with each element matching positionally by +/// the same rule; any other value must be exactly equal. Extra keys/object +/// fields in `actual` that `expected` doesn't mention are ignored. +pub fn assert_matches_object(actual: &Value, expected: &Value) { + assert_matches_object_at(actual, expected, "$"); +} + +fn assert_matches_object_at(actual: &Value, expected: &Value, path: &str) { + match expected { + Value::Object(expected_map) => { + let Value::Object(actual_map) = actual else { + panic!("at {path}: expected an object matching {expected}, got {actual}"); + }; + for (key, expected_value) in expected_map { + let actual_value = actual_map.get(key).unwrap_or_else(|| { + panic!("at {path}.{key}: key missing from actual value {actual}") + }); + assert_matches_object_at(actual_value, expected_value, &format!("{path}.{key}")); + } + } + Value::Array(expected_items) => { + let Value::Array(actual_items) = actual else { + panic!("at {path}: expected an array matching {expected}, got {actual}"); + }; + assert_eq!( + actual_items.len(), + expected_items.len(), + "at {path}: array length mismatch (actual {actual_items:?} vs expected {expected_items:?})" + ); + for (index, (actual_item, expected_item)) in + actual_items.iter().zip(expected_items).enumerate() + { + assert_matches_object_at(actual_item, expected_item, &format!("{path}[{index}]")); + } + } + // `serde_json::Number`'s `PartialEq` is representation-sensitive + // (`Number(60.0)` != `Number(60)` even though they're the same + // value) — a real dashboard's JSON response and a hand-written + // `json!(60)` literal in a test frequently disagree on exactly + // this, for a field that's numerically identical either way, so + // this compares as f64 instead of relying on `Value`'s own `==`. + Value::Number(expected_number) => match actual { + Value::Number(actual_number) => assert_eq!( + actual_number.as_f64(), + expected_number.as_f64(), + "at {path}" + ), + _ => panic!("at {path}: expected a number matching {expected}, got {actual}"), + }, + _ => assert_eq!(actual, expected, "at {path}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_float_and_an_integer_representation_of_the_same_number_match() { + assert_matches_object(&json!({ "timeout": 60.0 }), &json!({ "timeout": 60 })); + assert_matches_object(&json!({ "timeout": 60 }), &json!({ "timeout": 60.0 })); + } + + #[test] + #[should_panic] + fn genuinely_different_numbers_still_fail() { + assert_matches_object(&json!({ "timeout": 61 }), &json!({ "timeout": 60 })); + } +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_default_value.rs b/rust/crates/adc-backend-api7/tests/e2e_default_value.rs new file mode 100644 index 00000000..efaba4dd --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_default_value.rs @@ -0,0 +1,195 @@ +//! Real end-to-end tests against a live API7 Enterprise dashboard, not a +//! mock. Requires `docker compose up -d` in `libs/backend-api7/e2e/assets` +//! — see `tests/common/mod.rs`'s module doc. +//! +//! Ignored by default; run with `cargo test -p adc-backend-api7 --test +//! e2e_default_value -- --ignored --test-threads=1`. + +use adc_sdk::ResourceType; +use semver::Version; +use serde_json::{Value, json}; + +mod common; +use common::{assert_matches_object, get_default_value, server_version}; + +fn core_default( + core: &std::collections::HashMap, + resource_type: ResourceType, +) -> Value { + core.get(&resource_type) + .unwrap_or_else(|| panic!("no default value present for {resource_type:?}")) + .clone() +} + +/// Which of this file's three `#[ignore]`d tests a given version's gate +/// selects — kept in one place so [`version_gates_are_exhaustive`] can +/// verify no version (including `server_version()`'s own "unset" fallback +/// of `999.999.999`) slips through all three and leaves every test +/// reporting a silent pass without actually asserting anything. +fn gate_for(version: &Version) -> &'static str { + if *version < Version::new(3, 6, 0) { + "default_value_below_3_6_0" + } else if *version < Version::new(3, 8, 0) { + "default_value_from_3_6_0_below_3_8_0" + } else { + "default_value_from_3_8_0" + } +} + +#[test] +fn version_gates_are_exhaustive() { + assert_eq!(gate_for(&Version::new(0, 0, 0)), "default_value_below_3_6_0"); + assert_eq!( + gate_for(&Version::new(3, 5, 99)), + "default_value_below_3_6_0" + ); + assert_eq!( + gate_for(&Version::new(3, 6, 0)), + "default_value_from_3_6_0_below_3_8_0" + ); + assert_eq!( + gate_for(&Version::new(3, 7, 99)), + "default_value_from_3_6_0_below_3_8_0" + ); + assert_eq!(gate_for(&Version::new(3, 8, 0)), "default_value_from_3_8_0"); + // `server_version()`'s own fallback for an unset `BACKEND_API7_VERSION` + // — must land on the same "newest" gate every other local-run default + // assumes, not silently miss all three. + assert_eq!( + gate_for(&Version::new(999, 999, 999)), + "default_value_from_3_8_0" + ); +} + +#[tokio::test] +#[ignore] +async fn default_value_below_3_6_0() { + if server_version() >= Version::new(3, 6, 0) { + eprintln!("skipping: only applies below 3.6.0"); + return; + } + let backend = common::backend().await; + let default_value = get_default_value(&backend).await.unwrap(); + + assert_matches_object( + &core_default(&default_value.core, ResourceType::Service), + &json!({ + "upstream": { + "checks": { + "active": { + "concurrency": 10, + "healthy": { "http_statuses": [200, 302], "interval": 1, "successes": 2 }, + "http_path": "/", + "https_verify_certificate": true, + "timeout": 1, + "type": "http", + "unhealthy": { "http_failures": 5, "http_statuses": [429, 404, 500, 501, 502, 503, 504, 505], "interval": 1, "tcp_failures": 2, "timeouts": 3 }, + }, + "passive": { + "healthy": { "http_statuses": [200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308], "successes": 5 }, + "type": "http", + "unhealthy": { "http_failures": 5, "http_statuses": [429, 500, 503], "tcp_failures": 2, "timeouts": 7 }, + }, + }, + "discovery_args": {}, + "hash_on": "vars", + "keepalive_pool": { "idle_timeout": 60, "requests": 1000, "size": 320 }, + "name": "default", + "nodes": [{ "priority": 0 }], + "pass_host": "pass", + "retry_timeout": 0, + "scheme": "http", + "timeout": { "connect": 60, "read": 60, "send": 60 }, + "type": "roundrobin", + }, + }), + ); + assert_matches_object( + &core_default(&default_value.core, ResourceType::Ssl), + &json!({ "client": { "depth": 1 } }), + ); +} + +#[tokio::test] +#[ignore] +async fn default_value_from_3_6_0_below_3_8_0() { + if !(server_version() >= Version::new(3, 6, 0) && server_version() < Version::new(3, 8, 0)) { + eprintln!("skipping: only applies in [3.6.0, 3.8.0)"); + return; + } + let backend = common::backend().await; + let default_value = get_default_value(&backend).await.unwrap(); + + assert_matches_object( + &core_default(&default_value.core, ResourceType::Service), + &json!({ "strip_path_prefix": true }), + ); + assert_matches_object( + &core_default(&default_value.core, ResourceType::Ssl), + &json!({ "client": { "depth": 1 } }), + ); +} + +#[tokio::test] +#[ignore] +async fn default_value_from_3_8_0() { + if server_version() < Version::new(3, 8, 0) { + eprintln!("skipping: only applies from 3.8.0"); + return; + } + let backend = common::backend().await; + let default_value = get_default_value(&backend).await.unwrap(); + let core = &default_value.core; + + assert_matches_object(&core_default(core, ResourceType::Consumer), &json!({})); + assert_matches_object( + &core_default(core, ResourceType::ConsumerCredential), + &json!({}), + ); + assert_matches_object(&core_default(core, ResourceType::GlobalRule), &json!({})); + assert_matches_object( + &core_default(core, ResourceType::PluginMetadata), + &json!({}), + ); + assert_matches_object( + &core_default(core, ResourceType::Route), + &json!({ "priority": 0, "timeout": { "connect": 60, "read": 60, "send": 60 } }), + ); + + let upstream = json!({ + "discovery_args": {}, + "hash_on": "vars", + "keepalive_pool": { "idle_timeout": 60, "requests": 1000, "size": 320 }, + "name": "default", + "nodes": [{ "priority": 0 }], + "pass_host": "pass", + "retry_timeout": 0, + "scheme": "http", + "timeout": { "connect": 60, "read": 60, "send": 60 }, + "type": "roundrobin", + }); + assert_matches_object( + &core_default(core, ResourceType::Service), + &json!({ "strip_path_prefix": true, "upstream": upstream }), + ); + assert_matches_object( + &core_default(core, ResourceType::Ssl), + &json!({ "certificates": [], "snis": [] }), + ); + assert_matches_object(&core_default(core, ResourceType::StreamRoute), &json!({})); + assert_matches_object( + &core_default(core, ResourceType::InternalStreamService), + &json!({ + "upstream": { + "hash_on": "vars", + "name": "default", + "nodes": [{ "priority": 0 }], + "retry_timeout": 0, + "scheme": "tcp", + "timeout": { "connect": 60, "read": 60, "send": 60 }, + "type": "roundrobin", + }, + }), + ); + assert_matches_object(&core_default(core, ResourceType::Upstream), &upstream); +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_gateway_group.rs b/rust/crates/adc-backend-api7/tests/e2e_gateway_group.rs new file mode 100644 index 00000000..59a1db04 --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_gateway_group.rs @@ -0,0 +1,64 @@ +//! Real end-to-end tests against a live API7 Enterprise dashboard, not a +//! mock. Requires `docker compose up -d` in `libs/backend-api7/e2e/assets` +//! (the same stack the TS `backend-api7` e2e suite uses) — dashboard at +//! `https://localhost:7443` (self-signed cert). `common::client()`/ +//! `common::token()` handle the login/password-rotation/license-activation +//! /token-minting dance a fresh dashboard needs themselves (see +//! `common`'s module doc), so nothing beyond the running dashboard is +//! required; set `SERVER`/`GATEWAY_GROUP`/`TOKEN`/`BACKEND_API7_LICENSE`/ +//! `BACKEND_API7_VERSION` to override the defaults the TS e2e suite's own +//! `global-setup.ts` uses. +//! +//! Ignored by default (`cargo test` never touches the network); run with +//! `cargo test -p adc-backend-api7 --test e2e_gateway_group -- --ignored --test-threads=1`. + +use adc_backend_api7::tests::GatewayGroupResolver; +use adc_backend_core::{HttpClient, HttpClientConfig, TlsConfig}; + +mod common; +use common::{client, gateway_group, token}; + +#[tokio::test] +#[ignore] +async fn resolves_the_configured_gateway_group_to_a_real_id() { + let resolver = GatewayGroupResolver::new(client().await, gateway_group(), &token().await); + + let id = resolver.resolve().await.unwrap(); + assert!(id.is_some(), "expected a resolved gateway group id"); + + // Second call must come from the cache, not a fresh lookup — the + // resolved id is stable for the resolver's lifetime. + assert_eq!(resolver.resolve().await.unwrap(), id); +} + +#[tokio::test] +#[ignore] +async fn errors_when_the_configured_gateway_group_does_not_exist() { + let name = "adc-rust-e2e-does-not-exist"; + let resolver = GatewayGroupResolver::new(client().await, name.to_string(), &token().await); + + let error = resolver.resolve().await.unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("Gateway group \"{name}\" does not exist")), + "unexpected error message: {error}" + ); +} + +/// No dashboard needed: an `a7adm-` prefixed token short-circuits before +/// any request is made, so this exercises real (if unreachable) client +/// behavior rather than standing in for API7's own responses — not +/// gated behind `--ignored`. +#[tokio::test] +async fn an_admin_token_skips_resolution_without_making_any_request() { + let client = HttpClient::new(HttpClientConfig { + server: "http://127.0.0.1:1".to_string(), + token: "a7adm-test".to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap(); + let resolver = GatewayGroupResolver::new(client, "prod".to_string(), "a7adm-test"); + assert_eq!(resolver.resolve().await.unwrap(), None); +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_init.rs b/rust/crates/adc-backend-api7/tests/e2e_init.rs new file mode 100644 index 00000000..84480095 --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_init.rs @@ -0,0 +1,37 @@ +//! A dedicated bootstrap step, meant to run once in CI before every other +//! e2e test file (see `.github/workflows/e2e.yaml`'s `api7-rust` job): logs +//! in, rotates the admin password, activates the license, and mints a +//! token — the same dance `tests/common/mod.rs::bootstrap_token` runs +//! lazily on first use, but performed here exactly once and shared via +//! `$GITHUB_ENV`'s `TOKEN`, so every other test binary (each its own +//! process, with no state shared between them) picks up the result instead +//! of independently repeating the dance against the same live dashboard — +//! `common::token()` already prefers an externally-set `TOKEN` over running +//! `bootstrap_token()` itself. +//! +//! Running a single e2e test file locally, without this step first, still +//! works: `bootstrap_token`'s own login step tolerates an already-rotated +//! admin password (see its doc comment), so nothing here is load-bearing +//! outside CI's multi-binary run. + +use std::io::Write; + +mod common; + +#[tokio::test] +#[ignore] +async fn bootstrap_shared_token() { + let token = common::token().await; + + let Ok(github_env) = std::env::var("GITHUB_ENV") else { + // Not running in CI — nothing to share the token with. + return; + }; + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&github_env) + .unwrap_or_else(|e| panic!("opening $GITHUB_ENV ({github_env}): {e}")); + writeln!(file, "TOKEN<= Version::new(3, 2, 15) { + eprintln!("skipping: only applies below 3.2.15"); + return; + } + let backend = common::backend().await; + let consumer1_name = "consumer1"; + let mut consumer1 = + json!({ "username": consumer1_name, "plugins": { "key-auth": { "key": consumer1_name } } }); + let consumer2_name = "consumer2"; + let consumer2 = + json!({ "username": consumer2_name, "plugins": { "key-auth": { "key": consumer2_name } } }); + + sync_events( + &backend, + vec![ + create_event( + ResourceType::Consumer, + consumer1_name, + consumer1.clone(), + None, + ), + create_event( + ResourceType::Consumer, + consumer2_name, + consumer2.clone(), + None, + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let consumers = dump.consumers.as_ref().unwrap(); + assert_eq!(consumers.len(), 2); + assert_matches_object(&serde_json::to_value(&consumers[0]).unwrap(), &consumer2); + assert_matches_object(&serde_json::to_value(&consumers[1]).unwrap(), &consumer1); + + consumer1["description"] = json!("desc"); + sync_events( + &backend, + vec![update_event( + ResourceType::Consumer, + consumer1_name, + consumer1.clone(), + None, + )], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + assert_matches_object( + &serde_json::to_value(&dump.consumers.unwrap()[0]).unwrap(), + &consumer1, + ); + + sync_events( + &backend, + vec![delete_event(ResourceType::Consumer, consumer1_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + let consumers = dump.consumers.as_ref().unwrap(); + assert_eq!(consumers.len(), 1); + assert_matches_object(&serde_json::to_value(&consumers[0]).unwrap(), &consumer2); + + sync_events( + &backend, + vec![delete_event(ResourceType::Consumer, consumer2_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.consumers.is_none_or(|c| c.is_empty())); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_consumers_with_credential_support() { + if server_version() < Version::new(3, 2, 15) { + eprintln!("skipping: only applies from 3.2.15"); + return; + } + let backend = common::backend().await; + let consumer1_name = "consumer1"; + let consumer1_key = "consumer1-key"; + let mut consumer1_cred = + json!({ "name": consumer1_key, "type": "key-auth", "config": { "key": consumer1_key } }); + let mut consumer1 = json!({ "username": consumer1_name, "credentials": [consumer1_cred] }); + + sync_events( + &backend, + vec![ + create_event( + ResourceType::Consumer, + consumer1_name, + json!({ "username": consumer1_name }), + None, + ), + create_event( + ResourceType::ConsumerCredential, + consumer1_key, + consumer1_cred.clone(), + Some(consumer1_name), + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let consumers = dump.consumers.as_ref().unwrap(); + assert_eq!(consumers.len(), 1); + assert_matches_object(&serde_json::to_value(&consumers[0]).unwrap(), &consumer1); + assert_matches_object( + &serde_json::to_value(consumers[0].credentials.as_ref().unwrap()).unwrap(), + &json!([consumer1_cred]), + ); + + consumer1_cred["config"]["key"] = json!("new-key"); + consumer1["credentials"][0]["config"]["key"] = json!("new-key"); + sync_events( + &backend, + vec![update_event( + ResourceType::ConsumerCredential, + consumer1_key, + consumer1_cred.clone(), + Some(consumer1_name), + )], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let consumers = dump.consumers.as_ref().unwrap(); + assert_matches_object(&serde_json::to_value(&consumers[0]).unwrap(), &consumer1); + assert_eq!( + consumers[0].credentials.as_ref().unwrap()[0].config["key"], + json!("new-key") + ); + + sync_events( + &backend, + vec![delete_event( + ResourceType::ConsumerCredential, + consumer1_key, + Some(consumer1_name), + )], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + let consumers = dump.consumers.as_ref().unwrap(); + assert_eq!(consumers.len(), 1); + assert!(consumers[0].credentials.as_ref().unwrap().is_empty()); + + sync_events( + &backend, + vec![delete_event(ResourceType::Consumer, consumer1_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.consumers.is_none_or(|c| c.is_empty())); +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_resource_route.rs b/rust/crates/adc-backend-api7/tests/e2e_resource_route.rs new file mode 100644 index 00000000..fae501ca --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_resource_route.rs @@ -0,0 +1,114 @@ +//! Real end-to-end tests against a live API7 Enterprise dashboard, not a +//! mock. Requires `docker compose up -d` in `libs/backend-api7/e2e/assets` +//! — see `tests/common/mod.rs`'s module doc. +//! +//! Ignored by default; run with `cargo test -p adc-backend-api7 --test +//! e2e_resource_route -- --ignored --test-threads=1`. + +use adc_sdk::ResourceType; +use semver::Version; +use serde_json::json; + +mod common; +use common::{ + assert_matches_object, create_event, delete_event, dump_configuration, server_version, + sync_events, +}; + +#[tokio::test] +#[ignore] +async fn route_timeout_round_trips_through_sync_and_dump() { + let backend = common::backend().await; + let service_name = "test"; + let service = json!({ + "name": service_name, + "upstream": { "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }, + "path_prefix": "/test", + "strip_path_prefix": true, + }); + let route1_name = "route1"; + let route1 = json!({ "name": route1_name, "uris": ["/route1"], "timeout": { "connect": 111, "send": 222, "read": 333 } }); + + sync_events( + &backend, + vec![ + create_event(ResourceType::Service, service_name, service.clone(), None), + create_event( + ResourceType::Route, + route1_name, + route1.clone(), + Some(service_name), + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.as_ref().unwrap(); + assert_eq!(services.len(), 1); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service); + let routes = services[0].routes.as_ref().unwrap().http().unwrap(); + assert_eq!(routes.len(), 1); + assert_matches_object(&serde_json::to_value(&routes[0]).unwrap(), &route1); + + sync_events( + &backend, + vec![delete_event(ResourceType::Service, service_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.services.is_none_or(|s| s.is_empty())); +} + +#[tokio::test] +#[ignore] +async fn route_vars_round_trips_through_sync_and_dump() { + if server_version() < Version::new(3, 2, 16) { + eprintln!("skipping: only applies from 3.2.16"); + return; + } + let backend = common::backend().await; + let service_name = "test"; + let service = json!({ + "name": service_name, + "upstream": { "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }, + "path_prefix": "/test", + "strip_path_prefix": true, + }); + let route1_name = "route1"; + let route1 = json!({ "name": route1_name, "uris": ["/route1"], "vars": [["remote_addr", "==", "1.1.1.1"]] }); + + sync_events( + &backend, + vec![ + create_event(ResourceType::Service, service_name, service.clone(), None), + create_event( + ResourceType::Route, + route1_name, + route1.clone(), + Some(service_name), + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.as_ref().unwrap(); + assert_eq!(services.len(), 1); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service); + let routes = services[0].routes.as_ref().unwrap().http().unwrap(); + assert_eq!(routes.len(), 1); + assert_matches_object(&serde_json::to_value(&routes[0]).unwrap(), &route1); + + sync_events( + &backend, + vec![delete_event(ResourceType::Service, service_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.services.is_none_or(|s| s.is_empty())); +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_resource_service_upstream.rs b/rust/crates/adc-backend-api7/tests/e2e_resource_service_upstream.rs new file mode 100644 index 00000000..6b205b88 --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_resource_service_upstream.rs @@ -0,0 +1,106 @@ +//! Real end-to-end tests against a live API7 Enterprise dashboard, not a +//! mock. Requires `docker compose up -d` in `libs/backend-api7/e2e/assets` +//! — see `tests/common/mod.rs`'s module doc. +//! +//! Ignored by default; run with `cargo test -p adc-backend-api7 --test +//! e2e_resource_service_upstream -- --ignored --test-threads=1`. + +use adc_sdk::resources::Configuration; +use semver::Version; +use serde_json::json; + +mod common; +use common::{assert_matches_object, dump_configuration, server_version, sync_events}; + +fn local_config(json: serde_json::Value) -> Configuration { + serde_json::from_value(json).unwrap() +} + +#[tokio::test] +#[ignore] +async fn service_with_multiple_named_upstreams() { + if server_version() < Version::new(3, 5, 0) { + eprintln!("skipping: only applies from 3.5.0"); + return; + } + let backend = common::backend().await; + + let upstream_nd1_name = "nd-upstream1"; + let upstream_nd1 = json!({ "name": upstream_nd1_name, "type": "roundrobin", "scheme": "https", "nodes": [{ "host": "1.1.1.1", "port": 443, "weight": 100 }] }); + let upstream_nd2_name = "nd-upstream2"; + let upstream_nd2 = json!({ "name": upstream_nd2_name, "type": "roundrobin", "scheme": "https", "nodes": [{ "host": "1.0.0.1", "port": 443, "weight": 100 }] }); + let service_name = "test"; + let service_base = json!({ + "name": service_name, + "upstream": { "type": "roundrobin", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }, + "path_prefix": "/test", + "strip_path_prefix": true, + }); + let mut service = service_base.clone(); + service["upstreams"] = json!([upstream_nd1, upstream_nd2]); + + let remote = dump_configuration(&backend).await.unwrap(); + let events = common::diff( + &local_config(json!({ "services": [service] })), + &remote, + None, + ); + sync_events(&backend, events).await.unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.as_ref().unwrap(); + assert_eq!(services.len(), 1); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service_base); + let mut upstreams = services[0].upstreams.clone().unwrap(); + assert_eq!(upstreams.len(), 2); + upstreams.sort_by(|a, b| a.name.cmp(&b.name)); + assert_matches_object(&serde_json::to_value(&upstreams[0]).unwrap(), &upstream_nd1); + assert_matches_object(&serde_json::to_value(&upstreams[1]).unwrap(), &upstream_nd2); + + let mut new_upstream_nd1 = upstream_nd1.clone(); + new_upstream_nd1["retry_timeout"] = json!(100); + let mut service_with_updated_upstream = service_base.clone(); + service_with_updated_upstream["upstreams"] = json!([new_upstream_nd1, upstream_nd2]); + let remote = dump_configuration(&backend).await.unwrap(); + let events = common::diff( + &local_config(json!({ "services": [service_with_updated_upstream] })), + &remote, + None, + ); + sync_events(&backend, events).await.unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let mut upstreams = dump.services.unwrap()[0].upstreams.clone().unwrap(); + upstreams.sort_by(|a, b| a.name.cmp(&b.name)); + assert_matches_object( + &serde_json::to_value(&upstreams[0]).unwrap(), + &new_upstream_nd1, + ); + + let mut service_with_one_upstream = service_base.clone(); + service_with_one_upstream["upstreams"] = json!([new_upstream_nd1]); + let remote = dump_configuration(&backend).await.unwrap(); + let events = common::diff( + &local_config(json!({ "services": [service_with_one_upstream] })), + &remote, + None, + ); + sync_events(&backend, events).await.unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.as_ref().unwrap(); + assert_eq!(services.len(), 1); + let upstreams = services[0].upstreams.as_ref().unwrap(); + assert_eq!(upstreams.len(), 1); + assert_matches_object( + &serde_json::to_value(&upstreams[0]).unwrap(), + &new_upstream_nd1, + ); + + let remote = dump_configuration(&backend).await.unwrap(); + let events = common::diff(&local_config(json!({})), &remote, None); + sync_events(&backend, events).await.unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.services.is_none_or(|s| s.is_empty())); +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_stream_route_plugins.rs b/rust/crates/adc-backend-api7/tests/e2e_stream_route_plugins.rs new file mode 100644 index 00000000..4862f73d --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_stream_route_plugins.rs @@ -0,0 +1,101 @@ +//! Real end-to-end tests against a live API7 Enterprise dashboard, not a +//! mock. Requires `docker compose up -d` in `libs/backend-api7/e2e/assets` +//! — see `tests/common/mod.rs`'s module doc. +//! +//! Ignored by default; run with `cargo test -p adc-backend-api7 --test +//! e2e_stream_route_plugins -- --ignored --test-threads=1`. + +use adc_sdk::ResourceType; +use serde_json::json; + +mod common; +use common::{create_event, delete_event, dump_configuration, sync_events, update_event}; + +/// Regression: the stream route read-direction conversion used to drop +/// `plugins`, so a dumped stream route always came back without plugins, +/// and the differ could never detect a plugin removal (local empty === +/// remote empty), leaving stale plugins on the gateway. +#[tokio::test] +#[ignore] +async fn stream_route_plugin_round_trip_and_removal() { + let backend = common::backend().await; + let service_name = "stream-service"; + let service = json!({ "name": service_name, "upstream": { "scheme": "tcp", "nodes": [{ "host": "httpbin.org", "port": 80, "weight": 100 }] } }); + let stream_route_name = "stream-route"; + let plugins = json!({ "ip-restriction": { "whitelist": ["127.0.0.0/24"] } }); + let stream_route = json!({ "name": stream_route_name, "plugins": plugins }); + + sync_events( + &backend, + vec![ + create_event(ResourceType::Service, service_name, service, None), + create_event( + ResourceType::StreamRoute, + stream_route_name, + stream_route.clone(), + Some(service_name), + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let svc = dump + .services + .as_ref() + .unwrap() + .iter() + .find(|s| s.name == service_name) + .unwrap(); + let stream_routes = svc.routes.as_ref().unwrap().stream().unwrap(); + assert_eq!(stream_routes.len(), 1); + assert_eq!( + serde_json::to_value(&stream_routes[0].plugins).unwrap(), + plugins + ); + + let mut cleared = stream_route.clone(); + cleared["plugins"] = json!({}); + sync_events( + &backend, + vec![update_event( + ResourceType::StreamRoute, + stream_route_name, + cleared, + Some(service_name), + )], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let svc = dump + .services + .as_ref() + .unwrap() + .iter() + .find(|s| s.name == service_name) + .unwrap(); + let stream_routes = svc.routes.as_ref().unwrap().stream().unwrap(); + assert_eq!(stream_routes.len(), 1); + let plugins_after = stream_routes[0].plugins.clone().unwrap_or_default(); + assert!( + plugins_after.is_empty(), + "expected no plugins, got {plugins_after:?}" + ); + + sync_events( + &backend, + vec![ + delete_event( + ResourceType::StreamRoute, + stream_route_name, + Some(service_name), + ), + delete_event(ResourceType::Service, service_name, None), + ], + ) + .await + .unwrap(); +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_sync_and_dump_1.rs b/rust/crates/adc-backend-api7/tests/e2e_sync_and_dump_1.rs new file mode 100644 index 00000000..cab1a994 --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_sync_and_dump_1.rs @@ -0,0 +1,483 @@ +//! Real end-to-end tests against a live API7 Enterprise dashboard, not a +//! mock. Requires `docker compose up -d` in `libs/backend-api7/e2e/assets` +//! — see `tests/common/mod.rs`'s module doc. +//! +//! Ignored by default; run with `cargo test -p adc-backend-api7 --test +//! e2e_sync_and_dump_1 -- --ignored --test-threads=1`. + +use adc_sdk::ResourceType; +use serde_json::json; + +mod common; +use common::{ + assert_matches_object, create_event, delete_event, dump_configuration, read_asset, sync_events, + update_event, +}; + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_a_single_service() { + let backend = common::backend().await; + let upstream = json!({ "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }); + let service1_name = "service1"; + let mut service1 = json!({ "name": service1_name, "upstream": upstream }); + let service2_name = "service2"; + let service2 = json!({ "name": service2_name, "upstream": upstream }); + + sync_events( + &backend, + vec![ + create_event(ResourceType::Service, service1_name, service1.clone(), None), + create_event(ResourceType::Service, service2_name, service2.clone(), None), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let mut services = dump.services.unwrap(); + services.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(services.len(), 2); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service1); + assert_matches_object(&serde_json::to_value(&services[1]).unwrap(), &service2); + + service1["description"] = json!("desc"); + sync_events( + &backend, + vec![update_event( + ResourceType::Service, + service1_name, + service1.clone(), + None, + )], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let mut services = dump.services.unwrap(); + services.sort_by(|a, b| a.name.cmp(&b.name)); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service1); + + sync_events( + &backend, + vec![delete_event(ResourceType::Service, service1_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.unwrap(); + assert_eq!(services.len(), 1); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service2); + + sync_events( + &backend, + vec![delete_event(ResourceType::Service, service2_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.services.is_none_or(|s| s.is_empty())); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_a_service_with_routes() { + let backend = common::backend().await; + let service_name = "test"; + let service = json!({ + "name": service_name, + "upstream": { "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }, + "path_prefix": "/test", + "strip_path_prefix": true, + }); + let route1_name = "route1"; + let route1 = json!({ "name": route1_name, "uris": ["/route1", "/route1-2"], "priority": 100 }); + let route2_name = "route2"; + let route2 = json!({ "name": route2_name, "uris": ["/route2", "/route2-2"], "plugins": { "key-auth": {} } }); + + sync_events( + &backend, + vec![ + create_event(ResourceType::Service, service_name, service.clone(), None), + create_event( + ResourceType::Route, + route1_name, + route1.clone(), + Some(service_name), + ), + create_event( + ResourceType::Route, + route2_name, + route2.clone(), + Some(service_name), + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.as_ref().unwrap(); + assert_eq!(services.len(), 1); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service); + let mut routes = services[0] + .routes + .as_ref() + .unwrap() + .http() + .unwrap() + .to_vec(); + routes.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(routes.len(), 2); + assert_matches_object(&serde_json::to_value(&routes[0]).unwrap(), &route1); + assert_matches_object(&serde_json::to_value(&routes[1]).unwrap(), &route2); + + sync_events( + &backend, + vec![delete_event( + ResourceType::Route, + route1_name, + Some(service_name), + )], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.as_ref().unwrap(); + assert_eq!(services.len(), 1); + let routes = services[0].routes.as_ref().unwrap().http().unwrap(); + assert_eq!(routes.len(), 1); + assert_matches_object(&serde_json::to_value(&routes[0]).unwrap(), &route2); + + sync_events( + &backend, + vec![delete_event(ResourceType::Service, service_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.services.is_none_or(|s| s.is_empty())); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_a_service_with_stream_routes() { + let backend = common::backend().await; + let service_name = "test"; + let service = json!({ "name": service_name, "upstream": { "scheme": "tcp", "nodes": [{ "host": "1.1.1.1", "port": 853, "weight": 100 }] } }); + let route1_name = "sroute1"; + let route1 = json!({ "name": route1_name, "server_port": 5432 }); + let route2_name = "sroute2"; + let route2 = json!({ "name": route2_name, "server_port": 3306 }); + let mut service_for_sync = service.clone(); + service_for_sync["stream_routes"] = json!([]); + + sync_events( + &backend, + vec![ + create_event(ResourceType::Service, service_name, service_for_sync, None), + create_event( + ResourceType::StreamRoute, + route1_name, + route1.clone(), + Some(service_name), + ), + create_event( + ResourceType::StreamRoute, + route2_name, + route2.clone(), + Some(service_name), + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.as_ref().unwrap(); + assert_eq!(services.len(), 1); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service); + let mut stream_routes = services[0] + .routes + .as_ref() + .unwrap() + .stream() + .unwrap() + .to_vec(); + stream_routes.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(stream_routes.len(), 2); + assert_matches_object(&serde_json::to_value(&stream_routes[0]).unwrap(), &route2); + assert_matches_object(&serde_json::to_value(&stream_routes[1]).unwrap(), &route1); + + sync_events( + &backend, + vec![delete_event( + ResourceType::StreamRoute, + route1_name, + Some(service_name), + )], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + let services = dump.services.as_ref().unwrap(); + assert_eq!(services.len(), 1); + assert_matches_object(&serde_json::to_value(&services[0]).unwrap(), &service); + let stream_routes = services[0].routes.as_ref().unwrap().stream().unwrap(); + assert_eq!(stream_routes.len(), 1); + assert_matches_object(&serde_json::to_value(&stream_routes[0]).unwrap(), &route2); + + sync_events( + &backend, + vec![delete_event(ResourceType::Service, service_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.services.is_none_or(|s| s.is_empty())); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_ssls() { + let backend = common::backend().await; + let cert1 = read_asset("certs/test-ssl1.cer").trim().to_string(); + let key1 = read_asset("certs/test-ssl1.key").trim().to_string(); + let cert2 = read_asset("certs/test-ssl2.cer").trim().to_string(); + let key2 = read_asset("certs/test-ssl2.key").trim().to_string(); + + let ssl1_snis = ["ssl1-1.com", "ssl1-2.com"]; + let mut ssl1 = + json!({ "snis": ssl1_snis, "certificates": [{ "certificate": cert1, "key": key1 }] }); + let ssl2_snis = ["ssl2-1.com", "ssl2-2.com"]; + let ssl2 = + json!({ "snis": ssl2_snis, "certificates": [{ "certificate": cert2, "key": key2 }] }); + let ssl_name = |snis: &[&str]| snis.join(","); + + let mut ssl1_test = ssl1.clone(); + ssl1_test["certificates"][0] + .as_object_mut() + .unwrap() + .remove("key"); + let mut ssl2_test = ssl2.clone(); + ssl2_test["certificates"][0] + .as_object_mut() + .unwrap() + .remove("key"); + + sync_events( + &backend, + vec![ + create_event(ResourceType::Ssl, &ssl_name(&ssl1_snis), ssl1.clone(), None), + create_event(ResourceType::Ssl, &ssl_name(&ssl2_snis), ssl2.clone(), None), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let mut ssls = dump.ssls.unwrap(); + ssls.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(ssls.len(), 2); + assert_matches_object(&serde_json::to_value(&ssls[0]).unwrap(), &ssl2_test); + assert_matches_object(&serde_json::to_value(&ssls[1]).unwrap(), &ssl1_test); + + ssl1["labels"] = json!({ "test": "test" }); + sync_events( + &backend, + vec![update_event( + ResourceType::Ssl, + &ssl_name(&ssl1_snis), + ssl1.clone(), + None, + )], + ) + .await + .unwrap(); + + // Not sorted, unlike the dump above: the just-updated ssl1 comes back + // first in the dashboard's own natural order. + let dump = dump_configuration(&backend).await.unwrap(); + let ssls = dump.ssls.unwrap(); + let mut expected = ssl1.clone(); + expected["certificates"][0] + .as_object_mut() + .unwrap() + .remove("key"); + assert_matches_object(&serde_json::to_value(&ssls[0]).unwrap(), &expected); + + sync_events( + &backend, + vec![delete_event(ResourceType::Ssl, &ssl_name(&ssl1_snis), None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + let ssls = dump.ssls.unwrap(); + assert_eq!(ssls.len(), 1); + assert_matches_object(&serde_json::to_value(&ssls[0]).unwrap(), &ssl2_test); + + sync_events( + &backend, + vec![delete_event(ResourceType::Ssl, &ssl_name(&ssl2_snis), None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.ssls.is_none_or(|s| s.is_empty())); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_global_rules() { + let backend = common::backend().await; + let plugin1_name = "prometheus"; + let mut plugin1 = json!({ "prefer_name": true }); + let plugin2_name = "file-logger"; + let plugin2 = json!({ "path": "logs/file.log" }); + + sync_events( + &backend, + vec![ + create_event( + ResourceType::GlobalRule, + plugin1_name, + plugin1.clone(), + None, + ), + create_event( + ResourceType::GlobalRule, + plugin2_name, + plugin2.clone(), + None, + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let global_rules = dump.global_rules.unwrap(); + assert_eq!(global_rules.len(), 2); + assert_matches_object(&global_rules[plugin1_name], &plugin1); + assert_matches_object(&global_rules[plugin2_name], &plugin2); + + plugin1["test"] = json!("test"); + sync_events( + &backend, + vec![update_event( + ResourceType::GlobalRule, + plugin1_name, + plugin1.clone(), + None, + )], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + assert_matches_object(&dump.global_rules.unwrap()[plugin1_name], &plugin1); + + sync_events( + &backend, + vec![delete_event(ResourceType::GlobalRule, plugin1_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + let global_rules = dump.global_rules.unwrap(); + assert_eq!(global_rules.len(), 1); + assert!(!global_rules.contains_key(plugin1_name)); + assert_matches_object(&global_rules[plugin2_name], &plugin2); + + sync_events( + &backend, + vec![delete_event(ResourceType::GlobalRule, plugin2_name, None)], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.global_rules.is_none_or(|g| g.is_empty())); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_plugin_metadata() { + let backend = common::backend().await; + let plugin1_name = "http-logger"; + let mut plugin1 = json!({ "log_format": { "test": "test", "test1": "test1" } }); + let plugin2_name = "tcp-logger"; + let plugin2 = json!({ "log_format": { "test": "test", "test1": "test1" } }); + + sync_events( + &backend, + vec![ + create_event( + ResourceType::PluginMetadata, + plugin1_name, + plugin1.clone(), + None, + ), + create_event( + ResourceType::PluginMetadata, + plugin2_name, + plugin2.clone(), + None, + ), + ], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + let plugin_metadata = dump.plugin_metadata.unwrap(); + assert_eq!(plugin_metadata.len(), 2); + assert_matches_object(&plugin_metadata[plugin1_name], &plugin1); + assert_matches_object(&plugin_metadata[plugin2_name], &plugin2); + + plugin1["test"] = json!("test"); + sync_events( + &backend, + vec![update_event( + ResourceType::PluginMetadata, + plugin1_name, + plugin1.clone(), + None, + )], + ) + .await + .unwrap(); + + let dump = dump_configuration(&backend).await.unwrap(); + assert_matches_object(&dump.plugin_metadata.unwrap()[plugin1_name], &plugin1); + + sync_events( + &backend, + vec![delete_event( + ResourceType::PluginMetadata, + plugin1_name, + None, + )], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + let plugin_metadata = dump.plugin_metadata.unwrap(); + assert_eq!(plugin_metadata.len(), 1); + assert!(!plugin_metadata.contains_key(plugin1_name)); + assert_matches_object(&plugin_metadata[plugin2_name], &plugin2); + + sync_events( + &backend, + vec![delete_event( + ResourceType::PluginMetadata, + plugin2_name, + None, + )], + ) + .await + .unwrap(); + let dump = dump_configuration(&backend).await.unwrap(); + assert!(dump.plugin_metadata.is_none_or(|p| p.is_empty())); +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_sync_and_dump_2.rs b/rust/crates/adc-backend-api7/tests/e2e_sync_and_dump_2.rs new file mode 100644 index 00000000..21929c4f --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_sync_and_dump_2.rs @@ -0,0 +1,200 @@ +//! Real end-to-end tests against a live API7 Enterprise dashboard, not a +//! mock. Requires `docker compose up -d` in `libs/backend-api7/e2e/assets` +//! — see `tests/common/mod.rs`'s module doc. +//! +//! Ignored by default; run with `cargo test -p adc-backend-api7 --test +//! e2e_sync_and_dump_2 -- --ignored --test-threads=1`. + +use serde_json::json; + +mod common; +use common::{assert_matches_object, dump_configuration, load_events_fixture, sync_events}; + +/// Syncs a real, fairly large mixed-resource-type fixture and checks the +/// dump back against it, then cleans up with a matching "clean" fixture. +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_a_mixed_configuration() { + let backend = common::backend().await; + + sync_events(&backend, load_events_fixture("mixed-1.json")) + .await + .unwrap(); + + let mut dump = dump_configuration(&backend).await.unwrap(); + + let ssls = dump.ssls.as_ref().unwrap(); + assert!(!ssls.is_empty(), "expected at least one ssl, got none"); + let mut ssl0 = serde_json::to_value(&ssls[0]).unwrap(); + let cert = ssl0["certificates"][0]["certificate"] + .as_str() + .unwrap() + .trim() + .to_string(); + ssl0["certificates"][0]["certificate"] = json!(cert); + assert_matches_object( + &ssl0, + &json!({ + "type": "server", + "snis": ["test.com"], + "certificates": [{ "certificate": "-----BEGIN CERTIFICATE-----\nMIICrTCCAZUCFCcH5+jEDUhpTxEQo/pZYC91e2aYMA0GCSqGSIb3DQEBCwUAMBEx\nDzANBgNVBAMMBlJPT1RDQTAgFw0yNDAxMTgwNjAzMDNaGA8yMTIzMTIyNTA2MDMw\nM1owEzERMA8GA1UEAwwIdGVzdC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQCVkfufMRK2bckdpQ/aRfcaTxmjsv5Mb+sJdhb0QuEuXp/VgN3yzFM0\nzCmAeBZwNKpU3HZDv0tnkTx7OARYpj5Bw1ole0EfPVPKBRjlLE56tabzyd4vdLV2\nbk7jYH+H8NjGZNEYLm9MdWiB4Ulyc0+XFA0ZL5WWKOi+oSQVUibT8QK0CENFKNLP\nQjEXlbyujzRS3u6r99EEEy8+3psBA2EELq8GAjEp+jilWggBhUEpLQxCHhHeNevR\nkg5iEvhOhEVKtr5xvgolg5Wvz7GmDulIW9MCu0dIXim52H/spPwgi3yRraY1XjxU\nREyj5tcY7n7LBESkx/ODXEyCkICIPpo9AgMBAAEwDQYJKoZIhvcNAQELBQADggEB\nADBU5XvbnjaF4rpQoqdzgK6BuRvD/Ih/rh+xc+G9mm+qaHx0g3TdTqyhCvSg6aRW\njDq4Z0NILdb6wmJcunua1jjmOQMXER5y34Xfn21+dzjLN2Bl+/vZ/HyXlCjxkppG\nZAsd1H0/jmXqN1zddIThxOccmRcDEP+9GT3hba50sijFbO30Zx+ORJCoT8he6Kyw\nKdOs/yyukafoAtlpoPR+ao/kumto6w/rLfFlEsehU0dMGNgPVSxxVNtBSdxPTUBk\nD6mfqB4f//2DuAmiO+l5RmPUmumqzcYlpd+oAdy3OSnNEHbgxishZr/GI3s6DmUh\n16bgI69aQ5F+MnN3trvaufc=\n-----END CERTIFICATE-----" }], + }), + ); + + let mut services = dump.services.take().unwrap(); + assert!( + services.len() >= 2, + "expected at least two services, got {}", + services.len() + ); + services.sort_by(|a, b| a.name.cmp(&b.name)); + + assert_matches_object( + &serde_json::to_value(&services[0]).unwrap(), + &json!({ + "name": "service1", + "description": "service1 description", + "upstream": { + "name": "default", + "scheme": "http", + "type": "roundrobin", + "hash_on": "vars", + "nodes": [{ "host": "host", "port": 1100, "weight": 1100, "priority": 0 }], + "retry_timeout": 0, + "pass_host": "pass", + "checks": { + "active": { + "type": "tcp", + "timeout": 1, + "concurrency": 10, + "http_path": "/", + "healthy": { "interval": 1, "http_statuses": [200, 302], "successes": 2 }, + "unhealthy": { "interval": 1, "http_statuses": [429, 404, 500, 501, 502, 503, 504, 505], "http_failures": 5, "tcp_failures": 2, "timeouts": 3 }, + }, + }, + }, + "plugins": { + "limit-count": { + "allow_degradation": false, + "count": 2, + "key": "$consumer_name $remote_addr", + "key_type": "var_combination", + "policy": "local", + "rejected_code": 503, + "show_limit_quota_header": true, + "time_window": 60, + }, + }, + }), + ); + + let mut routes0 = services[0] + .routes + .as_ref() + .unwrap() + .http() + .unwrap() + .to_vec(); + routes0.sort_by(|a, b| a.name.cmp(&b.name)); + assert_matches_object( + &serde_json::to_value(&routes0[0]).unwrap(), + &json!({ + "uris": ["/anything"], + "name": "route1.1", + "methods": ["GET"], + "enable_websocket": false, + "plugins": { + "limit-count": { + "allow_degradation": false, + "count": 2, + "key": "$consumer_name $remote_addr", + "key_type": "var_combination", + "policy": "local", + "rejected_code": 503, + "show_limit_quota_header": true, + "time_window": 60, + }, + }, + }), + ); + assert_matches_object( + &serde_json::to_value(&routes0[1]).unwrap(), + &json!({ "uris": ["/anything"], "name": "route1.2", "methods": ["POST"], "enable_websocket": false }), + ); + + assert_matches_object( + &serde_json::to_value(&services[1]).unwrap(), + &json!({ + "name": "service2", + "description": "service2 description", + "upstream": { + "name": "default", + "scheme": "http", + "type": "roundrobin", + "hash_on": "vars", + "nodes": [{ "host": "host", "port": 1100, "weight": 1100, "priority": 0 }], + "retry_timeout": 0, + "pass_host": "pass", + }, + }), + ); + + let mut routes1 = services[1] + .routes + .as_ref() + .unwrap() + .http() + .unwrap() + .to_vec(); + routes1.sort_by(|a, b| a.name.cmp(&b.name)); + assert_matches_object( + &serde_json::to_value(&routes1[0]).unwrap(), + &json!({ + "uris": ["/getSomething"], + "name": "route2.1", + "methods": ["GET", "POST"], + "enable_websocket": false, + "plugins": { + "limit-count": { + "allow_degradation": false, + "count": 2, + "key": "$consumer_name $remote_addr", + "key_type": "var_combination", + "policy": "local", + "rejected_code": 503, + "show_limit_quota_header": true, + "time_window": 60, + }, + }, + }), + ); + assert_matches_object( + &serde_json::to_value(&routes1[1]).unwrap(), + &json!({ "uris": ["/postSomething"], "name": "route2.2", "methods": ["POST", "PUT"], "enable_websocket": false }), + ); + + assert_matches_object( + &dump.global_rules.as_ref().unwrap()["prometheus"], + &json!({ "prefer_name": false }), + ); + assert_matches_object( + &dump.plugin_metadata.as_ref().unwrap()["http-logger"], + &json!({ "log_format": { "@timestamp": "$time_iso8601", "client_ip": "$remote_addr", "host": "$host" } }), + ); + assert_matches_object( + &dump.plugin_metadata.as_ref().unwrap()["tcp-logger"], + &json!({ "log_format": { "@timestamp": "$time_iso8601", "client_ip": "$remote_addr", "host": "$host" } }), + ); + + sync_events(&backend, load_events_fixture("mixed-1-clean.json")) + .await + .unwrap(); +} + +#[test] +fn fixtures_parse_without_a_live_server() { + let create_events = load_events_fixture("mixed-1.json"); + assert!(!create_events.is_empty()); + let clean_events = load_events_fixture("mixed-1-clean.json"); + assert!(!clean_events.is_empty()); +} diff --git a/rust/crates/adc-backend-api7/tests/e2e_validate.rs b/rust/crates/adc-backend-api7/tests/e2e_validate.rs new file mode 100644 index 00000000..b8580157 --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/e2e_validate.rs @@ -0,0 +1,224 @@ +//! Real end-to-end tests against a live API7 Enterprise dashboard, not a +//! mock. Requires `docker compose up -d` in `libs/backend-api7/e2e/assets` +//! — see `tests/common/mod.rs`'s module doc. +//! +//! Ignored by default; run with `cargo test -p adc-backend-api7 --test +//! e2e_validate -- --ignored --test-threads=1`. + +use adc_sdk::resources::Configuration; +use adc_sdk::{Event, EventKind, ResourceType}; +use semver::Version; +use serde_json::json; + +mod common; +use common::{dump_configuration, server_version}; + +fn config(json: serde_json::Value) -> Configuration { + serde_json::from_value(json).unwrap() +} + +/// `Differ::diff` against an empty remote config: every resource in +/// `config` becomes a `Create` event, a shortcut for building test events +/// without hand-writing each one. +fn config_to_events(cfg: &Configuration) -> Vec { + common::diff(cfg, &config(json!({})), None) +} + +#[tokio::test] +#[ignore] +async fn reports_unsupported_below_the_minimum_version() { + if server_version() >= Version::new(3, 9, 10) { + eprintln!("skipping: only applies below 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let error = backend.validate(&[]).await.unwrap_err(); + assert!(error.to_string().contains("not supported"), "{error}"); +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_an_empty_configuration() { + if server_version() < Version::new(3, 9, 10) { + eprintln!("skipping: only applies from 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let result = backend.validate(&[]).await.unwrap(); + assert!(result.success); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_a_valid_service_and_route() { + if server_version() < Version::new(3, 9, 10) { + eprintln!("skipping: only applies from 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let cfg = config(json!({ + "services": [{ + "name": "validate-test-svc", + "upstream": { "scheme": "http", "nodes": [{ "host": "httpbin.org", "port": 80, "weight": 100 }] }, + "routes": [{ "name": "validate-test-route", "uris": ["/validate-test"], "methods": ["GET"] }], + }], + })); + + let result = backend.validate(&config_to_events(&cfg)).await.unwrap(); + assert!(result.success); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_a_valid_consumer() { + if server_version() < Version::new(3, 9, 10) { + eprintln!("skipping: only applies from 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let cfg = config( + json!({ "consumers": [{ "username": "validate-test-consumer", "plugins": { "key-auth": { "key": "test-key-123" } } }] }), + ); + + let result = backend.validate(&config_to_events(&cfg)).await.unwrap(); + assert!(result.success); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn fails_with_an_invalid_plugin_configuration() { + if server_version() < Version::new(3, 9, 10) { + eprintln!("skipping: only applies from 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let cfg = config(json!({ + "services": [{ + "name": "validate-bad-plugin-svc", + "upstream": { "scheme": "http", "nodes": [{ "host": "httpbin.org", "port": 80, "weight": 100 }] }, + // missing required fields: count, time_window + "routes": [{ "name": "validate-bad-plugin-route", "uris": ["/bad-plugin"], "plugins": { "limit-count": {} } }], + }], + })); + + let result = backend.validate(&config_to_events(&cfg)).await.unwrap(); + assert!(!result.success); + assert!(!result.errors.is_empty()); + assert_eq!(result.errors[0].resource_type, "routes"); +} + +/// A route whose `uris` isn't an array of strings fails to deserialize into +/// `adc_sdk::resources::Route` client-side, before any request reaches the +/// server — `validate` surfaces this as an `Err`, not an `Ok` result with +/// `success: false`. +#[tokio::test] +#[ignore] +async fn rejects_a_route_with_a_malformed_uri_client_side() { + if server_version() < Version::new(3, 9, 10) { + eprintln!("skipping: only applies from 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let event = Event::new( + ResourceType::Route, + EventKind::Create { + new_value: json!({ "name": "validate-bad-route", "uris": [123] }), + }, + adc_sdk::utils::generate_id("validate-bad-route"), + "validate-bad-route", + ); + + let error = backend.validate(&[event]).await.unwrap_err(); + assert!( + matches!(error, adc_sdk::BackendError::Serialization(_)), + "{error:?}" + ); +} + +#[tokio::test] +#[ignore] +async fn collects_multiple_errors() { + if server_version() < Version::new(3, 9, 10) { + eprintln!("skipping: only applies from 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let cfg = config(json!({ + "services": [{ + "name": "validate-multi-err-svc", + "upstream": { "scheme": "http", "nodes": [{ "host": "httpbin.org", "port": 80, "weight": 100 }] }, + "routes": [ + { "name": "validate-multi-err-route1", "uris": ["/multi-err-1"], "plugins": { "limit-count": {} } }, + { "name": "validate-multi-err-route2", "uris": ["/multi-err-2"], "plugins": { "limit-count": {} } }, + ], + }], + })); + + let result = backend.validate(&config_to_events(&cfg)).await.unwrap(); + assert!(!result.success); + assert!(result.errors.len() >= 2); +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_mixed_resource_types() { + if server_version() < Version::new(3, 9, 10) { + eprintln!("skipping: only applies from 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let cfg = config(json!({ + "services": [{ + "name": "validate-mixed-svc", + "upstream": { "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] }, + "routes": [{ "name": "validate-mixed-route", "uris": ["/mixed-test"], "methods": ["GET", "POST"] }], + }], + "consumers": [{ "username": "validate-mixed-consumer", "plugins": { "key-auth": { "key": "mixed-key-456" } } }], + "global_rules": { "prometheus": { "prefer_name": false } }, + })); + + let result = backend.validate(&config_to_events(&cfg)).await.unwrap(); + assert!(result.success); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn is_a_dry_run_with_no_side_effects_on_the_server() { + if server_version() < Version::new(3, 9, 10) { + eprintln!("skipping: only applies from 3.9.10"); + return; + } + use adc_sdk::Backend as _; + let backend = common::backend().await; + let service_name = "validate-dryrun-svc"; + let cfg = config(json!({ + "services": [{ + "name": service_name, + "upstream": { "scheme": "http", "nodes": [{ "host": "httpbin.org", "port": 80, "weight": 100 }] }, + "routes": [{ "name": "validate-dryrun-route", "uris": ["/dryrun-test"] }], + }], + })); + + let result = backend.validate(&config_to_events(&cfg)).await.unwrap(); + assert!(result.success); + + let dump = dump_configuration(&backend).await.unwrap(); + assert!( + dump.services + .unwrap_or_default() + .iter() + .all(|s| s.name != service_name) + ); +} diff --git a/rust/crates/adc-backend-api7/tests/timeout.rs b/rust/crates/adc-backend-api7/tests/timeout.rs new file mode 100644 index 00000000..bb131d85 --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/timeout.rs @@ -0,0 +1,124 @@ +//! Not a real-dashboard e2e test — a local, deliberately slow HTTP server +//! (same pattern as `adc-backend-core/tests/http_client.rs`) to check that +//! a timed-out request surfaces a message identifying which request it +//! was. Doesn't need `docker compose up`, so it isn't `#[ignore]`d. +//! +//! `sync` resolves only the gateway_group id before handing off to the +//! operator (`Operator` doesn't use a version or default-value at all), so +//! which request's timeout surfaces below is always deterministic: +//! `/api/gateway_groups` specifically. + +use std::time::Duration; + +use adc_backend_core::{HttpClient, HttpClientConfig, TlsConfig}; +use adc_sdk::{Backend as _, BackendSyncOptions, Event, EventKind, ResourceType}; +use axum::Router; +use axum::routing::any; +use serde_json::json; +use tokio::net::TcpListener; + +/// Every path on this server hangs for far longer than any timeout these +/// tests configure, so every request reliably times out. +async fn spawn_slow_server() -> String { + let router = Router::new().fallback(any(|| async { + tokio::time::sleep(Duration::from_secs(5)).await; + axum::Json(json!({ "value": {} })) + })); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") +} + +fn backend(server: &str, timeout: Duration) -> adc_backend_api7::Backend { + let client = HttpClient::new(HttpClientConfig { + server: server.to_string(), + token: "test-token".to_string(), + timeout: Some(timeout), + tls: TlsConfig::default(), + }) + .unwrap(); + adc_backend_api7::Backend::new( + client, + "default".to_string(), + "test-token", + adc_backend_core::ResourceFilter::default(), + ) +} + +#[tokio::test] +async fn ping_timeout_names_the_request_that_timed_out() { + let server = spawn_slow_server().await; + let backend = backend(&server, Duration::from_millis(10)); + + let error = backend.ping().await.unwrap_err(); + let message = error.to_string(); + assert!( + message.contains(&format!("{server}/api/gateway_groups")), + "{message}" + ); + assert!(message.contains("timed out"), "{message}"); +} + +#[tokio::test] +async fn version_timeout_names_the_request_that_timed_out() { + let server = spawn_slow_server().await; + let backend = backend(&server, Duration::from_millis(10)); + + let error = backend.version().await.unwrap_err(); + let message = error.to_string(); + assert!( + message.contains(&format!("{server}/api/version")), + "{message}" + ); + assert!(message.contains("timed out"), "{message}"); +} + +#[tokio::test] +async fn dump_timeout_names_the_request_that_timed_out() { + let server = spawn_slow_server().await; + let backend = backend(&server, Duration::from_millis(10)); + + // `dump` resolves the version before anything else, so this is the + // request that actually times out first. + let error = backend.dump().await.unwrap_err(); + let message = error.to_string(); + assert!( + message.contains(&format!("{server}/api/version")), + "{message}" + ); + assert!(message.contains("timed out"), "{message}"); +} + +#[tokio::test] +async fn sync_timeout_names_the_request_that_timed_out() { + let server = spawn_slow_server().await; + let backend = backend(&server, Duration::from_millis(10)); + + let event = Event::new( + ResourceType::Consumer, + EventKind::Create { + new_value: json!({ "username": "test", "plugins": {} }), + }, + "test-consumer", + "test-consumer", + ); + let error = backend + .sync( + vec![event], + BackendSyncOptions { + exit_on_failure: Some(true), + ..Default::default() + }, + ) + .await + .unwrap_err(); + let message = error.to_string(); + assert!( + message.contains(&format!("{server}/api/gateway_groups")), + "{message}" + ); + assert!(message.contains("timed out"), "{message}"); +} diff --git a/rust/crates/adc-backend-api7/tests/validator.rs b/rust/crates/adc-backend-api7/tests/validator.rs new file mode 100644 index 00000000..8599aae2 --- /dev/null +++ b/rust/crates/adc-backend-api7/tests/validator.rs @@ -0,0 +1,248 @@ +//! Not a real-dashboard e2e test: a local HTTP server (same pattern as +//! `adc-backend-core/tests/http_client.rs`) standing in for one specific, +//! canned `/apisix/admin/configs/validate` response — this exercises the +//! `Validator`'s own request-building and error-to-`Event` mapping logic, +//! not real gateway validation behavior, so it doesn't need `docker +//! compose up` or `#[ignore]`. + +use adc_backend_api7::tests::Validator; +use adc_backend_core::{HttpClient, HttpClientConfig, TlsConfig}; +use adc_sdk::utils::generate_id; +use adc_sdk::{Event, EventKind, ResourceType}; +use axum::Json; +use axum::extract::State; +use axum::routing::post; +use semver::Version; +use serde_json::{Value, json}; +use tokio::net::TcpListener; + +async fn spawn_validate_server(status: u16, body: Value) -> String { + let status = axum::http::StatusCode::from_u16(status).unwrap(); + let router = axum::Router::new() + .route( + "/apisix/admin/configs/validate", + post(move |State(body): State| async move { (status, Json(body)) }), + ) + .with_state(body); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") +} + +fn client(server: String) -> HttpClient { + HttpClient::new(HttpClientConfig { + server, + token: "test-token".to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap() +} + +/// A minimal but *structurally valid* `adc_sdk::resources::X` payload for +/// each resource type this file exercises — `Validator::build_request` +/// deserializes into the strongly-typed ADC shape before transforming, so a +/// genuinely incomplete object fails before ever reaching the mocked +/// server. +fn create_event( + resource_type: ResourceType, + resource_name: &str, + parent_id: Option<&str>, +) -> Event { + let new_value = match resource_type { + ResourceType::Consumer => json!({ "username": resource_name }), + ResourceType::Route => json!({ "name": resource_name, "uris": [] }), + _ => json!({ "name": resource_name }), + }; + let mut event = Event::new( + resource_type, + EventKind::Create { new_value }, + generate_id(resource_name), + resource_name, + ); + event.parent_id = parent_id.map(String::from); + event +} + +#[tokio::test] +async fn embeds_the_event_in_validation_errors_for_routes() { + let server = spawn_validate_server( + 400, + json!({ + "error_msg": "Configuration validation failed", + "errors": [{ + "resource_type": "routes", + "index": 0, + "error": "does not match schema due to: Error at \"/methods/0\": value is not one of the allowed values", + }], + }), + ) + .await; + let parent_id = generate_id("httpbin.org"); + let events = vec![ + create_event(ResourceType::Service, "httpbin.org", None), + create_event(ResourceType::Route, "get-anything", Some(&parent_id)), + ]; + + let validator = Validator::new( + client(server), + Version::new(3, 10, 0), + Some("default".to_string()), + ); + let result = validator.validate(&events).await.unwrap(); + + assert!(!result.success); + assert_eq!( + result.error_message.as_deref(), + Some("Configuration validation failed") + ); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].resource_type, "routes"); + assert_eq!( + result.errors[0].resource_name.as_deref(), + Some("get-anything") + ); + let event = result.errors[0] + .event + .as_ref() + .expect("event should be embedded"); + assert_eq!(event.resource_type, ResourceType::Route); + assert_eq!(event.resource_name, "get-anything"); + assert_eq!(event.parent_id.as_deref(), Some(parent_id.as_str())); + assert_eq!(event.event_type(), adc_sdk::EventType::Create); + assert_eq!( + event.kind.new_value(), + Some(&json!({ "name": "get-anything", "uris": [] })) + ); +} + +#[tokio::test] +async fn embeds_the_event_in_validation_errors_for_services() { + let server = spawn_validate_server( + 400, + json!({ + "error_msg": "Configuration validation failed", + "errors": [{ "resource_type": "services", "index": 0, "error": "does not match schema due to: plugins validation failed" }], + }), + ) + .await; + let events = vec![ + create_event(ResourceType::Service, "httpbin.org", None), + create_event(ResourceType::Route, "get-anything", Some("some-parent")), + ]; + + let validator = Validator::new( + client(server), + Version::new(3, 10, 0), + Some("default".to_string()), + ); + let result = validator.validate(&events).await.unwrap(); + + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].resource_type, "services"); + assert_eq!( + result.errors[0].resource_name.as_deref(), + Some("httpbin.org") + ); + let event = result.errors[0] + .event + .as_ref() + .expect("event should be embedded"); + assert_eq!(event.resource_type, ResourceType::Service); + assert_eq!(event.resource_name, "httpbin.org"); +} + +#[tokio::test] +async fn succeeds_when_there_are_no_validation_errors() { + let server = spawn_validate_server(200, json!({})).await; + let events = vec![create_event(ResourceType::Service, "httpbin.org", None)]; + + let validator = Validator::new( + client(server), + Version::new(3, 10, 0), + Some("default".to_string()), + ); + let result = validator.validate(&events).await.unwrap(); + + assert!(result.success); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +async fn handles_multiple_errors_with_correct_event_mapping() { + let server = spawn_validate_server( + 400, + json!({ + "error_msg": "Configuration validation failed", + "errors": [ + { "resource_type": "routes", "index": 0, "error": "error on route-a" }, + { "resource_type": "routes", "index": 1, "error": "error on route-b" }, + { "resource_type": "consumers", "index": 0, "error": "error on user1" }, + ], + }), + ) + .await; + let parent_id = generate_id("my-service"); + let events = vec![ + create_event(ResourceType::Service, "my-service", None), + create_event(ResourceType::Route, "route-a", Some(&parent_id)), + create_event(ResourceType::Route, "route-b", Some(&parent_id)), + create_event(ResourceType::Consumer, "user1", None), + ]; + + let validator = Validator::new( + client(server), + Version::new(3, 10, 0), + Some("default".to_string()), + ); + let result = validator.validate(&events).await.unwrap(); + + assert_eq!(result.errors.len(), 3); + + assert_eq!(result.errors[0].resource_name.as_deref(), Some("route-a")); + let event = result.errors[0].event.as_ref().unwrap(); + assert_eq!(event.resource_name, "route-a"); + assert_eq!(event.parent_id.as_deref(), Some(parent_id.as_str())); + + assert_eq!(result.errors[1].resource_name.as_deref(), Some("route-b")); + let event = result.errors[1].event.as_ref().unwrap(); + assert_eq!(event.resource_name, "route-b"); + assert_eq!(event.parent_id.as_deref(), Some(parent_id.as_str())); + + assert_eq!(result.errors[2].resource_name.as_deref(), Some("user1")); + let event = result.errors[2].event.as_ref().unwrap(); + assert_eq!(event.resource_name, "user1"); + assert_eq!(event.parent_id, None); +} + +#[tokio::test] +async fn handles_an_error_with_no_matching_event_index_gracefully() { + let server = spawn_validate_server(400, json!({ "errors": [{ "resource_type": "unknown_type", "index": 0, "error": "some error" }] })).await; + let events = vec![create_event(ResourceType::Service, "my-service", None)]; + + let validator = Validator::new( + client(server), + Version::new(3, 10, 0), + Some("default".to_string()), + ); + let result = validator.validate(&events).await.unwrap(); + + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].event.is_none()); +} + +#[tokio::test] +async fn rejects_up_front_when_the_version_is_below_the_minimum() { + // No server needed: the version check runs before any request is sent. + let client = client("http://127.0.0.1:1".to_string()); + let events = vec![create_event(ResourceType::Service, "my-service", None)]; + + let validator = Validator::new(client, Version::new(3, 9, 9), Some("default".to_string())); + let error = validator.validate(&events).await.unwrap_err(); + + assert!(error.to_string().contains("not supported"), "{error}"); +} diff --git a/rust/crates/adc-backend-apisix/src/backend.rs b/rust/crates/adc-backend-apisix/src/backend.rs index 4cd7815b..0424931e 100644 --- a/rust/crates/adc-backend-apisix/src/backend.rs +++ b/rust/crates/adc-backend-apisix/src/backend.rs @@ -1,7 +1,4 @@ -//! Ties the fetcher, operator, and validator together behind -//! `adc_sdk::Backend` — the interface the CLI actually dispatches through. - -use adc_backend_core::{HttpClient, Method}; +use adc_backend_core::{HttpClient, Method, ResourceFilter}; use adc_sdk::resources::Configuration; use adc_sdk::{ BackendError, BackendMetadata, BackendSyncOptions, BackendSyncResult, BackendValidateResult, @@ -17,13 +14,15 @@ use crate::validator::Validator; pub struct Backend { client: HttpClient, + filter: ResourceFilter, version: OnceCell, } impl Backend { - pub fn new(client: HttpClient) -> Self { + pub fn new(client: HttpClient, filter: ResourceFilter) -> Self { Self { client: client.with_log_scope(vec!["APISIX".to_string()]), + filter, version: OnceCell::new(), } } @@ -88,7 +87,9 @@ impl adc_sdk::Backend for Backend { async fn dump(&self) -> Result { let version = self.resolved_version().await?; - Fetcher::new(self.client.clone(), version).dump().await + Fetcher::new(self.client.clone(), version, self.filter.clone()) + .dump() + .await } async fn sync( diff --git a/rust/crates/adc-backend-apisix/src/fetcher.rs b/rust/crates/adc-backend-apisix/src/fetcher.rs index ae859361..7e5d9c72 100644 --- a/rust/crates/adc-backend-apisix/src/fetcher.rs +++ b/rust/crates/adc-backend-apisix/src/fetcher.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use adc_backend_core::{HttpClient, Method, concurrent_map_until_err}; +use adc_backend_core::{HttpClient, Method, ResourceFilter, concurrent_map_until_err}; use adc_sdk::resources::{self as adc, Configuration, LabelValue, Plugins}; use adc_sdk::{BackendError, ResourceType}; use indexmap::IndexMap; @@ -23,21 +23,34 @@ const CREDENTIAL_FETCH_CONCURRENCY: usize = 16; pub struct Fetcher { client: HttpClient, version: Version, + filter: ResourceFilter, } impl Fetcher { - pub fn new(client: HttpClient, version: Version) -> Self { - Self { client, version } + pub fn new(client: HttpClient, version: Version, filter: ResourceFilter) -> Self { + Self { + client, + version, + filter, + } } async fn list( &self, resource_type: ResourceType, ) -> Result, BackendError> { - let api_name = resource_type_to_api_name(resource_type) - .ok_or_else(|| BackendError::Unsupported(format!("{resource_type:?} has no top-level admin API collection")))?; + if self.filter.is_skip(resource_type) { + return Ok(Vec::new()); + } + let api_name = resource_type_to_api_name(resource_type).ok_or_else(|| { + BackendError::Unsupported(format!( + "{resource_type:?} has no top-level admin API collection" + )) + })?; let path = format!("/apisix/admin/{api_name}"); - let builder = self.client.request(Method::GET, &path)?; + let builder = self + .filter + .attach_label_selector(self.client.request(Method::GET, &path)?); let body: typing::ListResponse = self.client.send_json(builder).await?; Ok(body.list.into_iter().map(|item| item.value).collect()) } @@ -80,11 +93,17 @@ impl Fetcher { /// (`/apisix/plugin_metadata/http-logger`), so it's extracted from /// `ListItem::key` rather than `ListItem::value`. pub async fn list_plugin_metadata(&self) -> Result { + if self.filter.is_skip(ResourceType::PluginMetadata) { + return Ok(Plugins::new()); + } let path = format!( "/apisix/admin/{}", - resource_type_to_api_name(ResourceType::PluginMetadata).expect("PluginMetadata always has an api name") + resource_type_to_api_name(ResourceType::PluginMetadata) + .expect("PluginMetadata always has an api name") ); - let builder = self.client.request(Method::GET, &path)?; + let builder = self + .filter + .attach_label_selector(self.client.request(Method::GET, &path)?); let body: typing::ListResponse = self.client.send_json(builder).await?; let mut merged = Plugins::new(); @@ -102,9 +121,13 @@ impl Fetcher { /// (authentication/authorization failure, a 5xx) is a real error, same /// as [`Fetcher::list`]. pub async fn list_stream_routes(&self) -> Result, BackendError> { - let builder = self - .client - .request(Method::GET, "/apisix/admin/stream_routes")?; + if self.filter.is_skip(ResourceType::StreamRoute) { + return Ok(Vec::new()); + } + let builder = self.filter.attach_label_selector( + self.client + .request(Method::GET, "/apisix/admin/stream_routes")?, + ); let response = self.client.execute(builder).await?; if response.status().as_u16() == 404 { return Ok(Vec::new()); @@ -131,7 +154,10 @@ impl Fetcher { return Ok(consumers); } - concurrent_map_until_err(consumers, Some(CREDENTIAL_FETCH_CONCURRENCY), |consumer| self.with_credentials(consumer)).await + concurrent_map_until_err(consumers, Some(CREDENTIAL_FETCH_CONCURRENCY), |consumer| { + self.with_credentials(consumer) + }) + .await } async fn with_credentials( @@ -144,7 +170,10 @@ impl Fetcher { // of N concurrent credential fetches. let response = self .client - .execute_described(builder, &format!("Get credentials of consumer \"{}\"", consumer.username)) + .execute_described( + builder, + &format!("Get credentials of consumer \"{}\"", consumer.username), + ) .await?; if response.status().as_u16() == 404 { return Ok(consumer); @@ -267,7 +296,7 @@ impl Fetcher { // Step 6: assemble the final Configuration — everything not nested // under a service converts independently. - Ok(Configuration { + let mut configuration = Configuration { services: (!services.is_empty()).then(|| services.into_values().collect()), ssls: (!ssls.is_empty()) .then(|| { @@ -282,7 +311,18 @@ impl Fetcher { consumer_groups: None, // apisix's fetcher doesn't fetch consumer groups at all — see `crate::transformer`'s doc comment. global_rules: (!global_rules.is_empty()).then_some(global_rules), plugin_metadata: (!plugin_metadata.is_empty()).then_some(plugin_metadata), - }) + }; + + // Step 7: re-check every resource against the label selector + // client-side. The `labels[key]=value` query params attached to + // each request above (`Fetcher::list`) are a request to the server + // to narrow its response, not a guarantee that it did — nothing + // here can tell whether an unrecognized query param was silently + // ignored, so the result can't be trusted as filtered until this + // runs. + self.filter.filter_configuration(&mut configuration); + + Ok(configuration) } } @@ -352,3 +392,182 @@ fn index_upstreams( Ok((by_id, named_by_service)) } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use adc_backend_core::{HttpClientConfig, TlsConfig}; + + use super::*; + + /// Never resolves a connection, so any request made through it fails + /// immediately — used below to prove `is_skip` short-circuits *before* + /// a request is built, not just that the response gets discarded. + fn unreachable_client() -> HttpClient { + HttpClient::new(HttpClientConfig { + server: "http://0.0.0.0".to_string(), + token: "test-token".to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap() + } + + #[tokio::test] + async fn dump_makes_no_request_at_all_once_every_resource_type_is_excluded() { + let exclude = HashSet::from([ + ResourceType::Service, + ResourceType::Route, + ResourceType::Upstream, + ResourceType::Ssl, + ResourceType::PluginConfig, + ResourceType::GlobalRule, + ResourceType::PluginMetadata, + ResourceType::StreamRoute, + ResourceType::Consumer, + ]); + let filter = ResourceFilter { + include: HashSet::new(), + exclude, + label_selector: HashMap::new(), + }; + let fetcher = Fetcher::new(unreachable_client(), Version::new(999, 999, 999), filter); + + let configuration = fetcher.dump().await.unwrap(); + assert_eq!( + configuration, + Configuration { + services: None, + ssls: None, + consumers: None, + consumer_groups: None, + global_rules: None, + plugin_metadata: None, + } + ); + } + + /// A local server that records every path it's asked for and answers + /// generically (an empty `list` satisfies every resource type's + /// envelope without needing per-type fixtures) — used to prove a + /// specific endpoint was *never requested*, not just that its response + /// was discarded. + async fn spawn_recording_server() -> (String, std::sync::Arc>>) { + let seen = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())); + let seen_for_handler = seen.clone(); + let router = axum::Router::new().fallback(axum::routing::any( + move |request: axum::extract::Request| { + let seen = seen_for_handler.clone(); + async move { + seen.lock().await.push(request.uri().path().to_string()); + axum::Json(serde_json::json!({ "list": [] })) + } + }, + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + (format!("http://{addr}"), seen) + } + + #[tokio::test] + async fn excluding_a_resource_type_means_its_endpoint_is_never_requested() { + let (server, seen) = spawn_recording_server().await; + let client = HttpClient::new(HttpClientConfig { + server, + token: "test-token".to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap(); + let filter = ResourceFilter { + include: HashSet::new(), + exclude: HashSet::from([ResourceType::Service]), + label_selector: HashMap::new(), + }; + let fetcher = Fetcher::new(client, Version::new(999, 999, 999), filter); + + fetcher.dump().await.unwrap(); + + let seen = seen.lock().await; + assert!( + !seen.iter().any(|path| path == "/apisix/admin/services"), + "{seen:?}" + ); + assert!( + seen.iter().any(|path| path == "/apisix/admin/routes"), + "{seen:?}" + ); + } + + #[test] + fn a_top_level_collection_request_carries_the_label_selector() { + let filter = ResourceFilter { + include: HashSet::new(), + exclude: HashSet::new(), + label_selector: HashMap::from([("env".to_string(), "prod".to_string())]), + }; + let fetcher = Fetcher::new(unreachable_client(), Version::new(999, 999, 999), filter); + + let builder = fetcher.filter.attach_label_selector( + fetcher + .client + .request(Method::GET, "/apisix/admin/services") + .unwrap(), + ); + let request = builder.build().unwrap(); + assert_eq!(request.url().query(), Some("labels%5Benv%5D=prod")); + } + + /// A server that ignores the `labels[...]` query param entirely and + /// always returns every service — standing in for an admin API that + /// doesn't actually support server-side label filtering (unverified for + /// APISIX; the query param is sent on a best-effort basis). + async fn spawn_server_that_ignores_the_label_query() -> String { + let router = axum::Router::new() + .route( + "/apisix/admin/services", + axum::routing::get(|| async { + axum::Json(serde_json::json!({ + "list": [ + {"key": "/apisix/admin/services/1", "value": {"id": "1", "name": "matches", "labels": {"env": "prod"}}}, + {"key": "/apisix/admin/services/2", "value": {"id": "2", "name": "no-match", "labels": {"env": "dev"}}}, + ] + })) + }), + ) + .fallback(axum::routing::any(|| async { axum::Json(serde_json::json!({ "list": [] })) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn dump_filters_by_label_client_side_even_when_the_server_ignores_the_query() { + let server = spawn_server_that_ignores_the_label_query().await; + let client = HttpClient::new(HttpClientConfig { + server, + token: "test-token".to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap(); + let filter = ResourceFilter { + include: HashSet::from([ResourceType::Service]), + exclude: HashSet::new(), + label_selector: HashMap::from([("env".to_string(), "prod".to_string())]), + }; + let fetcher = Fetcher::new(client, Version::new(999, 999, 999), filter); + + let configuration = fetcher.dump().await.unwrap(); + + let names: Vec = configuration.services.unwrap().into_iter().map(|s| s.name).collect(); + assert_eq!(names, vec!["matches"]); + } +} diff --git a/rust/crates/adc-backend-apisix/src/lib.rs b/rust/crates/adc-backend-apisix/src/lib.rs index 284d624d..dfd63dd7 100644 --- a/rust/crates/adc-backend-apisix/src/lib.rs +++ b/rust/crates/adc-backend-apisix/src/lib.rs @@ -1,10 +1,3 @@ -//! The Apache APISIX gateway integration. The supported public API is just -//! [`Backend`] — the fetcher, operator, and validator it's built from are -//! internal orchestration pieces, not things a real consumer should reach -//! for directly (call `Backend::dump`/`sync`/`validate` instead). They're -//! still reachable via [`tests`] for this crate's own test suite and for -//! other crates' e2e tests that want to exercise one piece in isolation. - mod backend; mod fetcher; mod operator; @@ -15,11 +8,6 @@ mod validator; pub use backend::Backend; -/// Internal building blocks, exposed only for tests — see the crate-level -/// doc comment. Not part of the supported API: gated behind the -/// `test-utils` feature (on by default only via this crate's own -/// self-referencing dev-dependency), so it doesn't leak into a normal -/// build's public surface. #[cfg(feature = "test-utils")] #[doc(hidden)] pub mod tests { diff --git a/rust/crates/adc-backend-apisix/src/transformer.rs b/rust/crates/adc-backend-apisix/src/transformer.rs index a84e421a..2a69a74b 100644 --- a/rust/crates/adc-backend-apisix/src/transformer.rs +++ b/rust/crates/adc-backend-apisix/src/transformer.rs @@ -173,7 +173,7 @@ fn parse_discovery_map_nodes( host, port, weight, - priority: 0.0, + priority: 0, metadata: None, }) }) diff --git a/rust/crates/adc-backend-apisix/src/typing.rs b/rust/crates/adc-backend-apisix/src/typing.rs index 5b2dbe0a..74cdacdc 100644 --- a/rust/crates/adc-backend-apisix/src/typing.rs +++ b/rust/crates/adc-backend-apisix/src/typing.rs @@ -313,7 +313,11 @@ pub struct Upstream { pub upstream_host: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retries: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "adc_sdk::resources::serialize_optional_whole_number_as_integer" + )] pub retry_timeout: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, diff --git a/rust/crates/adc-backend-apisix/src/utils.rs b/rust/crates/adc-backend-apisix/src/utils.rs index 5c672964..ebf522d2 100644 --- a/rust/crates/adc-backend-apisix/src/utils.rs +++ b/rust/crates/adc-backend-apisix/src/utils.rs @@ -1,16 +1,14 @@ +use adc_backend_core::resource_type_collection_name; use adc_sdk::ResourceType; -/// Maps a resource type onto its admin API collection path segment (e.g. -/// `Service` -> `services`). Plugin metadata's collection isn't pluralized. -/// `ConsumerCredential` has no such collection at all — it lives nested -/// under a specific consumer's own path, which callers build themselves -/// (see `operator::main_path`'s dedicated branch) — so it's `None` here -/// rather than a made-up path fragment a new caller could accidentally use -/// as-is. +/// `ConsumerCredential` has no top-level admin API collection at all — it +/// lives nested under a specific consumer's own path, which callers build +/// themselves (see `operator::main_path`'s dedicated branch) — so it's +/// `None` here rather than a made-up path fragment a new caller could +/// accidentally use as-is. pub fn resource_type_to_api_name(resource_type: ResourceType) -> Option { match resource_type { - ResourceType::PluginMetadata => Some(resource_type.as_str().to_string()), ResourceType::ConsumerCredential => None, - _ => Some(format!("{}s", resource_type.as_str())), + other => Some(resource_type_collection_name(other)), } } diff --git a/rust/crates/adc-backend-apisix/tests/common/mod.rs b/rust/crates/adc-backend-apisix/tests/common/mod.rs index ef051065..af957e81 100644 --- a/rust/crates/adc-backend-apisix/tests/common/mod.rs +++ b/rust/crates/adc-backend-apisix/tests/common/mod.rs @@ -12,11 +12,17 @@ pub const SERVER: &str = "http://localhost:19180"; pub const TOKEN: &str = "edd1c9f034335f136f87ad84b625c8f1"; pub fn client() -> HttpClient { - HttpClient::new(HttpClientConfig { server: SERVER.to_string(), token: TOKEN.to_string(), timeout: None, tls: TlsConfig::default() }).unwrap() + HttpClient::new(HttpClientConfig { + server: SERVER.to_string(), + token: TOKEN.to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap() } pub fn backend() -> ApisixBackend { - ApisixBackend::new(client()) + ApisixBackend::new(client(), adc_backend_core::ResourceFilter::default()) } /// The CI matrix runs this suite against every supported APISIX release @@ -28,7 +34,8 @@ pub fn backend() -> ApisixBackend { /// silently falling back the same way "unset" does. pub fn apisix_version() -> semver::Version { match std::env::var("BACKEND_APISIX_VERSION") { - Ok(v) => semver::Version::parse(&v).unwrap_or_else(|e| panic!("BACKEND_APISIX_VERSION={v:?} is not a valid semver: {e}")), + Ok(v) => semver::Version::parse(&v) + .unwrap_or_else(|e| panic!("BACKEND_APISIX_VERSION={v:?} is not a valid semver: {e}")), Err(_) => semver::Version::new(999, 999, 999), } } diff --git a/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs b/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs index e704ce83..22ae5c98 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs @@ -17,7 +17,9 @@ mod common; use common::{apisix_version, client}; fn read_asset(name: &str) -> String { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../libs/backend-apisix/e2e/assets").join(name); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../libs/backend-apisix/e2e/assets") + .join(name); std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) } @@ -26,7 +28,11 @@ fn operator() -> Operator { } fn fetcher() -> Fetcher { - Fetcher::new(client(), apisix_version()) + Fetcher::new( + client(), + apisix_version(), + adc_backend_core::ResourceFilter::default(), + ) } fn create(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { @@ -34,7 +40,14 @@ fn create(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { } fn delete(rt: ResourceType, id: &str) -> Event { - Event::new(rt, EventKind::Delete { old_value: json!({}) }, id, id) + Event::new( + rt, + EventKind::Delete { + old_value: json!({}), + }, + id, + id, + ) } fn delete_child(rt: ResourceType, id: &str, parent_id: &str) -> Event { @@ -44,9 +57,16 @@ fn delete_child(rt: ResourceType, id: &str, parent_id: &str) -> Event { } async fn sync_ok(events: Vec) { - let results = operator().sync(events, BackendSyncOptions::default()).await.unwrap(); + let results = operator() + .sync(events, BackendSyncOptions::default()) + .await + .unwrap(); for result in &results { - assert!(result.success, "sync failed for {:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + assert!( + result.success, + "sync failed for {:?} {}: {:?}", + result.event.resource_type, result.event.resource_id, result.error + ); } } @@ -92,7 +112,10 @@ impl Drop for Cleanup { Ok(Ok(results)) => { for result in &results { if !result.success { - eprintln!("cleanup failed for {:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + eprintln!( + "cleanup failed for {:?} {}: {:?}", + result.event.resource_type, result.event.resource_id, result.error + ); } } } @@ -109,7 +132,11 @@ async fn syncs_a_service_with_upstream_and_route_then_reads_them_back() { let service_id = "e2e-svc-1"; let route_id = "e2e-route-1"; - let mut route_event = create(ResourceType::Route, route_id, json!({ "name": "e2e route", "uris": ["/e2e-1"] })); + let mut route_event = create( + ResourceType::Route, + route_id, + json!({ "name": "e2e route", "uris": ["/e2e-1"] }), + ); route_event.parent_id = Some(service_id.to_string()); sync_ok(vec![ @@ -121,29 +148,50 @@ async fn syncs_a_service_with_upstream_and_route_then_reads_them_back() { cleanup.push(delete(ResourceType::Service, service_id)); let services = fetcher().list_services().await.unwrap(); - let service = services.iter().find(|s| s.id == service_id).expect("service was not written"); + let service = services + .iter() + .find(|s| s.id == service_id) + .expect("service was not written"); assert_eq!(service.name.as_deref(), Some("e2e service")); assert_eq!(service.upstream_id.as_deref(), Some(service_id)); let upstreams = fetcher().list_upstreams().await.unwrap(); - let upstream = upstreams.iter().find(|u| u.id.as_deref() == Some(service_id)).expect("upstream was not written"); + let upstream = upstreams + .iter() + .find(|u| u.id.as_deref() == Some(service_id)) + .expect("upstream was not written"); let adc_upstream: adc_sdk::resources::Upstream = upstream.clone().try_into().unwrap(); let nodes = adc_upstream.nodes.unwrap(); assert_eq!(nodes[0].host, "127.0.0.1"); assert_eq!(nodes[0].port, 1980); let routes = fetcher().list_routes().await.unwrap(); - let route = routes.iter().find(|r| r.id == route_id).expect("route was not written"); + let route = routes + .iter() + .find(|r| r.id == route_id) + .expect("route was not written"); assert_eq!(route.uris, Some(vec!["/e2e-1".to_string()])); assert_eq!(route.service_id.as_deref(), Some(service_id)); - sync_ok(vec![delete(ResourceType::Route, route_id), delete(ResourceType::Service, service_id)]).await; + sync_ok(vec![ + delete(ResourceType::Route, route_id), + delete(ResourceType::Service, service_id), + ]) + .await; cleanup.disarm(); let routes = fetcher().list_routes().await.unwrap(); - assert!(routes.iter().all(|r| r.id != route_id), "route should have been deleted"); + assert!( + routes.iter().all(|r| r.id != route_id), + "route should have been deleted" + ); let upstreams = fetcher().list_upstreams().await.unwrap(); - assert!(upstreams.iter().all(|u| u.id.as_deref() != Some(service_id)), "upstream should have been deleted alongside its service"); + assert!( + upstreams + .iter() + .all(|u| u.id.as_deref() != Some(service_id)), + "upstream should have been deleted alongside its service" + ); } #[tokio::test] @@ -163,8 +211,14 @@ async fn syncs_an_ssl_certificate_then_reads_it_back() { cleanup.push(delete(ResourceType::Ssl, ssl_id)); let ssls = fetcher().list_ssls().await.unwrap(); - let ssl = ssls.iter().find(|s| s.id == ssl_id).expect("ssl was not written"); - assert_eq!(ssl.snis.as_deref(), Some(&["e2e.example.com".to_string()][..])); + let ssl = ssls + .iter() + .find(|s| s.id == ssl_id) + .expect("ssl was not written"); + assert_eq!( + ssl.snis.as_deref(), + Some(&["e2e.example.com".to_string()][..]) + ); assert!(ssl.cert.is_some()); sync_ok(vec![delete(ResourceType::Ssl, ssl_id)]).await; @@ -189,20 +243,49 @@ async fn syncs_a_consumer_with_a_key_auth_credential_then_reads_it_back() { let username = "e2e_consumer_1"; let credential_id = "e2e-cred-1"; - let mut credential_event = - create(ResourceType::ConsumerCredential, credential_id, json!({ "name": credential_id, "type": "key-auth", "config": { "key": "e2e-secret" } })); + let mut credential_event = create( + ResourceType::ConsumerCredential, + credential_id, + json!({ "name": credential_id, "type": "key-auth", "config": { "key": "e2e-secret" } }), + ); credential_event.parent_id = Some(username.to_string()); - sync_ok(vec![create(ResourceType::Consumer, username, json!({ "username": username })), credential_event]).await; - cleanup.push(delete_child(ResourceType::ConsumerCredential, credential_id, username)); + sync_ok(vec![ + create( + ResourceType::Consumer, + username, + json!({ "username": username }), + ), + credential_event, + ]) + .await; + cleanup.push(delete_child( + ResourceType::ConsumerCredential, + credential_id, + username, + )); cleanup.push(delete(ResourceType::Consumer, username)); let consumers = fetcher().list_consumers().await.unwrap(); - let consumer = consumers.iter().find(|c| c.username == username).expect("consumer was not written"); - let credentials = consumer.credentials.as_ref().expect("credentials should have been fetched (version-gated above)"); - assert!(credentials.iter().any(|c| c.id.as_deref() == Some(credential_id))); + let consumer = consumers + .iter() + .find(|c| c.username == username) + .expect("consumer was not written"); + let credentials = consumer + .credentials + .as_ref() + .expect("credentials should have been fetched (version-gated above)"); + assert!( + credentials + .iter() + .any(|c| c.id.as_deref() == Some(credential_id)) + ); - sync_ok(vec![delete_child(ResourceType::ConsumerCredential, credential_id, username), delete(ResourceType::Consumer, username)]).await; + sync_ok(vec![ + delete_child(ResourceType::ConsumerCredential, credential_id, username), + delete(ResourceType::Consumer, username), + ]) + .await; cleanup.disarm(); let consumers = fetcher().list_consumers().await.unwrap(); assert!(consumers.iter().all(|c| c.username != username)); @@ -220,8 +303,11 @@ async fn syncs_a_stream_route_then_reads_it_back() { let service_id = "e2e-svc-stream-1"; let stream_route_id = "e2e-stream-route-1"; - let mut stream_route_event = - create(ResourceType::StreamRoute, stream_route_id, json!({ "name": "e2e-stream-route", "server_port": 33061 })); + let mut stream_route_event = create( + ResourceType::StreamRoute, + stream_route_id, + json!({ "name": "e2e-stream-route", "server_port": 33061 }), + ); stream_route_event.parent_id = Some(service_id.to_string()); sync_ok(vec![ @@ -233,7 +319,10 @@ async fn syncs_a_stream_route_then_reads_it_back() { cleanup.push(delete(ResourceType::Service, service_id)); let stream_routes = fetcher().list_stream_routes().await.unwrap(); - let route = stream_routes.iter().find(|r| r.id.as_deref() == Some(stream_route_id)).expect("stream route was not written"); + let route = stream_routes + .iter() + .find(|r| r.id.as_deref() == Some(stream_route_id)) + .expect("stream route was not written"); assert_eq!(route.server_port, Some(33061)); let adc_route: adc_sdk::resources::StreamRoute = route.clone().into(); if apisix_version() >= Version::new(3, 8, 0) { @@ -249,10 +338,18 @@ async fn syncs_a_stream_route_then_reads_it_back() { assert_eq!(adc_route.name, stream_route_id); } - sync_ok(vec![delete(ResourceType::StreamRoute, stream_route_id), delete(ResourceType::Service, service_id)]).await; + sync_ok(vec![ + delete(ResourceType::StreamRoute, stream_route_id), + delete(ResourceType::Service, service_id), + ]) + .await; cleanup.disarm(); let stream_routes = fetcher().list_stream_routes().await.unwrap(); - assert!(stream_routes.iter().all(|r| r.id.as_deref() != Some(stream_route_id))); + assert!( + stream_routes + .iter() + .all(|r| r.id.as_deref() != Some(stream_route_id)) + ); } #[tokio::test] @@ -264,7 +361,12 @@ async fn deleting_a_service_that_never_had_an_upstream_still_succeeds() { // that resource existing — `operate` tolerates a 404 specifically for // this delete rather than failing the whole event. let service_id = "e2e-svc-no-upstream"; - sync_ok(vec![create(ResourceType::Service, service_id, json!({ "name": "e2e service with no upstream" }))]).await; + sync_ok(vec![create( + ResourceType::Service, + service_id, + json!({ "name": "e2e service with no upstream" }), + )]) + .await; sync_ok(vec![delete(ResourceType::Service, service_id)]).await; diff --git a/rust/crates/adc-backend-apisix/tests/e2e_ping.rs b/rust/crates/adc-backend-apisix/tests/e2e_ping.rs index 7231641a..44f5a59c 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_ping.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_ping.rs @@ -18,13 +18,21 @@ mod common; use common::TOKEN; fn read_asset(name: &str) -> Vec { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../libs/backend-apisix/e2e/assets/apisix_conf/mtls").join(name); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../libs/backend-apisix/e2e/assets/apisix_conf/mtls") + .join(name); std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) } fn backend(server: &str, tls: TlsConfig) -> ApisixBackend { - let client = HttpClient::new(HttpClientConfig { server: server.to_string(), token: TOKEN.to_string(), timeout: None, tls }).unwrap(); - ApisixBackend::new(client) + let client = HttpClient::new(HttpClientConfig { + server: server.to_string(), + token: TOKEN.to_string(), + timeout: None, + tls, + }) + .unwrap(); + ApisixBackend::new(client, adc_backend_core::ResourceFilter::default()) } #[tokio::test] @@ -52,7 +60,10 @@ async fn succeeds_over_mtls() { async fn fails_against_an_unreachable_server() { let backend = backend("http://0.0.0.0:1", TlsConfig::default()); let err = backend.ping().await.unwrap_err(); - assert!(matches!(err, adc_sdk::BackendError::Transport(_)), "got {err:?}"); + assert!( + matches!(err, adc_sdk::BackendError::Transport(_)), + "got {err:?}" + ); } #[tokio::test] @@ -62,13 +73,21 @@ async fn fails_when_the_server_certificate_is_not_trusted() { // supplying the CA to trust it must fail the TLS handshake. let backend = backend("https://localhost:29180", TlsConfig::default()); let err = backend.ping().await.unwrap_err(); - assert!(matches!(err, adc_sdk::BackendError::Transport(_)), "got {err:?}"); + assert!( + matches!(err, adc_sdk::BackendError::Transport(_)), + "got {err:?}" + ); } #[tokio::test] #[ignore] async fn fails_when_the_client_certificate_is_missing() { - let tls = TlsConfig { ca_cert_pem: Some(read_asset("ca.cer")), client_cert_pem: None, client_key_pem: None, skip_verify: false }; + let tls = TlsConfig { + ca_cert_pem: Some(read_asset("ca.cer")), + client_cert_pem: None, + client_key_pem: None, + skip_verify: false, + }; let backend = backend("https://localhost:29180", tls); // APISIX's mTLS listener requires a client cert; without one the TLS // handshake itself is refused before any HTTP response comes back. diff --git a/rust/crates/adc-backend-apisix/tests/e2e_resource_consumer.rs b/rust/crates/adc-backend-apisix/tests/e2e_resource_consumer.rs index 84a14985..4cf7a162 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_resource_consumer.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_resource_consumer.rs @@ -19,11 +19,27 @@ fn update(rt: ResourceType, id: &str, new_value: serde_json::Value) -> Event { // Consumer credential updates aren't SERVICE events, so the operator // doesn't need diff info to decide what to touch — an empty diff is // fine here (contrast `e2e_sync_and_dump.rs`'s `update()` helper). - Event::new(rt, EventKind::Update { old_value: json!({}), new_value, diff: None }, id, id) + Event::new( + rt, + EventKind::Update { + old_value: json!({}), + new_value, + diff: None, + }, + id, + id, + ) } fn delete(rt: ResourceType, id: &str) -> Event { - Event::new(rt, EventKind::Delete { old_value: json!({}) }, id, id) + Event::new( + rt, + EventKind::Delete { + old_value: json!({}), + }, + id, + id, + ) } #[tokio::test] @@ -50,7 +66,14 @@ async fn syncs_and_dumps_a_consumer_with_a_credential_lifecycle() { let results = backend .sync( - vec![create(ResourceType::Consumer, consumer_username, json!({ "username": consumer_username })), credential], + vec![ + create( + ResourceType::Consumer, + consumer_username, + json!({ "username": consumer_username }), + ), + credential, + ], BackendSyncOptions::default(), ) .await @@ -62,14 +85,26 @@ async fn syncs_and_dumps_a_consumer_with_a_credential_lifecycle() { let config = backend.dump().await.unwrap(); let consumers = config.consumers.unwrap(); assert_eq!(consumers.len(), 1); - let credentials = consumers[0].credentials.as_ref().expect("consumer should have its credential"); + let credentials = consumers[0] + .credentials + .as_ref() + .expect("consumer should have its credential"); assert_eq!(credentials.len(), 1); - assert_eq!(credentials[0].config.get("key"), Some(&json!(credential_id))); + assert_eq!( + credentials[0].config.get("key"), + Some(&json!(credential_id)) + ); - let mut updated_credential = - update(ResourceType::ConsumerCredential, credential_id, json!({ "name": credential_id, "type": "key-auth", "config": { "key": "new-key" } })); + let mut updated_credential = update( + ResourceType::ConsumerCredential, + credential_id, + json!({ "name": credential_id, "type": "key-auth", "config": { "key": "new-key" } }), + ); updated_credential.parent_id = Some(consumer_username.to_string()); - let results = backend.sync(vec![updated_credential], BackendSyncOptions::default()).await.unwrap(); + let results = backend + .sync(vec![updated_credential], BackendSyncOptions::default()) + .await + .unwrap(); assert!(results[0].success, "{:?}", results[0].error); let config = backend.dump().await.unwrap(); @@ -78,7 +113,10 @@ async fn syncs_and_dumps_a_consumer_with_a_credential_lifecycle() { let mut delete_credential = delete(ResourceType::ConsumerCredential, credential_id); delete_credential.parent_id = Some(consumer_username.to_string()); - let results = backend.sync(vec![delete_credential], BackendSyncOptions::default()).await.unwrap(); + let results = backend + .sync(vec![delete_credential], BackendSyncOptions::default()) + .await + .unwrap(); assert!(results[0].success, "{:?}", results[0].error); let config = backend.dump().await.unwrap(); @@ -86,7 +124,13 @@ async fn syncs_and_dumps_a_consumer_with_a_credential_lifecycle() { assert_eq!(consumers.len(), 1); assert!(consumers[0].credentials.is_none()); - let results = backend.sync(vec![delete(ResourceType::Consumer, consumer_username)], BackendSyncOptions::default()).await.unwrap(); + let results = backend + .sync( + vec![delete(ResourceType::Consumer, consumer_username)], + BackendSyncOptions::default(), + ) + .await + .unwrap(); assert!(results[0].success, "{:?}", results[0].error); let config = backend.dump().await.unwrap(); @@ -97,7 +141,9 @@ async fn syncs_and_dumps_a_consumer_with_a_credential_lifecycle() { #[ignore] async fn consumer_credentials_are_never_fetched_below_apisix_3_11_0() { if apisix_version() < semver::Version::new(3, 11, 0) { - eprintln!("skipping: needs a real >= 3.11.0 server to prove the client-side gate is what's skipping the fetch, not the server lacking the feature"); + eprintln!( + "skipping: needs a real >= 3.11.0 server to prove the client-side gate is what's skipping the fetch, not the server lacking the feature" + ); return; } @@ -112,7 +158,14 @@ async fn consumer_credentials_are_never_fetched_below_apisix_3_11_0() { credential.parent_id = Some(consumer_username.to_string()); let results = backend .sync( - vec![create(ResourceType::Consumer, consumer_username, json!({ "username": consumer_username })), credential], + vec![ + create( + ResourceType::Consumer, + consumer_username, + json!({ "username": consumer_username }), + ), + credential, + ], BackendSyncOptions::default(), ) .await @@ -126,15 +179,33 @@ async fn consumer_credentials_are_never_fetched_below_apisix_3_11_0() { // pre-3.11.0 apisix. `list_consumers` must not even attempt the // credentials sub-fetch in that case, regardless of what the server // could actually return. - let old_fetcher = Fetcher::new(client(), semver::Version::new(3, 10, 0)); + let old_fetcher = Fetcher::new( + client(), + semver::Version::new(3, 10, 0), + adc_backend_core::ResourceFilter::default(), + ); let consumers = old_fetcher.list_consumers().await.unwrap(); - let consumer = consumers.iter().find(|c| c.username == consumer_username).expect("consumer was not found"); - assert!(consumer.credentials.is_none(), "credentials must not be fetched when the fetcher believes the server predates 3.11.0"); + let consumer = consumers + .iter() + .find(|c| c.username == consumer_username) + .expect("consumer was not found"); + assert!( + consumer.credentials.is_none(), + "credentials must not be fetched when the fetcher believes the server predates 3.11.0" + ); let mut delete_credential = delete(ResourceType::ConsumerCredential, credential_id); delete_credential.parent_id = Some(consumer_username.to_string()); - let results = - backend.sync(vec![delete_credential, delete(ResourceType::Consumer, consumer_username)], BackendSyncOptions::default()).await.unwrap(); + let results = backend + .sync( + vec![ + delete_credential, + delete(ResourceType::Consumer, consumer_username), + ], + BackendSyncOptions::default(), + ) + .await + .unwrap(); for result in &results { assert!(result.success, "{:?}", result.error); } diff --git a/rust/crates/adc-backend-apisix/tests/e2e_resource_service.rs b/rust/crates/adc-backend-apisix/tests/e2e_resource_service.rs index 6f5b6c98..d5ff569c 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_resource_service.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_resource_service.rs @@ -20,20 +20,48 @@ async fn creating_a_service_with_an_inline_upstream_splits_it_into_a_separate_re let service = Event::new( ResourceType::Service, - EventKind::Create { new_value: json!({ "name": service_name, "upstream": { "type": "roundrobin", "nodes": [{ "host": "127.0.0.1", "port": 8080, "weight": 1 }] } }) }, + EventKind::Create { + new_value: json!({ "name": service_name, "upstream": { "type": "roundrobin", "nodes": [{ "host": "127.0.0.1", "port": 8080, "weight": 1 }] } }), + }, service_id.clone(), service_name, ); - let results = backend.sync(vec![service], BackendSyncOptions::default()).await.unwrap(); + let results = backend + .sync(vec![service], BackendSyncOptions::default()) + .await + .unwrap(); assert!(results[0].success, "{:?}", results[0].error); - let fetcher = Fetcher::new(client(), semver::Version::new(3, 17, 0)); + let fetcher = Fetcher::new( + client(), + semver::Version::new(3, 17, 0), + adc_backend_core::ResourceFilter::default(), + ); let services = fetcher.list_services().await.unwrap(); - let wire_service = services.iter().find(|s| s.id == service_id).expect("service was not created"); - assert!(wire_service.upstream_id.is_some(), "service should reference a separate upstream resource by id"); - assert!(wire_service.upstream.is_none(), "the upstream must not be inlined into the service's own wire body"); + let wire_service = services + .iter() + .find(|s| s.id == service_id) + .expect("service was not created"); + assert!( + wire_service.upstream_id.is_some(), + "service should reference a separate upstream resource by id" + ); + assert!( + wire_service.upstream.is_none(), + "the upstream must not be inlined into the service's own wire body" + ); - let delete = Event::new(ResourceType::Service, EventKind::Delete { old_value: json!({}) }, service_id, service_name); - let results = backend.sync(vec![delete], BackendSyncOptions::default()).await.unwrap(); + let delete = Event::new( + ResourceType::Service, + EventKind::Delete { + old_value: json!({}), + }, + service_id, + service_name, + ); + let results = backend + .sync(vec![delete], BackendSyncOptions::default()) + .await + .unwrap(); assert!(results[0].success, "{:?}", results[0].error); } diff --git a/rust/crates/adc-backend-apisix/tests/e2e_validate.rs b/rust/crates/adc-backend-apisix/tests/e2e_validate.rs index 7cf050c2..23e00fe7 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_validate.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_validate.rs @@ -114,7 +114,10 @@ async fn fails_with_an_invalid_plugin_configuration() { // The error is mapped back to the specific Event that produced it, not // just its position in apisix's response. assert_eq!(result.errors[0].resource_name.as_deref(), Some(route_id)); - let matched_event = result.errors[0].event.as_ref().expect("event should have been matched from the request index"); + let matched_event = result.errors[0] + .event + .as_ref() + .expect("event should have been matched from the request index"); assert_eq!(matched_event.resource_type, ResourceType::Route); assert_eq!(matched_event.resource_id, route_id); assert_eq!(matched_event.parent_id.as_deref(), Some(service_id)); @@ -146,12 +149,19 @@ async fn collects_multiple_errors() { ); route2.parent_id = Some(service_id.to_string()); - let result = validator().validate(&[service, route1, route2]).await.unwrap(); + let result = validator() + .validate(&[service, route1, route2]) + .await + .unwrap(); assert!(!result.success); assert!(result.errors.len() >= 2, "{:?}", result.errors); // Each route's error maps back to *its own* name, not a mix-up between // the two routes sharing a parent service. - let names: Vec<&str> = result.errors.iter().filter_map(|e| e.resource_name.as_deref()).collect(); + let names: Vec<&str> = result + .errors + .iter() + .filter_map(|e| e.resource_name.as_deref()) + .collect(); assert!(names.contains(&route1_id), "{names:?}"); assert!(names.contains(&route2_id), "{names:?}"); } @@ -172,7 +182,11 @@ async fn succeeds_with_mixed_resource_types() { consumer_username, json!({ "username": consumer_username, "plugins": { "key-auth": { "key": "mixed-key-456" } } }), )); - events.push(create(ResourceType::GlobalRule, "prometheus", json!({ "prefer_name": false }))); + events.push(create( + ResourceType::GlobalRule, + "prometheus", + json!({ "prefer_name": false }), + )); let result = validator().validate(&events).await.unwrap(); assert!(result.success, "{:?}", result.errors); @@ -194,7 +208,14 @@ async fn is_a_dry_run_with_no_side_effects_on_the_server() { let result = validator().validate(&events).await.unwrap(); assert!(result.success, "{:?}", result.errors); - let fetcher = adc_backend_apisix::tests::Fetcher::new(client(), semver::Version::new(3, 17, 0)); + let fetcher = adc_backend_apisix::tests::Fetcher::new( + client(), + semver::Version::new(3, 17, 0), + adc_backend_core::ResourceFilter::default(), + ); let services = fetcher.list_services().await.unwrap(); - assert!(services.iter().all(|s| s.id != service_id), "validate must not have created anything on the server"); + assert!( + services.iter().all(|s| s.id != service_id), + "validate must not have created anything on the server" + ); } diff --git a/rust/crates/adc-backend-apisix/tests/transformer.rs b/rust/crates/adc-backend-apisix/tests/transformer.rs index 2df4e077..36a7b256 100644 --- a/rust/crates/adc-backend-apisix/tests/transformer.rs +++ b/rust/crates/adc-backend-apisix/tests/transformer.rs @@ -136,7 +136,7 @@ fn route_parses_recognized_http_methods() { fn upstream_list_nodes_pass_through_unchanged() { let mut upstream = upstream(); upstream.nodes = - Some(typing::UpstreamNodes::List(vec![adc::UpstreamNode { host: "10.0.0.1".into(), port: 8080, weight: 1, priority: 0.0, metadata: None }])); + Some(typing::UpstreamNodes::List(vec![adc::UpstreamNode { host: "10.0.0.1".into(), port: 8080, weight: 1, priority: 0, metadata: None }])); let adc_upstream: adc::Upstream = upstream.try_into().unwrap(); let nodes = adc_upstream.nodes.unwrap(); assert_eq!(nodes.len(), 1); diff --git a/rust/crates/adc-backend-core/src/client.rs b/rust/crates/adc-backend-core/src/client.rs index 83b8142a..8c78645d 100644 --- a/rust/crates/adc-backend-core/src/client.rs +++ b/rust/crates/adc-backend-core/src/client.rs @@ -2,7 +2,9 @@ use std::time::Duration; use adc_sdk::BackendError; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; -use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, HeaderValue, SET_COOKIE}; +use reqwest::header::{ + AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, HeaderValue, SET_COOKIE, +}; use reqwest::{Method, RequestBuilder, Response, ResponseBuilderExt, Url}; use serde::de::DeserializeOwned; use tracing::Instrument; @@ -266,8 +268,7 @@ impl HttpClient { } /// Best-effort unwrap of APISIX's `{"error_msg": "..."}` error body down to -/// just the message, matching the TS CLI's `formatAxiosErrorMessage`. Falls -/// back to the raw body when it isn't that shape. +/// just the message. Falls back to the raw body when it isn't that shape. fn extract_error_message(body: &str) -> String { match serde_json::from_str::(body) { Ok(serde_json::Value::Object(map)) => match map.get("error_msg") { @@ -278,8 +279,7 @@ fn extract_error_message(body: &str) -> String { } } -/// `Header-Name: value\n`-per-line, matching the TS CLI's `transformHeaders`. -/// Sensitive values print as `*****` — either because the crate that set +/// `Header-Name: value\n`-per-line. Sensitive values print as `*****` — either because the crate that set /// them marked them so (`X-API-KEY`, via `HeaderValue::set_sensitive`), or /// because the name itself is always credential-bearing regardless of who /// set it (`is_sensitive_header_name`). diff --git a/rust/crates/adc-backend-core/src/lib.rs b/rust/crates/adc-backend-core/src/lib.rs index 7736c167..d68ffb18 100644 --- a/rust/crates/adc-backend-core/src/lib.rs +++ b/rust/crates/adc-backend-core/src/lib.rs @@ -7,12 +7,16 @@ mod client; mod concurrency; +mod resource_filter; +mod resource_path; mod retry; mod tls; pub use client::{HTTP_REQUEST_SPAN_NAME, HttpClient, HttpClientConfig, encode_path_segment}; pub use concurrency::{concurrent_map, concurrent_map_until_err}; +pub use resource_filter::{ResourceFilter, filter_configuration_by_labels}; +pub use resource_path::resource_type_collection_name; pub use retry::RetryPolicy; pub use tls::TlsConfig; -pub use reqwest::{Method, Response}; +pub use reqwest::{Method, RequestBuilder, Response}; diff --git a/rust/crates/adc-backend-core/src/resource_filter.rs b/rust/crates/adc-backend-core/src/resource_filter.rs new file mode 100644 index 00000000..9d9f9516 --- /dev/null +++ b/rust/crates/adc-backend-core/src/resource_filter.rs @@ -0,0 +1,225 @@ +use std::collections::{HashMap, HashSet}; + +use adc_sdk::ResourceType; +use adc_sdk::resources::{Configuration, LabelValue, Labels}; +use reqwest::RequestBuilder; + +/// What a fetcher should skip and how it should narrow a request, decided +/// once at `Backend` construction time and consulted before every +/// top-level collection request `dump()` makes — a service's nested +/// routes/upstreams or a consumer's nested credentials aren't filtered +/// individually, only whichever top-level collection they came from ever +/// gets fetched at all. +#[derive(Debug, Clone, Default)] +pub struct ResourceFilter { + pub include: HashSet, + pub exclude: HashSet, + pub label_selector: HashMap, +} + +impl ResourceFilter { + /// An empty (include, exclude) pair skips nothing — the common case, + /// when neither `--include-resource-type` nor `--exclude-resource-type` + /// was given. + pub fn is_skip(&self, resource_type: ResourceType) -> bool { + if !self.include.is_empty() && !self.include.contains(&resource_type) { + return true; + } + if !self.exclude.is_empty() && self.exclude.contains(&resource_type) { + return true; + } + false + } + + /// Adds one `labels[key]=value` query parameter per `--label-selector` + /// entry. A no-op when there's no selector, so callers can chain this + /// unconditionally. + pub fn attach_label_selector(&self, builder: RequestBuilder) -> RequestBuilder { + if self.label_selector.is_empty() { + return builder; + } + let params: Vec<(String, &str)> = self + .label_selector + .iter() + .map(|(key, value)| (format!("labels[{key}]"), value.as_str())) + .collect(); + builder.query(¶ms) + } + + /// Drops resources whose `labels` don't carry every key/value pair in + /// `label_selector`. A client-side backstop for [`Self::attach_label_selector`]: + /// nothing here guarantees the server actually understood that query + /// parameter and narrowed its response, so a fetcher can't treat + /// "asked the server to filter" as "the result is filtered" — this + /// re-checks every resource it got back, regardless of whether the + /// server already did the same filtering itself. + pub fn filter_configuration(&self, config: &mut Configuration) { + filter_configuration_by_labels(config, &self.label_selector); + } +} + +/// Top-level only (`services`/`ssls`/`consumers`/`consumer_groups`) — +/// `global_rules`/`plugin_metadata` aren't per-resource collections a label +/// selector could narrow down. A resource with no `labels` at all never +/// matches a non-empty selector. +pub fn filter_configuration_by_labels(config: &mut Configuration, label_selector: &HashMap) { + if label_selector.is_empty() { + return; + } + if let Some(services) = &mut config.services { + services.retain(|s| matches_labels(&s.labels, label_selector)); + } + if let Some(ssls) = &mut config.ssls { + ssls.retain(|s| matches_labels(&s.labels, label_selector)); + } + if let Some(consumers) = &mut config.consumers { + consumers.retain(|c| matches_labels(&c.labels, label_selector)); + } + if let Some(consumer_groups) = &mut config.consumer_groups { + consumer_groups.retain(|g| matches_labels(&g.labels, label_selector)); + } +} + +fn matches_labels(resource_labels: &Option, required: &HashMap) -> bool { + let Some(resource_labels) = resource_labels else { + return false; + }; + required.iter().all(|(key, value)| match resource_labels.get(key) { + Some(LabelValue::Single(v)) => v == value, + Some(LabelValue::Multiple(values)) => values.iter().any(|v| v == value), + None => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skips_nothing_when_neither_include_nor_exclude_is_set() { + let filter = ResourceFilter::default(); + assert!(!filter.is_skip(ResourceType::Service)); + assert!(!filter.is_skip(ResourceType::Consumer)); + } + + #[test] + fn an_include_list_skips_everything_not_on_it() { + let filter = ResourceFilter { + include: [ResourceType::Service].into_iter().collect(), + ..Default::default() + }; + assert!(!filter.is_skip(ResourceType::Service)); + assert!(filter.is_skip(ResourceType::Consumer)); + } + + #[test] + fn an_exclude_list_skips_only_whats_on_it() { + let filter = ResourceFilter { + exclude: [ResourceType::Service].into_iter().collect(), + ..Default::default() + }; + assert!(filter.is_skip(ResourceType::Service)); + assert!(!filter.is_skip(ResourceType::Consumer)); + } + + #[test] + fn an_empty_include_list_is_the_same_as_no_include_list() { + let filter = ResourceFilter { + include: HashSet::new(), + ..Default::default() + }; + assert!(!filter.is_skip(ResourceType::Service)); + } + + fn consumer(username: &str, labels: Option) -> adc_sdk::resources::Consumer { + adc_sdk::resources::Consumer { + username: username.to_string(), + description: None, + labels, + plugins: None, + credentials: None, + } + } + + fn consumer_group(name: &str, labels: Option) -> adc_sdk::resources::ConsumerGroup { + adc_sdk::resources::ConsumerGroup { + id: None, + name: name.to_string(), + description: None, + labels, + plugins: None, + consumers: None, + } + } + + fn empty_configuration() -> Configuration { + Configuration { + services: None, + ssls: None, + consumers: None, + consumer_groups: None, + global_rules: None, + plugin_metadata: None, + } + } + + #[test] + fn an_empty_selector_is_a_no_op() { + let mut config = empty_configuration(); + config.consumers = Some(vec![consumer("c1", None)]); + filter_configuration_by_labels(&mut config, &HashMap::new()); + assert_eq!(config.consumers.unwrap().len(), 1); + } + + #[test] + fn keeps_only_resources_matching_every_required_label() { + let mut config = empty_configuration(); + config.consumers = Some(vec![ + consumer( + "matches", + Some(Labels::from([ + ("env".to_string(), LabelValue::Single("prod".to_string())), + ("team".to_string(), LabelValue::Single("core".to_string())), + ])), + ), + consumer( + "missing_one_key", + Some(Labels::from([("env".to_string(), LabelValue::Single("prod".to_string()))])), + ), + consumer( + "wrong_value", + Some(Labels::from([ + ("env".to_string(), LabelValue::Single("dev".to_string())), + ("team".to_string(), LabelValue::Single("core".to_string())), + ])), + ), + ]); + let required = HashMap::from([ + ("env".to_string(), "prod".to_string()), + ("team".to_string(), "core".to_string()), + ]); + filter_configuration_by_labels(&mut config, &required); + let usernames: Vec<&str> = config.consumers.as_ref().unwrap().iter().map(|c| c.username.as_str()).collect(); + assert_eq!(usernames, vec!["matches"]); + } + + #[test] + fn a_resource_with_no_labels_never_matches_a_non_empty_selector() { + let mut config = empty_configuration(); + config.consumers = Some(vec![consumer("c1", None)]); + filter_configuration_by_labels(&mut config, &HashMap::from([("env".to_string(), "prod".to_string())])); + assert!(config.consumers.unwrap().is_empty()); + } + + #[test] + fn a_multiple_value_label_matches_if_any_entry_equals_the_required_value() { + let labels = Labels::from([( + "env".to_string(), + LabelValue::Multiple(vec!["dev".to_string(), "prod".to_string()]), + )]); + let mut config = empty_configuration(); + config.consumer_groups = Some(vec![consumer_group("g1", Some(labels))]); + filter_configuration_by_labels(&mut config, &HashMap::from([("env".to_string(), "prod".to_string())])); + assert_eq!(config.consumer_groups.unwrap().len(), 1); + } +} diff --git a/rust/crates/adc-backend-core/src/resource_path.rs b/rust/crates/adc-backend-core/src/resource_path.rs new file mode 100644 index 00000000..18d23304 --- /dev/null +++ b/rust/crates/adc-backend-core/src/resource_path.rs @@ -0,0 +1,19 @@ +use adc_sdk::ResourceType; + +/// The admin API collection path segment for a resource type (e.g. +/// `Service` -> `"services"`) — plain pluralization of its snake_case name, +/// except plugin metadata's collection, which isn't pluralized. +/// +/// Purely mechanical: it says nothing about whether a given backend +/// actually exposes that collection at the top level. A resource type that +/// lives nested under its parent's own path instead (a consumer's +/// credentials, an API7 service's named upstreams, ...) still gets a +/// segment from this function — it's each backend's own path-building code +/// that decides which resource types to route elsewhere before ever +/// calling this. +pub fn resource_type_collection_name(resource_type: ResourceType) -> String { + match resource_type { + ResourceType::PluginMetadata => resource_type.as_str().to_string(), + _ => format!("{}s", resource_type.as_str()), + } +} diff --git a/rust/crates/adc-cli/Cargo.toml b/rust/crates/adc-cli/Cargo.toml index b401723c..cc080e8a 100644 --- a/rust/crates/adc-cli/Cargo.toml +++ b/rust/crates/adc-cli/Cargo.toml @@ -14,6 +14,7 @@ adc-sdk = { path = "../adc-sdk" } adc-differ = { path = "../adc-differ" } adc-backend-core = { path = "../adc-backend-core" } adc-backend-apisix = { path = "../adc-backend-apisix" } +adc-backend-api7 = { path = "../adc-backend-api7" } clap = { version = "4", features = ["derive", "env"] } humantime = "2" serde_json = { workspace = true } diff --git a/rust/crates/adc-cli/src/config.rs b/rust/crates/adc-cli/src/config.rs index adc6a97e..f291c3a7 100644 --- a/rust/crates/adc-cli/src/config.rs +++ b/rust/crates/adc-cli/src/config.rs @@ -18,7 +18,7 @@ use crate::error::CliError; const ARRAY_KEYS: &[&str] = &["services", "ssls", "consumers", "consumer_groups"]; const MAP_KEYS: &[&str] = &["global_rules", "plugin_metadata"]; -const MANAGED_BY_LABEL_KEY: &str = "managed-by"; +pub(crate) const MANAGED_BY_LABEL_KEY: &str = "managed-by"; const MANAGED_BY_LABEL_VALUE: &str = "adc"; /// Expands glob patterns (defaulting to `adc.yaml` when none are given) and @@ -191,20 +191,39 @@ fn singular(array_key: &'static str) -> &'static str { } } -/// Stamps `managed-by: adc` onto every resource's `labels`, including the -/// nested spots a resource can be authored under (`services[].routes`, -/// `services[].stream_routes`, `consumer_groups[].consumers`) — mirrors the -/// TS CLI's `fillLabels` scope exactly, including its gaps (it does not -/// reach `services[].upstreams` or `consumers[].credentials`). +/// Stamps `managed-by: adc` onto every resource's `labels`. Thin wrapper +/// around [`fill_labels`] for this one fixed key/value pair. pub fn inject_managed_by_label(config: &mut Value) { + fill_labels( + config, + &HashMap::from([( + MANAGED_BY_LABEL_KEY.to_string(), + MANAGED_BY_LABEL_VALUE.to_string(), + )]), + ); +} + +/// Merges `labels` into every resource's own `labels` map (`labels` wins on +/// key conflict, so callers can force a value), including the nested spots +/// a resource can be authored under (`services[].routes`, +/// `services[].stream_routes`, `consumer_groups[].consumers`). Does not +/// reach `services[].upstreams` or `consumers[].credentials` — neither +/// carries its own independent identity worth labeling separately from its +/// parent. +pub fn fill_labels(config: &mut Value, labels: &HashMap) { + if labels.is_empty() { + return; + } let Value::Object(root) = config else { return }; - for key in ["services", "ssls", "consumers", "consumer_groups"] { - let Some(Value::Array(items)) = root.get_mut(key) else { + for key in ARRAY_KEYS { + let Some(Value::Array(items)) = root.get_mut(*key) else { continue; }; for item in items.iter_mut() { - set_label(item, MANAGED_BY_LABEL_KEY, MANAGED_BY_LABEL_VALUE); - let nested_keys: &[&str] = match key { + for (label_key, label_value) in labels { + set_label(item, label_key, label_value); + } + let nested_keys: &[&str] = match *key { "services" => &["routes", "stream_routes"], "consumer_groups" => &["consumers"], _ => &[], @@ -212,7 +231,9 @@ pub fn inject_managed_by_label(config: &mut Value) { for nested_key in nested_keys { if let Some(Value::Array(nested)) = item.get_mut(*nested_key) { for nested_item in nested.iter_mut() { - set_label(nested_item, MANAGED_BY_LABEL_KEY, MANAGED_BY_LABEL_VALUE); + for (label_key, label_value) in labels { + set_label(nested_item, label_key, label_value); + } } } } @@ -231,9 +252,9 @@ fn set_label(item: &mut Value, key: &str, value: &str) { } /// Strips `id` fields before writing a `dump` (unless `--with-id` was -/// given) — mirrors the TS CLI's `recursiveRemoveIdField` scope, which is -/// wider than `inject_managed_by_label`'s: it also reaches -/// `services[].upstreams` and `consumers[].credentials`. +/// given). Wider in scope than [`fill_labels`]: `id` is meaningful on +/// `services[].upstreams` and `consumers[].credentials` too, even though +/// neither gets its own labels. pub fn strip_ids(config: &mut Value) { let Value::Object(root) = config else { return }; for key in ["services", "ssls", "consumers", "consumer_groups"] { @@ -266,10 +287,9 @@ fn remove_id(item: &mut Value) { } /// Drops whole top-level resource-type buckets that don't match -/// `--include-resource-type`/`--exclude-resource-type` — mirrors the TS -/// CLI's `filterResourceType`, bucket-level rather than per-item (a -/// `Configuration` has no top-level `routes`/`upstreams` key to filter on; -/// those only exist nested under `services`). +/// `--include-resource-type`/`--exclude-resource-type`. Bucket-level rather +/// than per-item: a `Configuration` has no top-level `routes`/`upstreams` +/// key to filter on, since those only exist nested under `services`. pub fn filter_resource_types( config: &mut Configuration, include: &HashSet, @@ -305,3 +325,224 @@ pub fn filter_resource_types( config.plugin_metadata = None; } } + +/// Drops resources whose `labels` don't carry every key/value pair in +/// `labels`. Delegates to `adc_backend_core` — the same function +/// `adc_backend_apisix::Fetcher::dump()` calls on its own output as a +/// client-side backstop for its unreliable server-side `labels[key]=value` +/// query filter. This call is what makes label filtering apply to `api7ee` +/// too, whose fetcher does no client-side re-check of its own. +pub fn filter_by_labels(config: &mut Configuration, labels: &HashMap) { + adc_backend_core::filter_configuration_by_labels(config, labels); +} + +#[cfg(test)] +mod tests { + use super::*; + use adc_sdk::resources::{Consumer, ConsumerGroup, LabelValue, Labels, SSL, Service}; + use serde_json::json; + + fn labels(pairs: &[(&str, &str)]) -> Labels { + pairs + .iter() + .map(|(k, v)| (k.to_string(), LabelValue::Single(v.to_string()))) + .collect() + } + + fn service(name: &str, labels: Option) -> Service { + Service { + id: None, + name: name.to_string(), + description: None, + labels, + upstream: None, + upstreams: None, + plugins: None, + path_prefix: None, + strip_path_prefix: None, + hosts: None, + routes: None, + } + } + + fn ssl(sni: &str, labels: Option) -> SSL { + SSL { + id: None, + labels, + r#type: Default::default(), + snis: vec![sni.to_string()], + certificates: vec![], + client: None, + ssl_protocols: None, + } + } + + fn consumer(username: &str, labels: Option) -> Consumer { + Consumer { + username: username.to_string(), + description: None, + labels, + plugins: None, + credentials: None, + } + } + + fn consumer_group(name: &str, labels: Option) -> ConsumerGroup { + ConsumerGroup { + id: None, + name: name.to_string(), + description: None, + labels, + plugins: None, + consumers: None, + } + } + + mod filter_resource_types_tests { + use super::*; + + fn sample_config() -> Configuration { + Configuration { + services: Some(vec![service("s1", None)]), + ssls: Some(vec![ssl("example.com", None)]), + consumers: Some(vec![consumer("c1", None)]), + consumer_groups: Some(vec![consumer_group("g1", None)]), + global_rules: None, + plugin_metadata: None, + } + } + + #[test] + fn no_include_and_no_exclude_is_a_no_op() { + let mut config = sample_config(); + filter_resource_types(&mut config, &HashSet::new(), &HashSet::new()); + assert!(config.services.is_some()); + assert!(config.ssls.is_some()); + assert!(config.consumers.is_some()); + assert!(config.consumer_groups.is_some()); + } + + #[test] + fn an_include_list_drops_every_bucket_not_on_it() { + let mut config = sample_config(); + let include = HashSet::from([ResourceType::Service]); + filter_resource_types(&mut config, &include, &HashSet::new()); + assert!(config.services.is_some()); + assert!(config.ssls.is_none()); + assert!(config.consumers.is_none()); + assert!(config.consumer_groups.is_none()); + } + + #[test] + fn an_exclude_list_drops_only_the_buckets_on_it() { + let mut config = sample_config(); + let exclude = HashSet::from([ResourceType::Ssl]); + filter_resource_types(&mut config, &HashSet::new(), &exclude); + assert!(config.services.is_some()); + assert!(config.ssls.is_none()); + assert!(config.consumers.is_some()); + assert!(config.consumer_groups.is_some()); + } + } + + mod fill_labels_tests { + use super::*; + + #[test] + fn stamps_the_given_labels_onto_top_level_and_nested_resources() { + let mut config = json!({ + "services": [{ + "name": "s1", + "routes": [{"name": "r1", "uris": ["/foo"]}], + "stream_routes": [], + }], + "consumer_groups": [{ + "name": "g1", + "consumers": [{"username": "c1"}], + }], + }); + fill_labels( + &mut config, + &HashMap::from([("env".to_string(), "prod".to_string())]), + ); + + assert_eq!(config["services"][0]["labels"]["env"], "prod"); + assert_eq!(config["services"][0]["routes"][0]["labels"]["env"], "prod"); + assert_eq!(config["consumer_groups"][0]["labels"]["env"], "prod"); + assert_eq!( + config["consumer_groups"][0]["consumers"][0]["labels"]["env"], + "prod" + ); + } + + #[test] + fn does_not_touch_service_upstreams_or_consumer_credentials() { + let mut config = json!({ + "services": [{"name": "s1", "upstreams": [{"name": "u1"}]}], + "consumers": [{"username": "c1", "credentials": [{"name": "cred1", "type": "key-auth", "config": {}}]}], + }); + fill_labels( + &mut config, + &HashMap::from([("env".to_string(), "prod".to_string())]), + ); + + assert!( + config["services"][0]["upstreams"][0] + .get("labels") + .is_none() + ); + assert!( + config["consumers"][0]["credentials"][0] + .get("labels") + .is_none() + ); + } + + #[test] + fn an_empty_label_map_is_a_no_op() { + let mut config = json!({"services": [{"name": "s1"}]}); + let before = config.clone(); + fill_labels(&mut config, &HashMap::new()); + assert_eq!(config, before); + } + + #[test] + fn given_labels_overwrite_a_resources_existing_value_for_the_same_key() { + let mut config = json!({ + "services": [{"name": "s1", "labels": {"env": "dev"}}], + }); + fill_labels( + &mut config, + &HashMap::from([("env".to_string(), "prod".to_string())]), + ); + assert_eq!(config["services"][0]["labels"]["env"], "prod"); + } + } + + mod filter_by_labels_tests { + use super::*; + + // The actual matching logic (include/exclude label combinations, + // `LabelValue::Multiple`, unlabeled resources) is implemented and + // tested in `adc_backend_core::filter_configuration_by_labels`, + // which this function delegates to. This just confirms the + // delegation itself is wired up correctly. + #[test] + fn delegates_to_the_shared_label_filter() { + let mut config = Configuration { + services: Some(vec![ + service("matches", Some(labels(&[("env", "prod")]))), + service("does_not_match", Some(labels(&[("env", "dev")]))), + ]), + ssls: None, + consumers: None, + consumer_groups: None, + global_rules: None, + plugin_metadata: None, + }; + filter_by_labels(&mut config, &HashMap::from([("env".to_string(), "prod".to_string())])); + let names: Vec<&str> = config.services.as_ref().unwrap().iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, vec!["matches"]); + } + } +} diff --git a/rust/crates/adc-cli/src/main.rs b/rust/crates/adc-cli/src/main.rs index d2f658a4..a69c3a4f 100644 --- a/rust/crates/adc-cli/src/main.rs +++ b/rust/crates/adc-cli/src/main.rs @@ -5,7 +5,7 @@ mod logging; mod pipeline; mod progress; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use adc_sdk::{BackendSyncOptions, Event, EventType, ResourceType}; use clap::Parser; @@ -58,9 +58,10 @@ async fn cmd_ping(args: BackendArgs) -> Result<(), CliError> { async fn cmd_dump(args: DumpArgs) -> Result<(), CliError> { let backend = pipeline::init_backend(&args.backend).await?; let (include, exclude) = pipeline::resource_type_sets(&args.backend); + let label_selector = pipeline::label_selector_map(&args.backend)?; let remote = progress::stage( "Fetching remote configuration...", - pipeline::load_remote(backend.as_ref(), &include, &exclude), + pipeline::load_remote(backend.as_ref(), &include, &exclude, &label_selector), ) .await?; @@ -79,6 +80,7 @@ async fn cmd_dump(args: DumpArgs) -> Result<(), CliError> { async fn cmd_diff(args: DiffArgs) -> Result<(), CliError> { let backend = pipeline::init_backend(&args.backend).await?; let (include, exclude) = pipeline::resource_type_sets(&args.backend); + let label_selector = pipeline::label_selector_map(&args.backend)?; let local = progress::stage( "Loading local configuration...", @@ -86,13 +88,14 @@ async fn cmd_diff(args: DiffArgs) -> Result<(), CliError> { &args.files, &include, &exclude, + &label_selector, args.backend.managed_by_label, ), ) .await?; let remote = progress::stage( "Fetching remote configuration...", - pipeline::load_remote(backend.as_ref(), &include, &exclude), + pipeline::load_remote(backend.as_ref(), &include, &exclude, &label_selector), ) .await?; let events = progress::stage( @@ -110,6 +113,7 @@ async fn cmd_diff(args: DiffArgs) -> Result<(), CliError> { async fn cmd_sync(args: SyncArgs) -> Result<(), CliError> { let backend = pipeline::init_backend(&args.backend).await?; let (include, exclude) = pipeline::resource_type_sets(&args.backend); + let label_selector = pipeline::label_selector_map(&args.backend)?; let local = progress::stage( "Loading local configuration...", @@ -117,13 +121,14 @@ async fn cmd_sync(args: SyncArgs) -> Result<(), CliError> { &args.files, &include, &exclude, + &label_selector, args.backend.managed_by_label, ), ) .await?; let remote = progress::stage( "Fetching remote configuration...", - pipeline::load_remote(backend.as_ref(), &include, &exclude), + pipeline::load_remote(backend.as_ref(), &include, &exclude, &label_selector), ) .await?; let events = progress::stage( @@ -181,10 +186,17 @@ async fn cmd_sync(args: SyncArgs) -> Result<(), CliError> { } async fn cmd_lint(args: LintArgs) -> Result<(), CliError> { - let empty: HashSet = HashSet::new(); + let empty_types: HashSet = HashSet::new(); + let empty_labels = HashMap::new(); progress::stage( "Linting configuration...", - pipeline::load_local(&args.files, &empty, &empty, false), + pipeline::load_local( + &args.files, + &empty_types, + &empty_types, + &empty_labels, + false, + ), ) .await?; println!("Configuration is structurally valid."); @@ -194,6 +206,7 @@ async fn cmd_lint(args: LintArgs) -> Result<(), CliError> { async fn cmd_validate(args: ValidateArgs) -> Result<(), CliError> { let backend = pipeline::init_backend(&args.backend).await?; let (include, exclude) = pipeline::resource_type_sets(&args.backend); + let label_selector = pipeline::label_selector_map(&args.backend)?; let local = progress::stage( "Loading local configuration...", @@ -201,13 +214,14 @@ async fn cmd_validate(args: ValidateArgs) -> Result<(), CliError> { &args.files, &include, &exclude, + &label_selector, args.backend.managed_by_label, ), ) .await?; let remote = progress::stage( "Fetching remote configuration...", - pipeline::load_remote(backend.as_ref(), &include, &exclude), + pipeline::load_remote(backend.as_ref(), &include, &exclude, &label_selector), ) .await?; let events = progress::stage( diff --git a/rust/crates/adc-cli/src/pipeline.rs b/rust/crates/adc-cli/src/pipeline.rs index 7ab7d750..43782a21 100644 --- a/rust/crates/adc-cli/src/pipeline.rs +++ b/rust/crates/adc-cli/src/pipeline.rs @@ -5,10 +5,10 @@ //! job in the TS CLI, driven by its progress-rendering needs, which this //! CLI doesn't have yet). -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use adc_backend_core::{HttpClient, HttpClientConfig, TlsConfig}; +use adc_backend_core::{HttpClient, HttpClientConfig, ResourceFilter, TlsConfig}; use adc_differ::DifferV4; use adc_sdk::resources::Configuration; use adc_sdk::{Backend, Event, InternalConfiguration, ResourceType}; @@ -18,34 +18,54 @@ use crate::config; use crate::error::CliError; pub async fn init_backend(args: &BackendArgs) -> Result, CliError> { + let filter = resource_filter(args)?; match args.backend { BackendKind::Apisix => { - let token = args.token.clone().ok_or_else(|| { - CliError::msg("a backend token is required: pass --token or set ADC_TOKEN") - })?; - let ca_cert_pem = read_optional(&args.ca_cert_file).await?; - let client_cert_pem = read_optional(&args.tls_client_cert_file).await?; - let client_key_pem = read_optional(&args.tls_client_key_file).await?; - let client = HttpClient::new(HttpClientConfig { - server: args.server.clone(), - token, - timeout: Some(args.timeout), - tls: TlsConfig { - ca_cert_pem, - client_cert_pem, - client_key_pem, - skip_verify: args.tls_skip_verify, - }, - })?; - Ok(Box::new(adc_backend_apisix::Backend::new(client))) + let (client, _token) = build_client(args).await?; + Ok(Box::new(adc_backend_apisix::Backend::new(client, filter))) } - BackendKind::Api7Ee | BackendKind::ApisixStandalone => Err(CliError::msg(format!( - "backend \"{}\" is not yet implemented (only \"apisix\" is supported so far)", + BackendKind::Api7Ee => { + let (client, token) = build_client(args).await?; + Ok(Box::new(adc_backend_api7::Backend::new( + client, + args.gateway_group.clone(), + &token, + filter, + ))) + } + BackendKind::ApisixStandalone => Err(CliError::msg(format!( + "backend \"{}\" is not yet implemented (only \"apisix\"/\"api7ee\" are supported so far)", args.backend.as_str() ))), } } +/// Shared by every backend: the `X-API-KEY`/TLS-configured `HttpClient` +/// every one of them wraps. Returns the raw token alongside it — `api7ee` +/// needs it separately (to recognize an `a7adm-` admin token, which skips +/// gateway_group resolution entirely), not just baked into the client's +/// headers. +async fn build_client(args: &BackendArgs) -> Result<(HttpClient, String), CliError> { + let token = args.token.clone().ok_or_else(|| { + CliError::msg("a backend token is required: pass --token or set ADC_TOKEN") + })?; + let ca_cert_pem = read_optional(&args.ca_cert_file).await?; + let client_cert_pem = read_optional(&args.tls_client_cert_file).await?; + let client_key_pem = read_optional(&args.tls_client_key_file).await?; + let client = HttpClient::new(HttpClientConfig { + server: args.server.clone(), + token: token.clone(), + timeout: Some(args.timeout), + tls: TlsConfig { + ca_cert_pem, + client_cert_pem, + client_key_pem, + skip_verify: args.tls_skip_verify, + }, + })?; + Ok((client, token)) +} + async fn read_optional(path: &Option) -> Result>, CliError> { match path { Some(path) => Ok(Some(tokio::fs::read(path).await?)), @@ -67,6 +87,52 @@ pub fn resource_type_sets(args: &BackendArgs) -> (HashSet, HashSet (include, exclude) } +/// Parses `--label-selector key=value` entries into a map. Unconditionally +/// rejects `managed-by` as a key. +pub fn label_selector_map(args: &BackendArgs) -> Result, CliError> { + let selector = parse_label_selector(&args.label_selector)?; + if selector.contains_key(config::MANAGED_BY_LABEL_KEY) { + return Err(CliError::msg(format!( + "--label-selector cannot filter on \"{}\"", + config::MANAGED_BY_LABEL_KEY + ))); + } + Ok(selector) +} + +/// Rejects an entry without a `=` rather than silently dropping it — a typo +/// here should fail loudly, not quietly select nothing. +fn parse_label_selector(entries: &[String]) -> Result, CliError> { + entries + .iter() + .map(|entry| { + entry + .split_once('=') + .map(|(key, value)| (key.to_string(), value.to_string())) + .ok_or_else(|| { + CliError::msg(format!( + "invalid --label-selector \"{entry}\": expected \"key=value\"" + )) + }) + }) + .collect() +} + +/// The filter a backend applies at fetch time: skipping whole resource +/// types the request never needed, and (where the admin API supports it) +/// asking the server itself to narrow results by label. This is an +/// optimization only — `config::filter_resource_types`/`filter_by_labels` +/// still run afterward and are what actually guarantee the result matches. +fn resource_filter(args: &BackendArgs) -> Result { + let (include, exclude) = resource_type_sets(args); + let label_selector = label_selector_map(args)?; + Ok(ResourceFilter { + include, + exclude, + label_selector, + }) +} + /// Loads, merges, and structurally parses the local configuration file(s). /// Deserializing into `Configuration` here is the structural-validity gate /// (unknown fields, wrong types, missing required fields all reject) — the @@ -76,10 +142,12 @@ pub async fn load_local( files: &[PathBuf], include: &HashSet, exclude: &HashSet, + label_selector: &HashMap, managed_by_label: bool, ) -> Result { let files = config::read_files(files).await?; let mut merged = config::merge_files(files)?; + config::fill_labels(&mut merged, label_selector); if managed_by_label { config::inject_managed_by_label(&mut merged); } @@ -93,9 +161,11 @@ pub async fn load_remote( backend: &dyn Backend, include: &HashSet, exclude: &HashSet, + label_selector: &HashMap, ) -> Result { let mut configuration = backend.dump().await?; config::filter_resource_types(&mut configuration, include, exclude); + config::filter_by_labels(&mut configuration, label_selector); Ok(configuration) } @@ -121,3 +191,64 @@ fn to_diff_map(configuration: &Configuration) -> Result unreachable!("Configuration always serializes to a JSON object"), } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use crate::cli::BackendKind; + + use super::*; + + #[test] + fn parses_key_value_entries_into_a_map() { + let selector = parse_label_selector(&["env=prod".to_string(), "team=core".to_string()]).unwrap(); + assert_eq!(selector.get("env"), Some(&"prod".to_string())); + assert_eq!(selector.get("team"), Some(&"core".to_string())); + } + + #[test] + fn rejects_an_entry_with_no_equals_sign() { + assert!(parse_label_selector(&["not-a-pair".to_string()]).is_err()); + } + + #[test] + fn an_empty_selector_is_fine() { + assert!(parse_label_selector(&[]).unwrap().is_empty()); + } + + fn backend_args(label_selector: Vec) -> BackendArgs { + BackendArgs { + backend: BackendKind::Apisix, + server: "http://localhost:9180".to_string(), + token: None, + gateway_group: "default".to_string(), + label_selector, + include_resource_type: vec![], + exclude_resource_type: vec![], + timeout: Duration::from_secs(10), + ca_cert_file: None, + tls_client_cert_file: None, + tls_client_key_file: None, + tls_skip_verify: false, + managed_by_label: true, + } + } + + #[test] + fn rejects_managed_by_as_a_selector_key_regardless_of_the_value_supplied() { + let args = backend_args(vec!["managed-by=custom".to_string()]); + let error = label_selector_map(&args).unwrap_err(); + assert!(error.to_string().contains("managed-by"), "{error}"); + } + + #[test] + fn a_managed_by_label_selector_regression_is_rejected_outright() { + // --managed-by-label (the default) together with + // --label-selector managed-by= — this used to let the + // automatic stamp silently win over the selector's value; now it's + // rejected outright instead. + let args = backend_args(vec!["managed-by=custom".to_string()]); + assert!(label_selector_map(&args).is_err()); + } +} diff --git a/rust/crates/adc-sdk/src/resources/common.rs b/rust/crates/adc-sdk/src/resources/common.rs index a8b760f2..e9a491fc 100644 --- a/rust/crates/adc-sdk/src/resources/common.rs +++ b/rust/crates/adc-sdk/src/resources/common.rs @@ -26,11 +26,98 @@ pub type Plugins = serde_json::Map; /// evaluated by the gateway at request time. pub type Expr = Vec; +/// Serializes a whole-number `f64` as a bare JSON integer (`60`, not +/// `60.0`) — some gateways' own admin APIs unmarshal a handful of +/// nominally-numeric fields (timeouts, health-check counts, upstream node +/// priority) into a Go `int`, and reject a float-formatted literal outright +/// even when it's numerically a whole number. The field itself stays `f64` +/// (ADC's own schema for these fields isn't integer-constrained — a +/// genuinely fractional value still round-trips normally through this), +/// this only changes how a whole number happens to be spelled on the wire. +pub fn serialize_whole_number_as_integer( + value: &f64, + serializer: S, +) -> Result { + // `i64::MAX as f64` itself rounds up to `2f64.powi(63)` (an `f64` can't + // represent `i64::MAX` exactly), so comparing against it as an upper + // bound would let a value at or beyond `i64`'s actual range through — + // `as i64` on that saturates to `i64::MAX` instead of preserving the + // real value, silently corrupting it. Comparing against `2f64.powi(63)` + // directly (exclusive) is exact. + if value.fract() == 0.0 && value.is_finite() && value.abs() < 2f64.powi(63) { + serializer.serialize_i64(*value as i64) + } else { + serializer.serialize_f64(*value) + } +} + +/// [`serialize_whole_number_as_integer`] for an `Option` field paired +/// with `skip_serializing_if = "Option::is_none"` — only ever called with +/// `Some`, since serde skips the field entirely for `None` before this runs. +pub fn serialize_optional_whole_number_as_integer( + value: &Option, + serializer: S, +) -> Result { + serialize_whole_number_as_integer( + value.as_ref().expect("skip_serializing_if filters out None before this runs"), + serializer, + ) +} + /// Connect/send/read timeouts in seconds, shared by upstream and route configs. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Timeout { + #[serde(serialize_with = "serialize_whole_number_as_integer")] pub connect: f64, + #[serde(serialize_with = "serialize_whole_number_as_integer")] pub send: f64, + #[serde(serialize_with = "serialize_whole_number_as_integer")] pub read: f64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_whole_number_timeout_serializes_without_a_decimal_point() { + let timeout = Timeout { connect: 111.0, send: 222.0, read: 333.0 }; + assert_eq!( + serde_json::to_string(&timeout).unwrap(), + r#"{"connect":111,"send":222,"read":333}"# + ); + } + + #[test] + fn a_genuinely_fractional_timeout_still_serializes_as_a_float() { + let timeout = Timeout { connect: 1.5, send: 222.0, read: 333.0 }; + let json = serde_json::to_string(&timeout).unwrap(); + assert!(json.contains("\"connect\":1.5"), "{json}"); + } + + #[derive(Serialize)] + struct WholeNumber(#[serde(serialize_with = "serialize_whole_number_as_integer")] f64); + + #[test] + fn a_large_but_in_range_whole_number_still_serializes_as_an_integer() { + // `2^62` is exactly representable as both `f64` and `i64`, and well + // clear of the boundary this function has to guard. + let value = 2f64.powi(62); + let json = serde_json::to_string(&WholeNumber(value)).unwrap(); + assert_eq!(json, (2i64.pow(62)).to_string()); + } + + #[test] + fn a_value_at_two_to_the_63_falls_back_to_a_float_instead_of_an_incorrect_integer() { + // `2^63` is exactly `i64::MAX + 1` — out of `i64`'s range. Naively + // comparing against `i64::MAX as f64` (itself rounded up to `2^63`, + // since `i64::MAX` isn't exactly representable as an `f64`) would + // let this through and `as i64` would silently saturate it down to + // `i64::MAX` — a wrong value. It must fall back to `f64` instead. + let value = 2f64.powi(63); + let json = serde_json::to_string(&WholeNumber(value)).unwrap(); + assert_ne!(json, i64::MAX.to_string(), "{json}"); + assert_eq!(json.parse::().unwrap(), value); + } +} diff --git a/rust/crates/adc-sdk/src/resources/mod.rs b/rust/crates/adc-sdk/src/resources/mod.rs index d03347b1..fd3ee10c 100644 --- a/rust/crates/adc-sdk/src/resources/mod.rs +++ b/rust/crates/adc-sdk/src/resources/mod.rs @@ -21,7 +21,10 @@ pub mod service; pub mod ssl; pub mod upstream; -pub use common::{Expr, Labels, LabelValue, Plugin, Plugins, Timeout}; +pub use common::{ + Expr, Labels, LabelValue, Plugin, Plugins, Timeout, serialize_optional_whole_number_as_integer, + serialize_whole_number_as_integer, +}; pub use consumer::{Consumer, ConsumerCredential, ConsumerGroup}; pub use route::{HttpMethod, Route, StreamRoute}; pub use service::{Service, ServiceRoutes}; diff --git a/rust/crates/adc-sdk/src/resources/upstream.rs b/rust/crates/adc-sdk/src/resources/upstream.rs index bf6005cb..33488ae3 100644 --- a/rust/crates/adc-sdk/src/resources/upstream.rs +++ b/rust/crates/adc-sdk/src/resources/upstream.rs @@ -91,8 +91,8 @@ fn default_interval() -> u32 { fn default_active_timeout() -> f64 { 1.0 } -fn default_concurrency() -> f64 { - 10.0 +fn default_concurrency() -> i64 { + 10 } fn default_http_path() -> String { "/".to_string() @@ -114,8 +114,12 @@ pub struct UpstreamNode { pub host: String, pub port: u32, pub weight: i64, + // A count, not a duration: unlike `Timeout`/`retry_timeout`, there's no + // real-world fractional priority — matches the gateway's own schema + // (`nodes[].priority` is `type = "integer"`), even though ADC's own Zod + // schema doesn't bother declaring `.int()` here. #[serde(default)] - pub priority: f64, + pub priority: i64, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, } @@ -181,10 +185,13 @@ pub struct UpstreamHealthCheckActiveUnhealthy { pub struct UpstreamHealthCheckActive { #[serde(rename = "type", default)] pub r#type: UpstreamHealthCheckType, - #[serde(default = "default_active_timeout")] + #[serde(default = "default_active_timeout", serialize_with = "super::common::serialize_whole_number_as_integer")] pub timeout: f64, + // A count, not a duration: matches the gateway's own schema + // (`concurrency` is `type = "integer"` there), even though ADC's own + // Zod schema doesn't bother declaring `.int()` here. #[serde(default = "default_concurrency")] - pub concurrency: f64, + pub concurrency: i64, #[serde(skip_serializing_if = "Option::is_none")] pub host: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -234,7 +241,7 @@ pub struct UpstreamHealthCheck { pub struct UpstreamKeepalivePool { #[serde(default = "default_keepalive_pool_size")] pub size: u32, - #[serde(default = "default_keepalive_idle_timeout")] + #[serde(default = "default_keepalive_idle_timeout", serialize_with = "super::common::serialize_whole_number_as_integer")] pub idle_timeout: f64, #[serde(default = "default_keepalive_requests")] pub requests: u32, @@ -284,7 +291,10 @@ pub struct Upstream { pub scheme: UpstreamScheme, #[serde(skip_serializing_if = "Option::is_none")] pub retries: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "super::common::serialize_optional_whole_number_as_integer" + )] pub retry_timeout: Option, #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, diff --git a/rust/crates/adc-sdk/tests/resources_from_fixtures.rs b/rust/crates/adc-sdk/tests/resources_from_fixtures.rs index 88ea82cd..e845ffa3 100644 --- a/rust/crates/adc-sdk/tests/resources_from_fixtures.rs +++ b/rust/crates/adc-sdk/tests/resources_from_fixtures.rs @@ -75,7 +75,7 @@ fn deserializes_full_health_check_block() { let checks_patch = f["defaultValue"]["core"]["service"]["upstream"]["checks"].clone(); let checks: UpstreamHealthCheck = serde_json::from_value(checks_patch).expect("deserialize checks"); - assert_eq!(checks.active.concurrency, 10.0); + assert_eq!(checks.active.concurrency, 10); let active_healthy = checks.active.healthy.expect("active.healthy"); assert_eq!(active_healthy.successes, 2); let passive = checks.passive.expect("passive");