diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 2fb6a559..ae38f765 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -135,6 +135,28 @@ jobs: # Run E2E tests - name: Run E2E tests run: npx nx run backend-apisix-standalone:test + + # Run the Rust port's E2E tests against the same live cluster. Each + # test function restarts every instance itself before it starts + # (see adc-backend-apisix-standalone's tests/common/mod.rs), so + # running after the TS suite here is safe — nothing from the TS + # suite's own state survives into the Rust tests. + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: rust -> target + - name: Run Rust E2E tests + working-directory: ./rust + run: | + rustup update stable + rustup default stable + cargo test -p adc-backend-apisix-standalone -- --ignored --test-threads=1 + # Only useful when the step above fails: a bare 404 from an admin API + # request gives no clue why on its own — the container's own error + # log does. + - name: Dump APISIX standalone container logs + if: failure() + working-directory: ./libs/backend-apisix-standalone/e2e/assets + run: docker compose logs --no-color api7: runs-on: ubuntu-latest if: contains(github.event.pull_request.labels.*.name, 'test/api7') || github.event_name == 'push' diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9e87f722..fada453b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -38,6 +38,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "adc-backend-apisix-standalone" +version = "0.29.0" +dependencies = [ + "adc-backend-apisix", + "adc-backend-apisix-standalone", + "adc-backend-core", + "adc-differ", + "adc-sdk", + "async-trait", + "dashmap", + "indexmap", + "log", + "semver", + "serde", + "serde_json", + "sha1", + "tokio", + "tracing", +] + [[package]] name = "adc-backend-core" version = "0.29.0" @@ -578,6 +599,20 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.5.8" @@ -796,6 +831,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1062,7 +1103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1141,6 +1182,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.33" @@ -1233,6 +1283,19 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1436,6 +1499,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.13.1" @@ -1581,6 +1653,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ea64b2bc..a6df7c39 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/adc-backend-core", "crates/adc-backend-apisix", "crates/adc-backend-api7", + "crates/adc-backend-apisix-standalone", "crates/adc-sync-bench", "crates/adc-mock-server", "crates/adc-cli", diff --git a/rust/crates/adc-backend-api7/src/operator.rs b/rust/crates/adc-backend-api7/src/operator.rs index fdadb2cf..572fe9cb 100644 --- a/rust/crates/adc-backend-api7/src/operator.rs +++ b/rust/crates/adc-backend-api7/src/operator.rs @@ -80,7 +80,7 @@ impl Operator { Ok(result) => results.push(result), Err((event, error)) => results.push(BackendSyncResult { success: false, - event, + event: Some(event), error: Some(error), server: None, }), @@ -120,7 +120,7 @@ impl Operator { match outcome { Ok(()) => Ok(BackendSyncResult { success: true, - event, + event: Some(event), error: None, server: None, }), diff --git a/rust/crates/adc-backend-apisix-standalone/Cargo.toml b/rust/crates/adc-backend-apisix-standalone/Cargo.toml new file mode 100644 index 00000000..5d3dfef1 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "adc-backend-apisix-standalone" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true + +[features] +# Exposes `adc_backend_apisix_standalone::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" } +adc-backend-apisix = { path = "../adc-backend-apisix" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +semver = { workspace = true } +sha1 = { workspace = true } +dashmap = "6" +indexmap = "2" +tokio = { workspace = true, features = ["macros", "sync"] } +log = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +adc-backend-apisix-standalone = { path = ".", features = ["test-utils"] } +adc-differ = { path = "../adc-differ" } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time", "process"] } diff --git a/rust/crates/adc-backend-apisix-standalone/src/backend.rs b/rust/crates/adc-backend-apisix-standalone/src/backend.rs new file mode 100644 index 00000000..52ac287f --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/src/backend.rs @@ -0,0 +1,226 @@ +//! The apisix-standalone `Backend`: unlike `adc-backend-apisix`/ +//! `adc-backend-api7` (one admin API, one target), a standalone cluster is +//! *n* independently-addressed servers that must all end up holding the +//! same declarative config document — `dump` picks whichever one has the +//! most recently accepted write, `sync` writes the new document to every +//! one of them. + +use std::time::Duration; + +use adc_backend_core::{HttpClient, HttpClientConfig, Method, TlsConfig}; +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::cache::Cache; +use crate::fetcher::Fetcher; +use crate::operator::Operator; +use crate::typing::ApisixStandalone; + +/// One target server plus the client already configured with its own +/// (server-specific, since standalone clusters can each carry a different +/// admin API token) auth header. +#[derive(Clone)] +pub struct StandaloneServer { + pub server: String, + pub client: HttpClient, +} + +pub struct BackendOptions { + /// At least one entry, each a full admin API base URL + /// (`http://host:port`) — every entry gets written to on `sync` and is + /// a candidate for `dump`'s "most recently updated" pick. + pub servers: Vec, + /// Either one token shared by every server, or exactly as many tokens + /// as `servers` (paired up positionally) — matches the TS backend's own + /// `opts.token.split(',')` convention. + pub tokens: Vec, + /// Identifies this backend's entry in the process-wide config cache + /// (`crate::cache::Cache`) — callers targeting the same standalone + /// cluster across multiple `Backend` instances should pass the same + /// key, so they share cached state instead of each re-bootstrapping it. + pub cache_key: String, + /// Forces the next `dump` to discard whatever's cached for `cache_key` + /// and re-fetch from the cluster, instead of trusting the cache. + pub bypass_cache: bool, + pub timeout: Option, + pub tls: TlsConfig, +} + +/// Stands in for a version that couldn't be determined (missing/unparseable +/// `Server` header) — high enough to unlock every version-gated code path, +/// matching the TS backend's own `mockVersion` convention. +const UNKNOWN_VERSION: Version = Version::new(999, 999, 999); + +pub struct Backend { + servers: Vec, + cache_key: String, + bypass_cache: bool, + version: OnceCell, +} + +impl Backend { + pub fn new(opts: BackendOptions) -> Result { + if opts.servers.is_empty() { + return Err(BackendError::Other( + "apisix-standalone backend requires at least one server".into(), + )); + } + let servers_count = opts.servers.len(); + // A `token` per `server`, positionally paired, when the two lists + // are the same length; otherwise every server shares `tokens[0]` — + // matches the TS backend's own `opts.token.split(',')` convention. + let paired_tokens = opts.tokens.len() == servers_count; + + let servers = opts + .servers + .into_iter() + .enumerate() + .map(|(index, server)| { + let token = if paired_tokens { opts.tokens.get(index) } else { opts.tokens.first() } + .cloned() + .ok_or_else(|| BackendError::Other("apisix-standalone backend requires at least one token".into()))?; + let client = HttpClient::new(HttpClientConfig { + server: server.clone(), + token, + timeout: opts.timeout, + tls: opts.tls.clone(), + })? + .with_log_scope(vec!["APISIX".to_string()]); + Ok(StandaloneServer { server, client }) + }) + .collect::, BackendError>>()?; + + Ok(Self { + servers, + cache_key: opts.cache_key, + bypass_cache: opts.bypass_cache, + version: OnceCell::new(), + }) + } + + async fn resolved_version(&self) -> Result { + if let Some(version) = self.version.get() { + return Ok(version.clone()); + } + if let Some(version) = Cache::global().version(&self.cache_key) { + let _ = self.version.set(version.clone()); + return Ok(version); + } + + let version = self + .version + .get_or_try_init(|| async { + let primary = &self.servers[0]; + // HEAD support on the config document endpoint is itself + // version-gated (see `Fetcher::find_latest`'s + // `version_supports_head`), so the version probe uses the + // admin root instead, which has none of that ambiguity. + let request = primary.client.request(Method::HEAD, "/apisix/admin")?; + let response = primary.client.send(request).await?; + + let header = response + .headers() + .get("server") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("APISIX/")); + Ok::<_, BackendError>(match header.map(Version::parse) { + Some(Ok(version)) => version, + _ => UNKNOWN_VERSION, + }) + }) + .await?; + // Only cache a genuinely observed version, not the "couldn't tell" + // fallback — matches the TS backend's own `semverEQ(version, + // mockVersion)` guard. + if *version != UNKNOWN_VERSION { + Cache::global().set_version(&self.cache_key, version.clone()); + } + Ok(version.clone()) + } +} + +#[async_trait] +impl adc_sdk::Backend for Backend { + fn metadata(&self) -> BackendMetadata { + BackendMetadata { + log_scope: vec!["APISIX".to_string()], + } + } + + async fn ping(&self) -> Result<(), BackendError> { + let primary = &self.servers[0]; + let request = primary.client.request(Method::HEAD, "/apisix/admin")?; + primary.client.send(request).await?; + Ok(()) + } + + async fn version(&self) -> Result { + self.resolved_version().await + } + + async fn default_value(&self) -> Result { + Ok(DefaultValue::default()) + } + + async fn dump(&self) -> Result { + if self.bypass_cache { + Cache::global().invalidate(&self.cache_key); + } + if let Some(config) = Cache::global().config(&self.cache_key) { + return Ok(config); + } + + let version = self.resolved_version().await?; + let (config, raw_config) = Fetcher::new(self.servers.clone(), version).dump().await?; + + Cache::global().set_latest_version(&self.cache_key, highest_conf_version(&raw_config)); + Cache::global().set_config(&self.cache_key, config.clone()); + Cache::global().set_raw_config(&self.cache_key, raw_config); + Ok(config) + } + + async fn sync( + &self, + events: Vec, + opts: BackendSyncOptions, + ) -> Result, BackendError> { + let old_raw_config = Cache::global().raw_config(&self.cache_key).unwrap_or_default(); + Operator::new(self.servers.clone(), self.cache_key.clone(), old_raw_config) + .sync(events, opts) + .await + } + + async fn validate(&self, events: &[Event]) -> Result { + adc_backend_apisix::Validator::new(self.servers[0].client.clone()) + .validate(events) + .await + } +} + +/// The version cache primes off whichever `*_conf_version` field is +/// numerically highest across the whole document — there's no single +/// document-wide version, just one counter per resource collection, and the +/// highest one is what a subsequent `sync` must not regress below (see +/// `crate::operator::Operator::sync`'s clock-rollback guard). +fn highest_conf_version(raw_config: &ApisixStandalone) -> i64 { + [ + raw_config.routes_conf_version, + raw_config.services_conf_version, + raw_config.consumers_conf_version, + raw_config.ssls_conf_version, + raw_config.global_rules_conf_version, + raw_config.plugin_metadata_conf_version, + raw_config.upstreams_conf_version, + raw_config.stream_routes_conf_version, + ] + .into_iter() + .flatten() + .max() + .unwrap_or(0) +} diff --git a/rust/crates/adc-backend-apisix-standalone/src/cache.rs b/rust/crates/adc-backend-apisix-standalone/src/cache.rs new file mode 100644 index 00000000..9a39046d --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/src/cache.rs @@ -0,0 +1,305 @@ +//! Cross-request cache of each standalone target's resolved state, keyed by +//! a caller-supplied `cache_key` (typically derived from its server list). +//! Mirrors the TS backend's module-level `lru-cache` singletons — a real +//! process-wide cache, not per-`Backend`-instance state, so that +//! long-lived callers (e.g. an ingress-server handling many requests +//! against the same standalone target) don't pay for a fresh +//! "find the latest server + full dump" bootstrap on every request. +//! +//! Structurally simpler than the TS version: one entry per `cache_key` +//! holding all four cached values together (version/latest_version/ +//! config/raw_config), rather than four independent `lru-cache` instances — +//! they're always read and written in the same lifecycle anyway (see +//! `crate::backend::Backend::dump`/`sync`), so there's no case where one +//! would legitimately expire or evict independently of the others. + +use std::sync::LazyLock; +use std::time::{Duration, Instant}; + +use adc_sdk::resources::Configuration; +use dashmap::DashMap; +use semver::Version; + +use crate::typing::ApisixStandalone; + +const DEFAULT_MAX_ENTRIES: usize = 16; +const DEFAULT_TTL_MS: u64 = 3_600_000; + +fn env_max_entries() -> usize { + std::env::var("ADC_APISIX_STANDALONE_CACHE_MAX") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|v| *v >= 1) + .unwrap_or(DEFAULT_MAX_ENTRIES) +} + +fn env_ttl() -> Duration { + let ms = std::env::var("ADC_APISIX_STANDALONE_CACHE_TTL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|v: &u64| *v >= 1) + .unwrap_or(DEFAULT_TTL_MS); + Duration::from_millis(ms) +} + +#[derive(Clone, Default)] +struct CachedEntry { + version: Option, + latest_version: Option, + config: Option, + raw_config: Option, + updated_at: Option, +} + +fn is_expired(entry: &CachedEntry, ttl: Duration) -> bool { + entry.updated_at.is_none_or(|at| at.elapsed() > ttl) +} + +pub struct Cache { + entries: DashMap, + max_entries: usize, + ttl: Duration, +} + +static GLOBAL: LazyLock = LazyLock::new(|| Cache::with_limits(env_max_entries(), env_ttl())); + +impl Cache { + pub fn with_limits(max_entries: usize, ttl: Duration) -> Self { + Self { + entries: DashMap::new(), + max_entries: max_entries.max(1), + ttl, + } + } + + /// The process-wide singleton every `Backend` instance reads/writes by + /// default. Test code should use [`Self::with_limits`] instead — an + /// isolated instance, not this one, since tests running in the same + /// process would otherwise share (and race on) cache state keyed by + /// whatever `cache_key` happens to collide. + pub fn global() -> &'static Cache { + &GLOBAL + } + + fn get_live(&self, key: &str) -> Option { + let entry = self.entries.get(key)?; + if !is_expired(&entry, self.ttl) { + return Some(entry.clone()); + } + drop(entry); + // Rechecks expiry at removal time rather than removing + // unconditionally by key: between the drop above and this call, a + // concurrent writer could have refreshed this same entry — removing + // it unconditionally would discard that fresh write, not just the + // stale one this call actually observed. + self.entries.remove_if(key, |_, entry| is_expired(entry, self.ttl)); + None + } + + pub fn version(&self, key: &str) -> Option { + self.get_live(key)?.version + } + + pub fn latest_version(&self, key: &str) -> Option { + self.get_live(key)?.latest_version + } + + pub fn config(&self, key: &str) -> Option { + self.get_live(key)?.config + } + + pub fn raw_config(&self, key: &str) -> Option { + self.get_live(key)?.raw_config + } + + fn touch(&self, key: &str, apply: impl FnOnce(&mut CachedEntry)) { + { + let mut entry = self.entries.entry(key.to_string()).or_default(); + entry.updated_at = Some(Instant::now()); + apply(&mut entry); + } + self.evict_if_over_capacity(); + } + + pub fn set_version(&self, key: &str, version: Version) { + self.touch(key, |entry| entry.version = Some(version)); + } + + /// Bumps the cached version to `value`, never below whatever's already + /// there. A plain overwrite would let a slower concurrent + /// `Operator::sync` call — one that read an older `latest_version` + /// before a faster call raced ahead and wrote a newer one — regress the + /// cache back down once *it* finishes and writes its own (smaller) + /// value. That regression isn't just a stale cache entry: a later + /// sync's clock-rollback guard reads this value to pick its own + /// timestamp, so a regressed value here could produce a + /// `*_conf_version` the data plane has already seen (and rejects). + pub fn set_latest_version(&self, key: &str, value: i64) { + self.touch(key, |entry| { + entry.latest_version = Some(entry.latest_version.map_or(value, |current| current.max(value))); + }); + } + + pub fn set_config(&self, key: &str, config: Configuration) { + self.touch(key, |entry| entry.config = Some(config)); + } + + pub fn set_raw_config(&self, key: &str, raw_config: ApisixStandalone) { + self.touch(key, |entry| entry.raw_config = Some(raw_config)); + } + + pub fn invalidate(&self, key: &str) { + self.entries.remove(key); + } + + /// A soft, best-effort cap: eviction reads `updated_at` timestamps + /// without coordinating with concurrent inserts, so under concurrent + /// writers the map can transiently hold a couple more entries than + /// `max_entries` before the next call trims it back down. That's fine — + /// this exists to bound long-run memory growth, not to enforce an exact + /// invariant. + fn evict_if_over_capacity(&self) { + while self.entries.len() > self.max_entries { + let oldest = self + .entries + .iter() + .min_by_key(|entry| entry.updated_at) + .map(|entry| entry.key().clone()); + match oldest { + Some(key) => { + self.entries.remove(&key); + } + None => break, + } + } + } +} + +#[cfg(test)] +mod tests { + use std::thread::sleep; + + use super::*; + + #[test] + fn a_fresh_cache_has_nothing_cached() { + let cache = Cache::with_limits(16, Duration::from_secs(3600)); + assert!(cache.version("k").is_none()); + assert!(cache.config("k").is_none()); + } + + #[test] + fn set_then_get_round_trips_within_ttl() { + let cache = Cache::with_limits(16, Duration::from_secs(3600)); + cache.set_latest_version("k", 42); + assert_eq!(cache.latest_version("k"), Some(42)); + } + + #[test] + fn writing_a_smaller_version_never_regresses_the_cached_one() { + let cache = Cache::with_limits(16, Duration::from_secs(3600)); + cache.set_latest_version("k", 100); + // Simulates a slower concurrent `Operator::sync` call that decided + // on an older timestamp before a faster one raced ahead and wrote a + // newer value, only landing its own write afterward. + cache.set_latest_version("k", 50); + assert_eq!(cache.latest_version("k"), Some(100)); + } + + /// Regression coverage for a real bug this crate's design guards + /// against: many `Operator::sync` calls racing on the same `cache_key` + /// must leave the cached `latest_version` at the highest timestamp any + /// of them ever wrote, regardless of which one's write happens to land + /// last. Runs on a genuine multi-threaded runtime (not + /// `current_thread`) so the writes actually interleave across OS + /// threads rather than just cooperatively yielding on one. + #[tokio::test(flavor = "multi_thread", worker_threads = 8)] + async fn concurrent_writers_racing_on_the_same_key_never_regress_the_cached_version() { + let cache = std::sync::Arc::new(Cache::with_limits(16, Duration::from_secs(3600))); + let values: Vec = (0..200).map(|i| (i * 7919) % 1000).collect(); + let expected_max = *values.iter().max().unwrap(); + + let mut tasks = tokio::task::JoinSet::new(); + for value in values { + let cache = cache.clone(); + tasks.spawn(async move { + // Jitter completion order so writers genuinely interleave + // instead of running in the order they were spawned. + tokio::time::sleep(Duration::from_micros((value as u64 * 37) % 500)).await; + cache.set_latest_version("k", value); + }); + } + tasks.join_all().await; + + assert_eq!(cache.latest_version("k"), Some(expected_max)); + } + + #[test] + fn an_entry_older_than_the_ttl_is_treated_as_absent() { + let cache = Cache::with_limits(16, Duration::from_millis(10)); + cache.set_latest_version("k", 1); + sleep(Duration::from_millis(30)); + assert!(cache.latest_version("k").is_none()); + } + + fn empty_configuration() -> Configuration { + Configuration { + services: None, + ssls: None, + consumers: None, + consumer_groups: None, + global_rules: None, + plugin_metadata: None, + } + } + + #[test] + fn invalidate_removes_every_cached_field_for_that_key() { + let cache = Cache::with_limits(16, Duration::from_secs(3600)); + cache.set_latest_version("k", 1); + cache.set_config("k", empty_configuration()); + cache.invalidate("k"); + assert!(cache.latest_version("k").is_none()); + assert!(cache.config("k").is_none()); + } + + #[test] + fn different_keys_are_cached_independently() { + let cache = Cache::with_limits(16, Duration::from_secs(3600)); + cache.set_latest_version("a", 1); + cache.set_latest_version("b", 2); + assert_eq!(cache.latest_version("a"), Some(1)); + assert_eq!(cache.latest_version("b"), Some(2)); + } + + #[test] + fn inserting_past_capacity_evicts_the_least_recently_touched_key() { + let cache = Cache::with_limits(2, Duration::from_secs(3600)); + cache.set_latest_version("a", 1); + sleep(Duration::from_millis(5)); + cache.set_latest_version("b", 2); + sleep(Duration::from_millis(5)); + cache.set_latest_version("c", 3); + + assert!(cache.latest_version("a").is_none()); + assert_eq!(cache.latest_version("b"), Some(2)); + assert_eq!(cache.latest_version("c"), Some(3)); + } + + #[test] + fn re_touching_a_key_protects_it_from_eviction() { + let cache = Cache::with_limits(2, Duration::from_secs(3600)); + cache.set_latest_version("a", 1); + sleep(Duration::from_millis(5)); + cache.set_latest_version("b", 2); + sleep(Duration::from_millis(5)); + // Re-touch "a" so "b" becomes the least recently touched instead. + cache.set_latest_version("a", 10); + sleep(Duration::from_millis(5)); + cache.set_latest_version("c", 3); + + assert_eq!(cache.latest_version("a"), Some(10)); + assert!(cache.latest_version("b").is_none()); + assert_eq!(cache.latest_version("c"), Some(3)); + } +} diff --git a/rust/crates/adc-backend-apisix-standalone/src/fetcher.rs b/rust/crates/adc-backend-apisix-standalone/src/fetcher.rs new file mode 100644 index 00000000..515237e7 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/src/fetcher.rs @@ -0,0 +1,116 @@ +//! Fetching a standalone cluster's current config: which server has the +//! most recently accepted write (`find_latest`), then pulling that server's +//! full config document (`dump`). + +use adc_backend_core::{Method, concurrent_map}; +use adc_sdk::BackendError; +use adc_sdk::resources::Configuration; +use semver::Version; + +use crate::backend::StandaloneServer; +use crate::transformer; +use crate::typing::ApisixStandalone; + +const ENDPOINT_CONFIG: &str = "/apisix/admin/configs"; +const HEADER_LAST_MODIFIED: &str = "x-last-modified"; + +/// APISIX standalone versions above 3.13.0 accept `HEAD` on the config +/// endpoint (a cheaper way to read just the `X-Last-Modified` header); +/// older ones don't implement `HEAD` for it at all, so `find_latest` falls +/// back to a full `GET` there. +const HEAD_SUPPORTED_SINCE: (u64, u64, u64) = (3, 13, 0); + +pub struct Fetcher { + servers: Vec, + version: Version, +} + +impl Fetcher { + pub fn new(servers: Vec, version: Version) -> Self { + Self { servers, version } + } + + /// Pulls the full config document from whichever server + /// [`Self::find_latest`] picks (or the first configured server, if none + /// of them has ever accepted a write yet), and converts it into ADC's + /// model. + pub async fn dump(&self) -> Result<(Configuration, ApisixStandalone), BackendError> { + let target = match self.find_latest().await? { + Some(server) => server, + None => self + .servers + .first() + .ok_or_else(no_servers_configured)? + .server + .clone(), + }; + let client = &self + .servers + .iter() + .find(|s| s.server == target) + .expect("find_latest only ever returns a server from self.servers") + .client; + + let request = client.request(Method::GET, ENDPOINT_CONFIG)?; + let raw_config: ApisixStandalone = client.send_json(request).await?; + let config = transformer::to_adc(&raw_config); + Ok((config, raw_config)) + } + + /// Finds which server holds the most recently accepted write, going by + /// each one's `X-Last-Modified` response header (a timestamp the server + /// stamps on every config it stores). `None` means no server has ever + /// accepted a write (every one reports timestamp `0` — a fresh + /// cluster), not that a request failed — a request failure is still + /// propagated as `Err`. + async fn find_latest(&self) -> Result, BackendError> { + let method = if version_supports_head(&self.version) { Method::HEAD } else { Method::GET }; + + let probe = |server: StandaloneServer| { + let method = method.clone(); + async move { + let request = server.client.request(method, ENDPOINT_CONFIG)?; + let response = server.client.send(request).await?; + let timestamp = response + .headers() + .get(HEADER_LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + Ok::<_, BackendError>((server.server, timestamp)) + } + }; + let results = concurrent_map(self.servers.clone(), None, probe).await; + + let mut latest: Option<(String, i64)> = None; + for result in results { + let (server, timestamp) = result?; + if latest.as_ref().is_none_or(|(_, best)| timestamp >= *best) { + latest = Some((server, timestamp)); + } + } + + Ok(latest.filter(|(_, timestamp)| *timestamp > 0).map(|(server, _)| server)) + } +} + +fn version_supports_head(version: &Version) -> bool { + let (major, minor, patch) = HEAD_SUPPORTED_SINCE; + *version > Version::new(major, minor, patch) +} + +fn no_servers_configured() -> BackendError { + BackendError::Other("apisix-standalone backend has no servers configured".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn head_is_only_used_strictly_above_the_version_cutoff() { + assert!(!version_supports_head(&Version::new(3, 13, 0))); + assert!(version_supports_head(&Version::new(3, 13, 1))); + assert!(!version_supports_head(&Version::new(3, 12, 9))); + } +} diff --git a/rust/crates/adc-backend-apisix-standalone/src/lib.rs b/rust/crates/adc-backend-apisix-standalone/src/lib.rs new file mode 100644 index 00000000..9ade4cfe --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/src/lib.rs @@ -0,0 +1,25 @@ +mod backend; +mod cache; +mod fetcher; +mod operator; +mod transformer; +mod typing; +mod utils; + +pub use backend::{Backend, BackendOptions}; + +#[cfg(feature = "test-utils")] +#[doc(hidden)] +pub mod tests { + pub use crate::backend::StandaloneServer; + pub use crate::cache::Cache; + pub use crate::fetcher::Fetcher; + pub use crate::operator::Operator; + + pub mod transformer { + pub use crate::transformer::*; + } + pub mod typing { + pub use crate::typing::*; + } +} diff --git a/rust/crates/adc-backend-apisix-standalone/src/operator.rs b/rust/crates/adc-backend-apisix-standalone/src/operator.rs new file mode 100644 index 00000000..ab246591 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/src/operator.rs @@ -0,0 +1,1026 @@ +//! Applying a differ's `Event`s to a standalone cluster: `sync`. +//! +//! Unlike `adc-backend-apisix`/`adc-backend-api7` (one HTTP request per +//! event), standalone has no per-resource admin API at all — every event +//! is folded into one in-memory config document (cloned from whatever was +//! cached from the last dump/sync), then that *whole document* is written +//! to every server with a single `PUT /apisix/admin/configs`. So there's +//! one `BackendSyncResult` per *server*, not per event (`event` on each +//! result is always `None` — no single event owns that write), and +//! `BackendSyncOptions::concurrent` (which bounds how many *events* run at +//! once in the other two backends) has nothing to bound here; the only +//! fan-out is across servers, and that's always unbounded — matches the TS +//! operator's own bare (no concurrency argument) `mergeMap`. + +use std::collections::{HashMap, HashSet}; + +use adc_backend_core::{HttpClient, Method, concurrent_map, concurrent_map_until_err}; +use adc_sdk::resources::{self as adc}; +use adc_sdk::{BackendError, BackendSyncOptions, BackendSyncResult, Event, EventType, PathSegment, ResourceType, ValueDiff}; +use serde_json::{Map, Value}; +use sha1::{Digest, Sha1}; + +use crate::backend::StandaloneServer; +use crate::cache::Cache; +use crate::typing::{self, ApisixStandalone, ConsumerOrCredential}; +use crate::utils::stable_timestamp; + +const CONFIG_ENDPOINT: &str = "/apisix/admin/configs"; +const HEADER_DIGEST: &str = "x-digest"; + +pub struct Operator { + servers: Vec, + cache_key: String, + old_raw_config: ApisixStandalone, +} + +impl Operator { + pub fn new(servers: Vec, cache_key: String, old_raw_config: ApisixStandalone) -> Self { + Self { servers, cache_key, old_raw_config } + } + + pub async fn sync(&self, events: Vec, opts: BackendSyncOptions) -> Result, BackendError> { + let mut new_config = self.old_raw_config.clone(); + let mut increase_version: HashSet = HashSet::new(); + + // An always-advancing wall-clock read would normally never regress + // on its own, but this process isn't the only writer — an earlier + // `sync` (in this process or another) may have already pushed the + // cluster's config to a version at or ahead of what the wall clock + // reads right now: either a real clock rollback, or simply two + // syncs landing in the same millisecond (millisecond resolution is + // coarse enough for this to happen under normal, fast-succession + // load, not just as a clock-skew edge case). Either way, clamping + // to one past the latest known version keeps every write both + // acceptable to the data plane and strictly increasing. + let timestamp = resolve_sync_timestamp(stable_timestamp(), Cache::global().latest_version(&self.cache_key)); + + for event in &events { + apply_event_for_service_inlined_upstream(&mut new_config, &mut increase_version, timestamp, event)?; + apply_event(&mut new_config, &mut increase_version, timestamp, event)?; + } + + filter_orphan_credentials(&mut new_config); + bump_conf_versions(&mut new_config, &increase_version, timestamp); + + let body = serde_json::to_string(&new_config) + .map_err(|e| BackendError::Serialization(format!("encoding sync config: {e}")))?; + let digest = sha1_hex(body.as_bytes()); + + let put = |server: StandaloneServer| { + let body = body.clone(); + let digest = digest.clone(); + async move { + match put_one(&server.client, body, digest).await { + Ok(()) => Ok(BackendSyncResult { success: true, event: None, error: None, server: Some(server.server) }), + Err(error) => Err((server.server, error)), + } + } + }; + + let exit_on_failure = opts.exit_on_failure.unwrap_or(true); + let results = if exit_on_failure { + match concurrent_map_until_err(self.servers.clone(), None, put).await { + Ok(results) => results, + Err((_, error)) => { + // A server earlier in the batch may have already + // accepted the new document before a later one failed + // and aborted the rest — the cache's idea of "current + // state" can't be trusted either way at that point, so + // it's cleared rather than left pointing at data a live + // server may have already moved past. The next dump() + // re-fetches and re-runs `find_latest` to discover the + // cluster's real state instead of trusting stale cache. + Cache::global().invalidate(&self.cache_key); + return Err(error); + } + } + } else { + concurrent_map(self.servers.clone(), None, put) + .await + .into_iter() + .map(|outcome| match outcome { + Ok(result) => result, + Err((server, error)) => BackendSyncResult { success: false, event: None, error: Some(error), server: Some(server) }, + }) + .collect() + }; + + // Updated once, after every server has settled, rather than + // per-server as each PUT completes: with concurrent writers, "cache + // whatever the most recently completed request happened to see" + // has no coherent meaning — completion order isn't sync order. + // "At least one server accepted the write" is a real, checkable + // fact to key the update on instead. + if results.iter().any(|result| result.success) { + Cache::global().set_latest_version(&self.cache_key, timestamp); + Cache::global().set_config(&self.cache_key, crate::transformer::to_adc(&new_config)); + Cache::global().set_raw_config(&self.cache_key, new_config); + } + + Ok(results) + } +} + +async fn put_one(client: &HttpClient, body: String, digest: String) -> Result<(), BackendError> { + let request = client.request(Method::PUT, CONFIG_ENDPOINT)?.header(HEADER_DIGEST, digest).body(body); + client.send(request).await?; + Ok(()) +} + +fn sha1_hex(bytes: &[u8]) -> String { + let mut hasher = Sha1::new(); + hasher.update(bytes); + hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Never below `latest_known` — clamps `now` up to one past it whenever +/// `now` isn't already strictly ahead, covering both a real clock rollback +/// and two syncs landing in the same wall-clock millisecond alike. +fn resolve_sync_timestamp(now: i64, latest_known: Option) -> i64 { + match latest_known { + Some(latest) if latest >= now => latest + 1, + _ => now, + } +} + +fn missing_new_value(event: &Event) -> BackendError { + BackendError::Other(format!("{:?} event for resource {:?} is missing new_value", event.event_type(), event.resource_id).into()) +} + +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}"))) +} + +/// `ConsumerCredential` events are keyed by `parentId/credentials/resourceId` +/// (their owning consumer's username plus their own id) since a bare +/// `resourceId` alone isn't unique across different consumers' credentials; +/// every other resource type's own `resourceId` is already unique on its own. +fn generate_id_from_event(event: &Event) -> Result { + if event.resource_type == ResourceType::ConsumerCredential { + let parent_id = event.parent_id.as_deref().ok_or_else(|| missing_parent(event))?; + Ok(format!("{parent_id}/credentials/{}", event.resource_id)) + } else { + Ok(event.resource_id.clone()) + } +} + +fn diff_path_is_upstream(diff: &ValueDiff) -> bool { + let path = match diff { + ValueDiff::New { path, .. } | ValueDiff::Deleted { path, .. } | ValueDiff::Edit { path, .. } | ValueDiff::Array { path, .. } => path, + }; + matches!(path.first(), Some(PathSegment::Key(key)) if key == "upstream") +} + +fn from_adc_labels(labels: Option) -> Option { + labels.map(|labels| labels.into_iter().map(|(key, value)| (key, stringify_label_value(value))).collect()) +} + +fn stringify_label_value(value: adc::LabelValue) -> String { + match value { + adc::LabelValue::Single(s) => s, + adc::LabelValue::Multiple(items) => serde_json::to_string(&items).unwrap_or_default(), + } +} + +/// Builds an upstream's wire body from its ADC shape, minus `id`/ +/// `modifiedIndex`/`name` (every caller overwrites those with values that +/// come from the owning `Event`, not from the upstream resource itself — +/// see `from_adc_upstream` and `apply_event_for_service_inlined_upstream`). +/// `parent_id`, when set, stamps the service-association bookkeeping label +/// onto a *named* upstream; a service's own inline default upstream is +/// never passed one (see `typing::ADC_UPSTREAM_SERVICE_ID_LABEL`'s doc +/// comment). +fn from_adc_upstream_wire(res: &adc::Upstream, parent_id: Option<&str>) -> typing::Upstream { + let mut labels = from_adc_labels(res.labels.clone()); + if let Some(parent_id) = parent_id { + labels + .get_or_insert_with(HashMap::new) + .insert(typing::ADC_UPSTREAM_SERVICE_ID_LABEL.to_string(), parent_id.to_string()); + } + + typing::Upstream { + modified_index: 0, + id: String::new(), + name: res.name.clone().unwrap_or_default(), + desc: res.description.clone(), + labels, + + nodes: res.nodes.clone(), + scheme: Some(res.scheme), + ty: Some(res.r#type), + hash_on: res.hash_on.clone(), + key: res.key.clone(), + + pass_host: Some(res.pass_host), + upstream_host: res.upstream_host.clone(), + retries: res.retries, + retry_timeout: res.retry_timeout, + timeout: res.timeout.clone(), + tls: res.tls.clone(), + keepalive_pool: res.keepalive_pool.clone(), + + checks: res.checks.clone(), + discovery_type: res.discovery_type.clone(), + service_name: res.service_name.clone(), + discovery_args: res.discovery_args.clone(), + } +} + +fn from_adc_route(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let res: adc::Route = deserialize_event_value(new_value)?; + let parent_id = event.parent_id.clone().ok_or_else(|| missing_parent(event))?; + + Ok(typing::Route { + modified_index, + id: generate_id_from_event(event)?, + name: res.name, + desc: res.description, + labels: from_adc_labels(res.labels), + + uris: res.uris, + hosts: res.hosts, + methods: res.methods, + remote_addrs: res.remote_addrs, + vars: res.vars, + filter_func: res.filter_func, + + plugins: res.plugins, + service_id: parent_id, + + timeout: res.timeout, + enable_websocket: res.enable_websocket, + priority: res.priority, + status: Some(1), + }) +} + +fn from_adc_service(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let res: adc::Service = deserialize_event_value(new_value)?; + let id = generate_id_from_event(event)?; + + Ok(typing::Service { + modified_index, + id: id.clone(), + name: res.name, + desc: res.description, + labels: from_adc_labels(res.labels), + + hosts: res.hosts, + // Always points at this service's own id, regardless of whether it + // actually has a default upstream — matches the TS operator's own + // unconditional `upstream_id: id`. A service with no default + // upstream simply references an upstream document that was never + // written; standalone tolerates the dangling reference. + upstream_id: Some(id), + plugins: res.plugins, + }) +} + +fn from_adc_consumer(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let res: adc::Consumer = deserialize_event_value(new_value)?; + + Ok(typing::Consumer { + modified_index, + username: generate_id_from_event(event)?, + desc: res.description, + labels: from_adc_labels(res.labels), + plugins: res.plugins, + }) +} + +fn from_adc_credential(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let res: adc::ConsumerCredential = deserialize_event_value(new_value)?; + + let mut plugins = adc::Plugins::new(); + plugins.insert(res.r#type, Value::Object(res.config)); + + Ok(typing::ConsumerCredential { + modified_index, + id: generate_id_from_event(event)?, + name: res.name, + desc: res.description, + labels: from_adc_labels(res.labels), + plugins: Some(plugins), + }) +} + +fn from_adc_ssl(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let res: adc::SSL = deserialize_event_value(new_value)?; + + let mut certificates = res.certificates.into_iter(); + let first = certificates + .next() + .ok_or_else(|| BackendError::Other(format!("ssl {:?} has no certificates", event.resource_id).into()))?; + let (certs, keys): (Vec, Vec) = certificates.map(|c| (c.certificate, c.key)).unzip(); + + Ok(typing::Ssl { + modified_index, + id: generate_id_from_event(event)?, + desc: None, + labels: from_adc_labels(res.labels), + + ty: Some(res.r#type), + snis: res.snis, + cert: first.certificate, + key: first.key, + certs: (!certs.is_empty()).then_some(certs), + keys: (!keys.is_empty()).then_some(keys), + client: res.client, + ssl_protocols: res.ssl_protocols, + + status: 1, + }) +} + +fn from_adc_global_rule(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let mut plugins = adc::Plugins::new(); + plugins.insert(event.resource_id.clone(), new_value.clone()); + + Ok(typing::GlobalRule { + modified_index, + id: generate_id_from_event(event)?, + plugins: Some(plugins), + }) +} + +fn from_adc_plugin_metadata(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let extra = match new_value { + Value::Object(map) => map.clone(), + _ => Map::new(), + }; + + Ok(typing::PluginMetadata { + modified_index, + id: generate_id_from_event(event)?, + extra, + }) +} + +fn from_adc_upstream(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let res: adc::Upstream = deserialize_event_value(new_value)?; + + let mut wire = from_adc_upstream_wire(&res, event.parent_id.as_deref()); + wire.modified_index = modified_index; + wire.id = generate_id_from_event(event)?; + Ok(wire) +} + +fn from_adc_stream_route(event: &Event, modified_index: i64) -> Result { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let res: adc::StreamRoute = deserialize_event_value(new_value)?; + let parent_id = event.parent_id.clone().ok_or_else(|| missing_parent(event))?; + + Ok(typing::StreamRoute { + modified_index, + id: generate_id_from_event(event)?, + name: res.name, + desc: res.description, + labels: from_adc_labels(res.labels), + + remote_addr: res.remote_addr, + server_addr: res.server_addr, + server_port: res.server_port, + sni: res.sni, + service_id: parent_id, + + plugins: res.plugins, + protocol: None, + }) +} + +/// Creates/updates/deletes one entry in `field`, matched by `identity` +/// against the id [`generate_id_from_event`] derives — the same lookup +/// logic every resource type needs, parameterized over its own collection +/// type and identity accessor. +/// +/// `Create` and `Update` both upsert: whichever one fires, a matching +/// existing entry is replaced and a missing one is inserted — a `Create` +/// for an id that's already present (a duplicate differ event, or a retried +/// sync landing on a base that already has it) replaces it instead of +/// appending a second entry with the same id, and symmetrically an +/// `Update` for an id that isn't there yet still leaves the document with +/// it rather than silently dropping the write. `Delete` alone stays a +/// genuine no-op for a missing id (matches the TS operator's own +/// `findIndex !== -1` guard) — there's nothing sensible to insert for a +/// deletion. Returns whether `field` actually changed. +fn upsert_or_delete( + field: &mut Option>, + event: &Event, + identity: impl Fn(&T) -> &str, + build: impl FnOnce() -> Result, +) -> Result { + match event.event_type() { + EventType::Create | EventType::Update => { + let target_id = generate_id_from_event(event)?; + let vec = field.get_or_insert_with(Vec::new); + let built = build()?; + match vec.iter_mut().find(|item| identity(item) == target_id) { + Some(slot) => *slot = built, + None => vec.push(built), + } + Ok(true) + } + EventType::Delete => { + let target_id = generate_id_from_event(event)?; + let Some(vec) = field.as_mut() else { return Ok(false) }; + match vec.iter().position(|item| identity(item) == target_id) { + Some(pos) => { + vec.remove(pos); + Ok(true) + } + None => Ok(false), + } + } + EventType::OnlySubEvents => Ok(false), + } +} + +fn apply_event(config: &mut ApisixStandalone, increase_version: &mut HashSet, timestamp: i64, event: &Event) -> Result<(), BackendError> { + // A CONSUMER_CREDENTIAL shares its owning consumer's collection and + // conf_version counter — there's no separate "credentials" array on + // the wire. + let version_resource_type = match event.resource_type { + ResourceType::ConsumerCredential => ResourceType::Consumer, + other => other, + }; + + let changed = match event.resource_type { + ResourceType::Route => upsert_or_delete(&mut config.routes, event, |r| r.id.as_str(), || from_adc_route(event, timestamp))?, + ResourceType::Service => { + // Only an UPDATE can be a no-op for this collection: when the + // diff shows nothing but the inline default upstream changed, + // the service body itself is untouched (that's already handled + // separately by `apply_event_for_service_inlined_upstream`, + // called before this for every SERVICE event regardless) — so + // skip writing to `config.services` for that update to avoid + // bumping `services_conf_version` for no real change. A CREATE + // or DELETE always writes: `EventKind::diff()` only ever + // returns `Some` for `Update`, so gating on it unconditionally + // (as opposed to only within the `Update` arm) would silently + // drop every service CREATE — `.unwrap_or(&[])` makes an empty + // diff, and `.any()` over an empty slice is always `false`. + if event.event_type() == EventType::Update { + let diff = event.kind.diff().unwrap_or(&[]); + if diff.iter().any(|d| !diff_path_is_upstream(d)) { + upsert_or_delete(&mut config.services, event, |s| s.id.as_str(), || from_adc_service(event, timestamp))? + } else { + false + } + } else { + upsert_or_delete(&mut config.services, event, |s| s.id.as_str(), || from_adc_service(event, timestamp))? + } + } + ResourceType::Consumer => { + upsert_or_delete(&mut config.consumers, event, ConsumerOrCredential::identity, || { + Ok(ConsumerOrCredential::Consumer(from_adc_consumer(event, timestamp)?)) + })? + } + ResourceType::ConsumerCredential => { + upsert_or_delete(&mut config.consumers, event, ConsumerOrCredential::identity, || { + Ok(ConsumerOrCredential::Credential(from_adc_credential(event, timestamp)?)) + })? + } + ResourceType::Ssl => upsert_or_delete(&mut config.ssls, event, |s| s.id.as_str(), || from_adc_ssl(event, timestamp))?, + ResourceType::GlobalRule => { + upsert_or_delete(&mut config.global_rules, event, |g| g.id.as_str(), || from_adc_global_rule(event, timestamp))? + } + ResourceType::PluginMetadata => { + upsert_or_delete(&mut config.plugin_metadata, event, |p| p.id.as_str(), || from_adc_plugin_metadata(event, timestamp))? + } + ResourceType::Upstream => { + upsert_or_delete(&mut config.upstreams, event, |u| u.id.as_str(), || from_adc_upstream(event, timestamp))? + } + ResourceType::StreamRoute => { + upsert_or_delete(&mut config.stream_routes, event, |r| r.id.as_str(), || from_adc_stream_route(event, timestamp))? + } + // Not part of standalone's config document — matches the TS + // operator's `fromADC` switch, which has no case for these either. + ResourceType::ConsumerGroup | ResourceType::PluginConfig | ResourceType::InternalStreamService => false, + }; + + if changed { + increase_version.insert(version_resource_type); + } + Ok(()) +} + +/// A service's default upstream is stored as its own entry in the +/// top-level `upstreams` array (id = the service's own id), not embedded +/// inline in the service body — this keeps that entry in sync with +/// whatever the differ's SERVICE event carries. A service with no default +/// upstream (`upstream: None`) has nothing to write here. +fn apply_event_for_service_inlined_upstream( + config: &mut ApisixStandalone, + increase_version: &mut HashSet, + timestamp: i64, + event: &Event, +) -> Result<(), BackendError> { + if event.resource_type != ResourceType::Service { + return Ok(()); + } + + let build_wire = |event: &Event| -> Result, BackendError> { + let new_value = event.kind.new_value().ok_or_else(|| missing_new_value(event))?; + let service: adc::Service = deserialize_event_value(new_value)?; + let Some(upstream) = service.upstream else { return Ok(None) }; + let mut wire = from_adc_upstream_wire(&upstream, None); + wire.id = event.resource_id.clone(); + wire.modified_index = timestamp; + wire.name = event.resource_name.clone(); + Ok(Some(wire)) + }; + + match event.event_type() { + EventType::Create => { + if let Some(wire) = build_wire(event)? { + config.upstreams.get_or_insert_with(Vec::new).push(wire); + increase_version.insert(ResourceType::Upstream); + } + } + EventType::Update => { + let diff = event.kind.diff().unwrap_or(&[]); + if !diff.iter().any(diff_path_is_upstream) { + return Ok(()); + } + // `.as_mut()`, not `get_or_insert_with`: there's nothing to + // update when no upstream has ever been written, and + // materializing an empty `Vec` here would flip + // `config.upstreams` from `None` to `Some(vec![])` — a real + // (if harmless-looking) change to what gets cached and PUT to + // the servers, for an event that changed nothing. + if let Some(wire) = build_wire(event)? + && let Some(upstreams) = config.upstreams.as_mut() + && let Some(slot) = upstreams.iter_mut().find(|item| item.id == event.resource_id) + { + *slot = wire; + increase_version.insert(ResourceType::Upstream); + } + } + EventType::Delete => { + if let Some(upstreams) = config.upstreams.as_mut() + && let Some(pos) = upstreams.iter().position(|item| item.id == event.resource_id) + { + upstreams.remove(pos); + increase_version.insert(ResourceType::Upstream); + } + } + EventType::OnlySubEvents => {} + } + Ok(()) +} + +/// A newly-created consumer credential with no matching consumer (or a +/// consumer deleted in the same batch as its credentials survive) has +/// nothing left to belong to — dropped rather than left dangling. +fn filter_orphan_credentials(config: &mut ApisixStandalone) { + let Some(consumers) = &mut config.consumers else { return }; + let usernames: HashSet = consumers + .iter() + .filter_map(ConsumerOrCredential::as_consumer) + .map(|consumer| consumer.username.clone()) + .collect(); + + consumers.retain(|item| match item { + ConsumerOrCredential::Consumer(_) => true, + ConsumerOrCredential::Credential(credential) => { + let owner = credential.id.split('/').next().unwrap_or(""); + usernames.contains(owner) + } + }); +} + +fn bump_conf_versions(config: &mut ApisixStandalone, increase_version: &HashSet, timestamp: i64) { + for resource_type in increase_version { + let field = match resource_type { + ResourceType::Route => &mut config.routes_conf_version, + ResourceType::Service => &mut config.services_conf_version, + ResourceType::Consumer => &mut config.consumers_conf_version, + ResourceType::Ssl => &mut config.ssls_conf_version, + ResourceType::GlobalRule => &mut config.global_rules_conf_version, + ResourceType::PluginMetadata => &mut config.plugin_metadata_conf_version, + ResourceType::Upstream => &mut config.upstreams_conf_version, + ResourceType::StreamRoute => &mut config.stream_routes_conf_version, + ResourceType::ConsumerCredential | ResourceType::ConsumerGroup | ResourceType::PluginConfig | ResourceType::InternalStreamService => continue, + }; + *field = Some(timestamp); + } +} + +#[cfg(test)] +mod tests { + use adc_sdk::EventKind; + use serde_json::json; + use tokio::task::JoinSet; + + use super::*; + + fn event(rt: ResourceType, kind: EventKind, id: &str) -> Event { + Event::new(rt, kind, id, id) + } + + fn empty_config() -> ApisixStandalone { + ApisixStandalone::default() + } + + #[test] + fn no_known_latest_version_uses_the_wall_clock_time_as_is() { + assert_eq!(resolve_sync_timestamp(100, None), 100); + } + + #[test] + fn a_wall_clock_time_already_ahead_of_the_latest_known_version_is_used_as_is() { + assert_eq!(resolve_sync_timestamp(100, Some(50)), 100); + } + + /// Regression test: two syncs landing in the same wall-clock millisecond + /// (a real, non-exotic race under fast-succession load, not just a + /// clock-rollback edge case) must still produce a strictly increasing + /// timestamp, not the same one twice. + #[test] + fn a_wall_clock_time_equal_to_the_latest_known_version_is_bumped_past_it() { + assert_eq!(resolve_sync_timestamp(100, Some(100)), 101); + } + + #[test] + fn a_wall_clock_time_behind_the_latest_known_version_is_bumped_past_it() { + assert_eq!(resolve_sync_timestamp(50, Some(100)), 101); + } + + #[test] + fn create_route_pushes_it_with_the_parent_service_id() { + let mut config = empty_config(); + let mut increase_version = HashSet::new(); + let mut route_event = event( + ResourceType::Route, + EventKind::Create { new_value: json!({ "name": "r1", "uris": ["/x"] }) }, + "r1", + ); + route_event.parent_id = Some("svc-1".to_string()); + + apply_event(&mut config, &mut increase_version, 100, &route_event).unwrap(); + + let routes = config.routes.unwrap(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].id, "r1"); + assert_eq!(routes[0].service_id, "svc-1"); + assert_eq!(routes[0].modified_index, 100); + assert!(increase_version.contains(&ResourceType::Route)); + } + + /// Regression test: a SERVICE CREATE event must always be pushed into + /// `config.services`. An earlier bug gated this on `event.kind.diff()` + /// unconditionally — `diff()` only ever returns `Some` for an `Update` + /// event, so a CREATE (or DELETE) silently fell through to an empty + /// diff, and `.any()` over it was always `false`, meaning `apply_event` + /// never actually added the service at all despite the event + /// succeeding at the HTTP layer. + #[test] + fn create_service_pushes_it_into_the_services_collection() { + let mut config = empty_config(); + let mut increase_version = HashSet::new(); + let service_event = event( + ResourceType::Service, + EventKind::Create { new_value: json!({ "name": "svc-1" }) }, + "svc-1", + ); + + apply_event(&mut config, &mut increase_version, 100, &service_event).unwrap(); + + let services = config.services.unwrap(); + assert_eq!(services.len(), 1); + assert_eq!(services[0].id, "svc-1"); + assert!(increase_version.contains(&ResourceType::Service)); + } + + /// Regression test: a CREATE for an id that's already present (a + /// duplicate differ event, or a retried sync landing on a base that + /// already has it) must replace the existing entry, not append a + /// second one sharing the same id. + #[test] + fn create_for_an_already_present_id_replaces_it_instead_of_duplicating() { + let mut config = empty_config(); + config.services = Some(vec![typing::Service { + modified_index: 1, + id: "svc-1".to_string(), + name: "svc-1".to_string(), + desc: Some("original".to_string()), + labels: None, + hosts: None, + upstream_id: None, + plugins: None, + }]); + let mut increase_version = HashSet::new(); + let service_event = event( + ResourceType::Service, + EventKind::Create { new_value: json!({ "name": "svc-1", "description": "replaced" }) }, + "svc-1", + ); + + apply_event(&mut config, &mut increase_version, 200, &service_event).unwrap(); + + let services = config.services.unwrap(); + assert_eq!(services.len(), 1, "must not end up with two entries sharing id \"svc-1\""); + assert_eq!(services[0].desc.as_deref(), Some("replaced")); + assert_eq!(services[0].modified_index, 200); + } + + /// Regression test: an UPDATE for an id that isn't present yet must + /// still insert it, rather than silently dropping the write. + /// Uses `Route`, not `Service`: `ResourceType::Service`'s own branch in + /// `apply_event` gates UPDATE on the diff touching more than just + /// `upstream` before it even calls `upsert_or_delete` (see that + /// branch's own doc comment), which would make this test exercise that + /// gating instead of the upsert behavior it's actually meant to cover. + #[test] + fn update_for_a_missing_id_inserts_it_instead_of_dropping_the_write() { + let mut config = empty_config(); + let mut increase_version = HashSet::new(); + let mut route_event = event( + ResourceType::Route, + EventKind::Update { + old_value: json!({ "name": "r1", "uris": ["/x"] }), + new_value: json!({ "name": "r1", "uris": ["/x"] }), + diff: None, + }, + "r1", + ); + route_event.parent_id = Some("svc-1".to_string()); + + apply_event(&mut config, &mut increase_version, 300, &route_event).unwrap(); + + let routes = config.routes.unwrap(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].id, "r1"); + assert!(increase_version.contains(&ResourceType::Route)); + } + + #[test] + fn delete_service_removes_it_from_the_services_collection() { + let mut config = empty_config(); + config.services = Some(vec![typing::Service { + modified_index: 1, + id: "svc-1".to_string(), + name: "svc-1".to_string(), + desc: None, + labels: None, + hosts: None, + upstream_id: None, + plugins: None, + }]); + let mut increase_version = HashSet::new(); + let delete_service_event = event(ResourceType::Service, EventKind::Delete { old_value: json!({}) }, "svc-1"); + + apply_event(&mut config, &mut increase_version, 200, &delete_service_event).unwrap(); + + assert_eq!(config.services.unwrap().len(), 0); + assert!(increase_version.contains(&ResourceType::Service)); + } + + #[test] + fn a_service_update_that_only_touches_upstream_leaves_the_services_collection_untouched_but_updates_the_inline_upstream() { + let mut config = empty_config(); + config.services = Some(vec![typing::Service { + modified_index: 1, + id: "svc-1".to_string(), + name: "svc-1".to_string(), + desc: None, + labels: None, + hosts: None, + upstream_id: Some("svc-1".to_string()), + plugins: None, + }]); + config.upstreams = Some(vec![typing::Upstream { + modified_index: 1, + id: "svc-1".to_string(), + name: "svc-1".to_string(), + desc: None, + labels: None, + nodes: None, + scheme: None, + ty: None, + hash_on: None, + key: None, + pass_host: None, + upstream_host: None, + retries: None, + retry_timeout: None, + timeout: None, + tls: None, + keepalive_pool: None, + checks: None, + discovery_type: None, + service_name: None, + discovery_args: None, + }]); + let mut increase_version = HashSet::new(); + let diff = vec![ValueDiff::Edit { + path: vec![PathSegment::Key("upstream".to_string())], + lhs: json!({}), + rhs: json!({}), + }]; + let service_event = event( + ResourceType::Service, + EventKind::Update { + old_value: json!({ "name": "svc-1" }), + new_value: json!({ "name": "svc-1", "upstream": { "nodes": [{"host":"1.1.1.1","port":80,"weight":1}] } }), + diff: Some(diff), + }, + "svc-1", + ); + + apply_event_for_service_inlined_upstream(&mut config, &mut increase_version, 200, &service_event).unwrap(); + apply_event(&mut config, &mut increase_version, 200, &service_event).unwrap(); + + assert_eq!(config.services.as_ref().unwrap()[0].modified_index, 1, "service body itself must stay untouched"); + assert!(!increase_version.contains(&ResourceType::Service)); + + let upstreams = config.upstreams.unwrap(); + assert_eq!(upstreams.len(), 1); + assert_eq!(upstreams[0].id, "svc-1"); + assert!(increase_version.contains(&ResourceType::Upstream)); + } + + #[test] + fn service_create_with_no_default_upstream_creates_no_inline_upstream_entry() { + let mut config = empty_config(); + let mut increase_version = HashSet::new(); + let service_event = event(ResourceType::Service, EventKind::Create { new_value: json!({ "name": "svc-no-upstream" }) }, "svc-2"); + + apply_event_for_service_inlined_upstream(&mut config, &mut increase_version, 300, &service_event).unwrap(); + + assert!(config.upstreams.is_none()); + assert!(!increase_version.contains(&ResourceType::Upstream)); + } + + /// Regression test: deleting a service that never had a default + /// upstream must leave `config.upstreams` at `None`, not flip it to + /// `Some(vec![])` — the latter would serialize as a stray `"upstreams": + /// []` key in the synced document even though nothing about upstreams + /// actually changed. + #[test] + fn deleting_a_service_with_no_upstream_leaves_the_upstreams_field_absent() { + let mut config = empty_config(); + let mut increase_version = HashSet::new(); + let delete_service_event = event(ResourceType::Service, EventKind::Delete { old_value: json!({}) }, "svc-no-upstream"); + + apply_event_for_service_inlined_upstream(&mut config, &mut increase_version, 300, &delete_service_event).unwrap(); + + assert!(config.upstreams.is_none()); + assert!(!increase_version.contains(&ResourceType::Upstream)); + } + + /// Regression test: an update whose diff touches `upstream` but for + /// which no upstream entry exists yet (e.g. `config.upstreams` was + /// never populated) must also leave it `None`, for the same reason. + #[test] + fn updating_a_services_upstream_with_no_existing_entry_leaves_the_upstreams_field_absent() { + let mut config = empty_config(); + let mut increase_version = HashSet::new(); + let diff = vec![ValueDiff::Edit { + path: vec![PathSegment::Key("upstream".to_string())], + lhs: json!({}), + rhs: json!({}), + }]; + let service_event = event( + ResourceType::Service, + EventKind::Update { + old_value: json!({ "name": "svc-no-upstream" }), + new_value: json!({ "name": "svc-no-upstream", "upstream": { "nodes": [{"host":"1.1.1.1","port":80,"weight":1}] } }), + diff: Some(diff), + }, + "svc-no-upstream", + ); + + apply_event_for_service_inlined_upstream(&mut config, &mut increase_version, 300, &service_event).unwrap(); + + assert!(config.upstreams.is_none()); + assert!(!increase_version.contains(&ResourceType::Upstream)); + } + + #[test] + fn delete_consumer_credential_matches_by_the_parent_prefixed_id() { + let mut config = empty_config(); + config.consumers = Some(vec![ + ConsumerOrCredential::Consumer(typing::Consumer { + modified_index: 1, + username: "alice".to_string(), + desc: None, + labels: None, + plugins: None, + }), + ConsumerOrCredential::Credential(typing::ConsumerCredential { + modified_index: 1, + id: "alice/credentials/key1".to_string(), + name: "key1".to_string(), + desc: None, + labels: None, + plugins: None, + }), + ]); + let mut increase_version = HashSet::new(); + let mut delete_event = event(ResourceType::ConsumerCredential, EventKind::Delete { old_value: json!({}) }, "key1"); + delete_event.parent_id = Some("alice".to_string()); + + apply_event(&mut config, &mut increase_version, 400, &delete_event).unwrap(); + + let consumers = config.consumers.unwrap(); + assert_eq!(consumers.len(), 1); + assert!(consumers[0].as_consumer().is_some()); + assert!(increase_version.contains(&ResourceType::Consumer)); + } + + #[test] + fn filter_orphan_credentials_drops_credentials_whose_consumer_is_gone() { + let mut config = empty_config(); + config.consumers = Some(vec![ + ConsumerOrCredential::Consumer(typing::Consumer { + modified_index: 1, + username: "alice".to_string(), + desc: None, + labels: None, + plugins: None, + }), + ConsumerOrCredential::Credential(typing::ConsumerCredential { + modified_index: 1, + id: "alice/credentials/key1".to_string(), + name: "key1".to_string(), + desc: None, + labels: None, + plugins: None, + }), + ConsumerOrCredential::Credential(typing::ConsumerCredential { + modified_index: 1, + id: "bob/credentials/key2".to_string(), + name: "key2".to_string(), + desc: None, + labels: None, + plugins: None, + }), + ]); + + filter_orphan_credentials(&mut config); + + let remaining: Vec<&str> = config.consumers.as_ref().unwrap().iter().map(ConsumerOrCredential::identity).collect(); + assert_eq!(remaining, vec!["alice", "alice/credentials/key1"]); + } + + #[test] + fn bump_conf_versions_only_touches_resource_types_that_actually_changed() { + let mut config = empty_config(); + let mut increase_version = HashSet::new(); + increase_version.insert(ResourceType::Route); + + bump_conf_versions(&mut config, &increase_version, 555); + + assert_eq!(config.routes_conf_version, Some(555)); + assert_eq!(config.services_conf_version, None); + } + + /// Applying an (event batch, base config) pair is a pure computation — + /// each call clones its own `new_config` from a shared base and never + /// touches any state outside its own locals. Running many of these + /// concurrently, each producing its own independently-verified result, + /// is a smoke test that nothing here secretly relies on being called + /// from a single thread (no hidden shared mutable state, no data races + /// under Miri/TSan-style concurrent access) — a real multi-threaded + /// runtime, not `current_thread`, so tasks genuinely run in parallel. + #[tokio::test(flavor = "multi_thread", worker_threads = 8)] + async fn applying_independent_event_batches_concurrently_is_race_free() { + let base = empty_config(); + + let mut tasks = JoinSet::new(); + for i in 0..200i64 { + let mut config = base.clone(); + tasks.spawn(async move { + let mut increase_version = HashSet::new(); + let mut route_event = event( + ResourceType::Route, + EventKind::Create { new_value: json!({ "name": format!("r{i}"), "uris": ["/x"] }) }, + &format!("r{i}"), + ); + route_event.parent_id = Some(format!("svc-{i}")); + apply_event(&mut config, &mut increase_version, i, &route_event).unwrap(); + + let routes = config.routes.expect("route was just created"); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].id, format!("r{i}")); + assert_eq!(routes[0].modified_index, i); + }); + } + let results = tasks.join_all().await; + assert_eq!(results.len(), 200); + } +} diff --git a/rust/crates/adc-backend-apisix-standalone/src/transformer.rs b/rust/crates/adc-backend-apisix-standalone/src/transformer.rs new file mode 100644 index 00000000..d43c3625 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/src/transformer.rs @@ -0,0 +1,316 @@ +//! Converting `typing::ApisixStandalone` (the whole config document) into +//! ADC's nested `Configuration` model — the read direction used by +//! `crate::fetcher::Fetcher::dump` and, after a sync, to refresh +//! `crate::cache::Cache`'s cached `Configuration` from the just-written raw +//! document. There is no write-direction counterpart module here the way +//! `adc-backend-apisix` has one: standalone's write path +//! (`crate::operator::Operator`) builds each resource's wire body directly +//! off the differ's `Event`, not off a full `Configuration`. + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use adc_sdk::resources::{self as adc, LabelValue}; + +use crate::typing; + +fn to_adc_labels(labels: Option) -> Option { + labels.map(|labels| { + labels + .into_iter() + .map(|(key, value)| (key, LabelValue::Single(value))) + .collect() + }) +} + +/// Drops the service-association bookkeeping label a named upstream is +/// stamped with (see `typing::ADC_UPSTREAM_SERVICE_ID_LABEL`) — not +/// something a consumer of the ADC model should see. A service's own +/// inline default upstream never carries this label in the first place +/// (see `crate::operator::Operator::apply_event_for_service_inlined_upstream`), +/// so this is only ever called for named upstreams. +fn strip_service_id_label(labels: Option) -> Option { + labels + .map(|mut labels| { + labels.remove(typing::ADC_UPSTREAM_SERVICE_ID_LABEL); + labels + }) + .filter(|labels| !labels.is_empty()) +} + +/// Builds an upstream's ADC shape, minus `id` (callers that need one set +/// it themselves — a service's own default upstream never gets one, a +/// named upstream does) and always carrying `name` (callers building a +/// service's own default upstream override it back to `None` afterward, +/// since a service's default upstream has no independent name in ADC's +/// model). +fn wire_upstream_to_adc(upstream: &typing::Upstream) -> adc::Upstream { + adc::Upstream { + id: None, + name: Some(upstream.name.clone()), + description: upstream.desc.clone(), + labels: to_adc_labels(upstream.labels.clone()), + + r#type: upstream.ty.unwrap_or_default(), + hash_on: upstream.hash_on.clone(), + key: upstream.key.clone(), + checks: upstream.checks.clone(), + nodes: upstream.nodes.clone(), + scheme: upstream.scheme.unwrap_or_default(), + retries: upstream.retries, + retry_timeout: upstream.retry_timeout, + timeout: upstream.timeout.clone(), + tls: upstream.tls.clone(), + keepalive_pool: upstream.keepalive_pool.clone(), + pass_host: upstream.pass_host.unwrap_or_default(), + upstream_host: upstream.upstream_host.clone(), + + service_name: upstream.service_name.clone(), + discovery_type: upstream.discovery_type.clone(), + discovery_args: upstream.discovery_args.clone(), + } +} + +fn route_to_adc(route: &typing::Route) -> adc::Route { + adc::Route { + id: Some(route.id.clone()), + name: route.name.clone(), + description: route.desc.clone(), + labels: to_adc_labels(route.labels.clone()), + + hosts: route.hosts.clone(), + uris: route.uris.clone(), + priority: route.priority, + timeout: route.timeout.clone(), + vars: route.vars.clone(), + methods: route.methods.clone(), + enable_websocket: route.enable_websocket, + remote_addrs: route.remote_addrs.clone(), + plugins: route.plugins.clone(), + filter_func: route.filter_func.clone(), + } +} + +fn stream_route_to_adc(route: &typing::StreamRoute) -> adc::StreamRoute { + adc::StreamRoute { + id: Some(route.id.clone()), + name: route.name.clone(), + description: route.desc.clone(), + labels: to_adc_labels(route.labels.clone()), + + plugins: route.plugins.clone(), + remote_addr: route.remote_addr.clone(), + server_addr: route.server_addr.clone(), + server_port: route.server_port, + sni: route.sni.clone(), + } +} + +/// Zips a certificate list with its matching key list positionally, +/// falling back to an empty key past the end of a shorter `keys` list +/// (rather than TS's `keys?.[idx]` producing `undefined`, which would leave +/// a certificate entry with no key at all) — mirrors +/// `adc-backend-apisix::transformer`'s identical fix for the same +/// mismatched-length edge case. +fn ssl_to_adc(ssl: &typing::Ssl) -> adc::SSL { + let mut keys = ssl.keys.clone().unwrap_or_default().into_iter(); + let mut certificates = vec![adc::SSLCertificate { + certificate: ssl.cert.clone(), + key: ssl.key.clone(), + }]; + if let Some(certs) = &ssl.certs { + certificates.extend(certs.iter().map(|certificate| adc::SSLCertificate { + certificate: certificate.clone(), + key: keys.next().unwrap_or_default(), + })); + } + + adc::SSL { + id: Some(ssl.id.clone()), + labels: to_adc_labels(ssl.labels.clone()), + r#type: ssl.ty.unwrap_or_default(), + snis: ssl.snis.clone(), + certificates, + client: ssl.client.clone(), + ssl_protocols: ssl.ssl_protocols.clone(), + } +} + +/// A credential's `type`/`config` come from its single plugin entry +/// (standalone models a credential as a one-plugin `Plugins` map, same as +/// `adc-backend-apisix`); a credential with no plugin configured has +/// nothing to convert. Unlike `adc-backend-apisix`'s equivalent, this +/// doesn't reject an unrecognized plugin name or a non-object config — +/// matching the TS transformer, which casts the plugin name and passes the +/// config through without validating either. +fn credential_to_adc(credential: &typing::ConsumerCredential, prefix: &str) -> Option { + let plugins = credential.plugins.clone()?; + let (plugin_name, config) = plugins.into_iter().next()?; + let config = match config { + Value::Object(map) => map, + _ => Map::new(), + }; + + let id = credential.id.strip_prefix(prefix).unwrap_or(&credential.id).to_string(); + + Some(adc::ConsumerCredential { + id: Some(id), + name: credential.name.clone(), + description: credential.desc.clone(), + labels: to_adc_labels(credential.labels.clone()), + r#type: plugin_name, + config, + }) +} + +/// Converts the whole standalone config document into ADC's nested +/// `Configuration` model: routes/stream_routes/named-upstreams get nested +/// under their owning service, consumer credentials under their owning +/// consumer, and `global_rules`/`plugin_metadata` (each already a flat +/// per-plugin map on the wire, just split across possibly-multiple entries) +/// get merged into one map apiece. +pub fn to_adc(input: &typing::ApisixStandalone) -> adc::Configuration { + let credentials: Vec<&typing::ConsumerCredential> = input + .consumers + .iter() + .flatten() + .filter_map(typing::ConsumerOrCredential::as_credential) + .collect(); + + // Grouped once up front rather than re-scanned per service: with S + // services and U/R/T upstreams/routes/stream_routes, filtering inside + // the services closure below costs O(S*(U+R+T)); a single grouping + // pass costs O(U+R+T) plus an O(1) lookup per service. + let upstream_by_id: HashMap<&str, &typing::Upstream> = + input.upstreams.iter().flatten().map(|upstream| (upstream.id.as_str(), upstream)).collect(); + + let mut named_upstreams_by_service: HashMap<&str, Vec<&typing::Upstream>> = HashMap::new(); + for upstream in input.upstreams.iter().flatten() { + if let Some(owner) = upstream.labels.as_ref().and_then(|labels| labels.get(typing::ADC_UPSTREAM_SERVICE_ID_LABEL)) { + named_upstreams_by_service.entry(owner.as_str()).or_default().push(upstream); + } + } + + let mut routes_by_service: HashMap<&str, Vec<&typing::Route>> = HashMap::new(); + for route in input.routes.iter().flatten() { + routes_by_service.entry(route.service_id.as_str()).or_default().push(route); + } + + let mut stream_routes_by_service: HashMap<&str, Vec<&typing::StreamRoute>> = HashMap::new(); + for route in input.stream_routes.iter().flatten() { + stream_routes_by_service.entry(route.service_id.as_str()).or_default().push(route); + } + + let services = input.services.iter().flatten().map(|service| { + let upstream = service + .upstream_id + .as_deref() + .and_then(|upstream_id| upstream_by_id.get(upstream_id).copied()) + .map(|upstream| adc::Upstream { + name: None, + ..wire_upstream_to_adc(upstream) + }); + + let named_upstreams: Vec = named_upstreams_by_service + .get(service.id.as_str()) + .into_iter() + .flatten() + .copied() + .map(|upstream| adc::Upstream { + id: Some(upstream.id.clone()), + labels: strip_service_id_label(to_adc_labels(upstream.labels.clone())), + ..wire_upstream_to_adc(upstream) + }) + .collect(); + + let routes: Vec = + routes_by_service.get(service.id.as_str()).into_iter().flatten().copied().map(route_to_adc).collect(); + let stream_routes: Vec = stream_routes_by_service + .get(service.id.as_str()) + .into_iter() + .flatten() + .copied() + .map(stream_route_to_adc) + .collect(); + + // A service is either HTTP or stream, never both — matches + // `ServiceRoutes`'s own invariant, and how standalone data is + // actually shaped (a route and a stream_route never share a + // `service_id`). + let routes = if !routes.is_empty() { + Some(adc::ServiceRoutes::Http { routes }) + } else if !stream_routes.is_empty() { + Some(adc::ServiceRoutes::Stream { stream_routes }) + } else { + None + }; + + adc::Service { + id: Some(service.id.clone()), + name: service.name.clone(), + description: service.desc.clone(), + labels: to_adc_labels(service.labels.clone()), + upstream, + upstreams: (!named_upstreams.is_empty()).then_some(named_upstreams), + plugins: service.plugins.clone(), + path_prefix: None, + strip_path_prefix: None, + hosts: service.hosts.clone(), + routes, + } + }); + let services: Vec = services.collect(); + + let consumers: Vec = input + .consumers + .iter() + .flatten() + .filter_map(typing::ConsumerOrCredential::as_consumer) + .map(|consumer| { + let prefix = format!("{}/credentials/", consumer.username); + let owned: Vec = credentials + .iter() + .filter(|credential| credential.id.starts_with(&prefix)) + .filter_map(|credential| credential_to_adc(credential, &prefix)) + .collect(); + + adc::Consumer { + username: consumer.username.clone(), + description: consumer.desc.clone(), + labels: to_adc_labels(consumer.labels.clone()), + plugins: consumer.plugins.clone(), + credentials: Some(owned), + } + }) + .collect(); + + let ssls: Vec = input.ssls.iter().flatten().map(ssl_to_adc).collect(); + + let mut global_rules = adc::Plugins::new(); + for entry in input.global_rules.iter().flatten() { + if let Some(plugins) = &entry.plugins { + global_rules.extend(plugins.clone()); + } + } + + // `rest` here intentionally keeps `modifiedIndex` alongside each + // plugin's own config keys — matches the TS transformer's `const {id, + // ...rest} = pluginMetadata` destructure, which only pulls `id` out and + // leaves `modifiedIndex` in `rest`. + let mut plugin_metadata = adc::Plugins::new(); + for entry in input.plugin_metadata.iter().flatten() { + let mut rest = entry.extra.clone(); + rest.insert("modifiedIndex".to_string(), Value::from(entry.modified_index)); + plugin_metadata.insert(entry.id.clone(), Value::Object(rest)); + } + + adc::Configuration { + services: (!services.is_empty()).then_some(services), + ssls: (!ssls.is_empty()).then_some(ssls), + consumers: (!consumers.is_empty()).then_some(consumers), + consumer_groups: None, + global_rules: (!global_rules.is_empty()).then_some(global_rules), + plugin_metadata: (!plugin_metadata.is_empty()).then_some(plugin_metadata), + } +} diff --git a/rust/crates/adc-backend-apisix-standalone/src/typing.rs b/rust/crates/adc-backend-apisix-standalone/src/typing.rs new file mode 100644 index 00000000..a1df0f47 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/src/typing.rs @@ -0,0 +1,375 @@ +//! APISIX standalone's `/apisix/admin/configs` wire shape — the whole +//! declarative config document standalone mode reads/writes atomically, as +//! opposed to `adc_sdk::resources::*` (ADC's own resource model). Unlike +//! `adc-backend-apisix`'s per-collection admin API, every resource type here +//! lives as an array inside one document, each entry stamped with a +//! `modifiedIndex` version number instead of being independently versioned. +//! +//! `labels` is a plain `Record` on every resource here +//! (never the string-or-array `Labels` union `adc_sdk::resources` and +//! `adc-backend-apisix`'s wire types use) — standalone's admin API schema +//! only ever accepts flat string values. +//! +//! `Deserialize` stays permissive (no `deny_unknown_fields`): a live +//! standalone config document carries fields this crate doesn't need to +//! model (e.g. `X-Last-Modified`/`X-Digest` metadata APISIX embeds in the +//! body itself), and an unrecognized field from a newer APISIX release +//! should be ignored, not rejected. `Serialize` omits `None` fields via +//! `skip_serializing_if` rather than sending explicit `null`s. + +use std::collections::HashMap; + +use adc_sdk::resources::{ + Expr, Plugins, SslClient, SslProtocol, SslType, Timeout, UpstreamBalancer, UpstreamHealthCheck, + UpstreamKeepalivePool, UpstreamNode, UpstreamPassHost, UpstreamScheme, UpstreamTls, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// Bookkeeping label a service-inlined default upstream is stamped with, so +/// [`crate::transformer::to_adc`] can find "which upstreams belong to this +/// service" among the flat top-level `upstreams` array — mirrors +/// `adc-backend-apisix`'s identically-named constant (this crate does +/// depend on that one, for its `Validator`, but not for this: the two +/// crates' upstream wire shapes differ too much to share the constant's +/// usage, so it's redefined here rather than imported). +pub const ADC_UPSTREAM_SERVICE_ID_LABEL: &str = "__ADC_UPSTREAM_SERVICE_ID"; + +pub type StandaloneLabels = HashMap; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Route { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, + + pub uris: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hosts: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub methods: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_addrs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vars: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter_func: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + pub service_id: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: 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 status: Option, +} + +/// APISIX's cjson encodes an empty Lua table as a JSON object (`{}`) +/// instead of an array — a standalone config document read back after being +/// stored with an empty `nodes: []` can come back as `nodes: {}`. A plain +/// `Vec` would reject that outright, so this accepts either +/// shape and normalizes `{}` to an empty vec. +fn deserialize_upstream_nodes<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + match Option::::deserialize(deserializer)? { + None | Some(Value::Null) => Ok(None), + Some(Value::Object(map)) if map.is_empty() => Ok(Some(Vec::new())), + Some(other) => serde_json::from_value(other).map(Some).map_err(serde::de::Error::custom), + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Upstream { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + pub id: String, + pub name: 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", + deserialize_with = "deserialize_upstream_nodes" + )] + 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 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, + + #[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>, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Service { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + pub id: String, + pub name: 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 hosts: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Consumer { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + 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, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ConsumerCredential { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + pub id: String, + pub name: 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, +} + +/// `consumers[]` holds both consumers and their credentials in one flat +/// array, discriminated by which required field is present: a `Consumer` +/// always has `username`, a `ConsumerCredential` never does (it has `id` + +/// `name` instead) — matches the TS union's own runtime discrimination +/// (`'username' in item`). `#[serde(untagged)]` tries `Consumer` first; +/// that's only safe because the two shapes' required fields never overlap. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ConsumerOrCredential { + Consumer(Consumer), + Credential(ConsumerCredential), +} + +impl ConsumerOrCredential { + /// The key `crate::operator` matches against to find "the entry this + /// event refers to" — a consumer's `username`, or a credential's `id` + /// (already `parentId/credentials/resourceId`-shaped, see + /// `crate::operator::generate_id_from_event`). + pub fn identity(&self) -> &str { + match self { + ConsumerOrCredential::Consumer(consumer) => &consumer.username, + ConsumerOrCredential::Credential(credential) => &credential.id, + } + } + + pub fn as_consumer(&self) -> Option<&Consumer> { + match self { + ConsumerOrCredential::Consumer(consumer) => Some(consumer), + ConsumerOrCredential::Credential(_) => None, + } + } + + pub fn as_credential(&self) -> Option<&ConsumerCredential> { + match self { + ConsumerOrCredential::Consumer(_) => None, + ConsumerOrCredential::Credential(credential) => Some(credential), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Ssl { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: 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, + pub snis: Vec, + pub cert: String, + pub key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certs: 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 ssl_protocols: Option>, + + pub status: i64, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct GlobalRule { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, +} + +/// A plugin's shared config: `id` + `modifiedIndex` are the only fields +/// this crate cares about; the rest of the plugin's own config keys pass +/// through untouched via `extra`, matching the TS schema's `looseObject` +/// (arbitrary additional keys, shape depends on which plugin it configures). +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct PluginMetadata { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + pub id: String, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StreamRouteProtocolLogger { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter: Option>, + pub conf: Map, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StreamRouteProtocol { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub superior_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conf: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logger: Option>, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct StreamRoute { + #[serde(rename = "modifiedIndex")] + pub modified_index: i64, + pub id: String, + pub name: 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 remote_addr: 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 sni: Option, + pub service_id: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol: Option, +} + +/// The whole `/apisix/admin/configs` document: every resource type's array, +/// plus a per-collection `${collection}_conf_version` version number bumped +/// whenever that collection changes (see `crate::operator`). All fields are +/// optional since a fresh standalone instance's config starts out empty. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ApisixStandalone { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub routes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub services: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub consumers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub global_rules: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_metadata: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstreams: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_routes: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub routes_conf_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub services_conf_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub consumers_conf_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssls_conf_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub global_rules_conf_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_metadata_conf_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstreams_conf_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_routes_conf_version: Option, +} + +/// Server URL -> its admin API token. `IndexMap`, not `HashMap`: several +/// call sites (picking "the first server" to probe for version/validate) +/// rely on iteration order matching configuration order, mirroring the TS +/// backend's own reliance on JS `Map`'s insertion-order iteration. +pub type ServerTokenMap = indexmap::IndexMap; diff --git a/rust/crates/adc-backend-apisix-standalone/src/utils.rs b/rust/crates/adc-backend-apisix-standalone/src/utils.rs new file mode 100644 index 00000000..2bf4b503 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/src/utils.rs @@ -0,0 +1,27 @@ +use std::sync::LazyLock; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +static ORIGIN: LazyLock<(SystemTime, Instant)> = LazyLock::new(|| (SystemTime::now(), Instant::now())); + +pub fn stable_timestamp() -> i64 { + let (origin_wall, origin_monotonic) = *ORIGIN; + let wall = origin_wall + origin_monotonic.elapsed(); + wall.duration_since(UNIX_EPOCH) + .expect("system clock is before the Unix epoch") + .as_millis() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn successive_calls_never_decrease() { + let mut previous = stable_timestamp(); + for _ in 0..1000 { + let current = stable_timestamp(); + assert!(current >= previous, "{current} < {previous}"); + previous = current; + } + } +} diff --git a/rust/crates/adc-backend-apisix-standalone/tests/common/mod.rs b/rust/crates/adc-backend-apisix-standalone/tests/common/mod.rs new file mode 100644 index 00000000..001f6001 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/tests/common/mod.rs @@ -0,0 +1,282 @@ +//! Shared scaffolding for this crate's real-e2e test files: three live +//! standalone APISIX admin APIs, all sharing the same static admin key — +//! see `libs/backend-apisix-standalone/e2e/assets/docker-compose.yaml` (the +//! same fixture the TS suite uses) for how to bring them up, or +//! `.github/workflows/e2e.yaml`'s `apisix-standalone-rust` job for how CI +//! does it. Not every test file uses every item here, so dead-code warnings +//! are suppressed at the module level rather than per item. +#![allow(dead_code)] + +use std::time::Duration; + +use adc_backend_apisix_standalone::tests::Cache; +use adc_backend_apisix_standalone::Backend; +use adc_backend_core::{HttpClient, HttpClientConfig, Method, TlsConfig}; +use adc_sdk::resources::{self as adc, Configuration}; +use adc_sdk::utils::generate_id; +use adc_sdk::{DefaultValue, Event, EventKind, ResourceType}; +use serde_json::Value; + +pub const SERVER1: &str = "http://localhost:19180"; +pub const SERVER2: &str = "http://localhost:29180"; +pub const SERVER3: &str = "http://localhost:39180"; +pub const TOKEN: &str = "edd1c9f034335f136f87ad84b625c8f1"; + +fn tls() -> TlsConfig { + TlsConfig { skip_verify: true, ..Default::default() } +} + +pub fn backend_options(servers: Vec, cache_key: &str) -> adc_backend_apisix_standalone::BackendOptions { + adc_backend_apisix_standalone::BackendOptions { + servers, + tokens: vec![TOKEN.to_string()], + cache_key: cache_key.to_string(), + bypass_cache: false, + timeout: Some(Duration::from_secs(10)), + tls: tls(), + } +} + +/// A backend against just `SERVER1` — matches most of the TS suite's +/// `describe` blocks, which only ever exercise a single instance. +pub fn backend(cache_key: &str) -> Backend { + Backend::new(backend_options(vec![SERVER1.to_string()], cache_key)).unwrap() +} + +/// A backend against one specific server, for scenarios that write to each +/// standalone instance independently before a later multi-server backend +/// reads them back. +pub fn backend_for(server: &str, cache_key: &str) -> Backend { + Backend::new(backend_options(vec![server.to_string()], cache_key)).unwrap() +} + +/// A backend against all three servers, mirroring the TS suite's `servers`/ +/// `tokens` comma-joined constants. +pub fn backend_multi(cache_key: &str) -> Backend { + Backend::new(backend_options(vec![SERVER1.to_string(), SERVER2.to_string(), SERVER3.to_string()], cache_key)).unwrap() +} + +/// The CI matrix runs this suite against every supported APISIX release +/// (`BACKEND_APISIX_VERSION`, same env var the TS e2e suite reads) — falls +/// back to a version high enough to exercise every version-gated code path +/// when unset, for local runs against whatever's in the compose file. +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}")), + Err(_) => semver::Version::new(999, 999, 999), + } +} + +/// Mirrors `support/utils.ts`'s `createEvent`'s id-generation rule: most +/// resource types hash `parent_name.resource_name` (or just `resource_name` +/// with no parent); consumers/global rules/plugin metadata use their name +/// as-is (they're addressed by it directly, not a derived hash); SSLs hash +/// their SNI list instead of a name, so they don't go through this helper +/// at all (see `create_ssl_event`). +fn resource_id_for(rt: ResourceType, resource_name: &str, parent_name: Option<&str>) -> String { + match rt { + ResourceType::Consumer | ResourceType::GlobalRule | ResourceType::PluginMetadata => resource_name.to_string(), + _ => match parent_name { + Some(parent) => generate_id(&format!("{parent}.{resource_name}")), + None => generate_id(resource_name), + }, + } +} + +fn parent_id_for(rt: ResourceType, parent_name: Option<&str>) -> Option { + parent_name.map(|parent| if rt == ResourceType::ConsumerCredential { parent.to_string() } else { generate_id(parent) }) +} + +pub fn create_event(rt: ResourceType, resource_name: &str, new_value: Value, parent_name: Option<&str>) -> Event { + let mut event = Event::new(rt, EventKind::Create { new_value }, resource_id_for(rt, resource_name, parent_name), resource_name); + event.parent_id = parent_id_for(rt, parent_name); + event +} + +pub fn update_event(rt: ResourceType, resource_name: &str, new_value: Value, old_value: Value, parent_name: Option<&str>) -> Event { + let mut event = Event::new( + rt, + EventKind::Update { old_value, new_value, diff: None }, + resource_id_for(rt, resource_name, parent_name), + resource_name, + ); + event.parent_id = parent_id_for(rt, parent_name); + event +} + +pub fn delete_event(rt: ResourceType, resource_name: &str, parent_name: Option<&str>) -> Event { + let mut event = Event::new( + rt, + EventKind::Delete { old_value: Value::Null }, + resource_id_for(rt, resource_name, parent_name), + resource_name, + ); + event.parent_id = parent_id_for(rt, parent_name); + event +} + +pub fn cache() -> &'static Cache { + Cache::global() +} + +/// Wipes every standalone instance back to an empty config, the same way +/// the TS suite's own `restartAPISIX()` (a `docker compose restart` in +/// `libs/backend-apisix-standalone/e2e/assets`) does before each scenario — +/// standalone's declarative document lives entirely on the live servers, +/// with no per-test namespacing, so without this, resources left behind by +/// one test function would collide with (or be silently reused by) the +/// next one to run against the same three containers. +pub async fn restart_apisix() { + let compose_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../libs/backend-apisix-standalone/e2e/assets"); + let status = tokio::process::Command::new("docker") + .args(["compose", "restart"]) + .current_dir(&compose_dir) + .status() + .await + .unwrap_or_else(|e| panic!("failed to run `docker compose restart` in {compose_dir:?}: {e}")); + assert!(status.success(), "`docker compose restart` in {compose_dir:?} failed"); + + for server in [SERVER1, SERVER2, SERVER3] { + wait_until_ready(server).await; + } +} + +/// Polls `server`'s admin API until it answers `/apisix/admin/configs` with +/// a genuine `200` — not merely "anything but 404". A fixed post-restart +/// sleep isn't reliable across APISIX versions: real container logs from a +/// 3.13.0 CI failure show `docker compose restart` returning (Docker's own +/// "container started" signal) 1-2 full seconds *before* APISIX's own boot +/// sequence inside it — `init_etcd`, then per-worker `init_worker_by_lua` +/// loading ~80 plugins — actually finishes registering the admin routes. +/// A request landing in that window can get more than just a plain 404: +/// nginx's master process can already be accepting connections before +/// content routing is live, so a stray non-404, non-200 status (a 5xx from +/// Lua init not being ready, or similar) is possible too — checking only +/// "not 404" treated one of those as "ready" once and let a real request +/// moments later land back in the same still-initializing window. Requiring +/// a 200 specifically, twice in a row, is a tighter bar that a single lucky +/// sample during a churning startup can't satisfy by accident. +async fn wait_until_ready(server: &str) { + let client = HttpClient::new(HttpClientConfig { + server: server.to_string(), + token: TOKEN.to_string(), + timeout: Some(Duration::from_secs(2)), + tls: tls(), + }) + .unwrap(); + + const MAX_ATTEMPTS: u32 = 60; + const REQUIRED_CONSECUTIVE_SUCCESSES: u32 = 2; + let mut consecutive_successes = 0; + for attempt in 1..=MAX_ATTEMPTS { + let got_200 = match client.request(Method::GET, "/apisix/admin/configs") { + Ok(request) => match client.execute(request).await { + Ok(response) => response.status().as_u16() == 200, + Err(_) => false, + }, + Err(_) => false, + }; + consecutive_successes = if got_200 { consecutive_successes + 1 } else { 0 }; + if consecutive_successes >= REQUIRED_CONSECUTIVE_SUCCESSES { + return; + } + if attempt == MAX_ATTEMPTS { + panic!("{server} never became ready after `docker compose restart` ({MAX_ATTEMPTS} attempts)"); + } + tokio::time::sleep(Duration::from_millis(500)).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 +/// the TS suite's own `Differ.diff(config, await dumpConfiguration(backend))` +/// calls work, and `adc-backend-api7`'s e2e suite's own `common::diff`. Used +/// wherever the TS spec builds events via `Differ.diff` rather than its +/// hand-rolled `createEvent`/`updateEvent`/`deleteEvent` — an `Update` +/// event's `diff` field content matters for standalone specifically (a +/// SERVICE update only touches the `services` collection when the diff +/// shows more than just its `upstream` changed), so those scenarios need a +/// real diff, not a hand-built one with `diff: None`. +pub fn diff(local: &Configuration, remote: &Configuration) -> 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), None::<&DefaultValue>, None) +} + +/// Reads one `*_conf_version` field straight off `SERVER1`'s admin API — +/// bypasses this crate's own cache entirely, so it reflects what the server +/// actually has, not what we think we last wrote. `field` is the raw JSON +/// key, e.g. `"consumers_conf_version"`. +pub async fn raw_conf_version(field: &str) -> Option { + let client = HttpClient::new(HttpClientConfig { + server: SERVER1.to_string(), + token: TOKEN.to_string(), + timeout: None, + tls: TlsConfig::default(), + }) + .unwrap(); + let request = client.request(Method::GET, "/apisix/admin/configs").unwrap(); + let body: Value = client.send_json(request).await.unwrap(); + body.get(field).and_then(Value::as_i64) +} + +/// An `adc::Upstream` with every field at its zero value — shared starting +/// point for tests that only care about a couple of fields, via struct +/// update syntax (`..common::base_upstream()`). +pub fn base_upstream() -> adc::Upstream { + adc::Upstream { + id: None, + name: None, + description: None, + labels: None, + r#type: adc::UpstreamBalancer::default(), + hash_on: None, + key: None, + checks: None, + nodes: None, + scheme: adc::UpstreamScheme::default(), + retries: None, + retry_timeout: None, + timeout: None, + tls: None, + keepalive_pool: None, + pass_host: adc::UpstreamPassHost::default(), + upstream_host: None, + service_name: None, + discovery_type: None, + discovery_args: None, + } +} + +/// An `adc::Service` with every field at its zero value — see +/// [`base_upstream`]. +pub fn base_service() -> adc::Service { + adc::Service { + id: None, + name: String::new(), + description: None, + labels: None, + upstream: None, + upstreams: None, + plugins: None, + path_prefix: None, + strip_path_prefix: None, + hosts: None, + routes: None, + } +} + +pub fn empty_configuration() -> Configuration { + Configuration { + services: None, + ssls: None, + consumers: None, + consumer_groups: None, + global_rules: None, + plugin_metadata: None, + } +} diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_cache.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_cache.rs new file mode 100644 index 00000000..8616fcef --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_cache.rs @@ -0,0 +1,267 @@ +//! Ported from `libs/backend-apisix-standalone/e2e/cache.e2e-spec.ts`. Real +//! network calls against a live 3-instance standalone APISIX cluster — see +//! `common`'s module doc for how to bring one up and run this file. +//! +//! The TS suite pins `stableTimestamp()` to exact values via `vi.mock` for +//! several assertions; this port has no clock-injection seam (see +//! `e2e_resource_service_inline_upstream.rs`'s module doc for why) and +//! checks the same properties via self-consistency and real wall-clock +//! ordering instead. Not ported: the TS suite's final `axios.get(...)` +//! check per `describe` block that port 9080 (the data plane, not the +//! admin API) also 401s without a key — that exercises APISIX's own HTTP +//! server, not anything in this crate. + +use std::time::Duration; + +use adc_backend_apisix_standalone::Backend; +use adc_sdk::resources::{self as adc, Configuration}; +use adc_sdk::Backend as _; +use adc_sdk::BackendSyncOptions; + +mod common; +use common::{backend, backend_for, backend_multi, base_service, base_upstream, diff, empty_configuration}; + +async fn dump(backend: &Backend) -> Configuration { + backend.dump().await.unwrap() +} + +async fn sync_ok(backend: &Backend, events: Vec) -> Vec { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}: {:?}", result.server, result.error); + } + results +} + +fn node(host: &str, port: u32) -> adc::UpstreamNode { + adc::UpstreamNode { host: host.to_string(), port, weight: 100, priority: 0, metadata: None } +} + +/// A `service1` (with an inline upstream on `upstream_port`, and a route +/// bound to `/apisix/admin/configs`) plus two empty-plugin consumers, +/// `jack`/`jane` — mirrors the fixture shared by `cache.e2e-spec.ts`'s +/// first three `describe` blocks. +fn fixture_config(upstream_port: u32) -> Configuration { + let service = adc::Service { + name: "service1".to_string(), + upstream: Some(adc::Upstream { nodes: Some(vec![node("127.0.0.1", upstream_port)]), ..base_upstream() }), + routes: Some(adc::ServiceRoutes::Http { + routes: vec![adc::Route { + id: None, + name: "route1".to_string(), + description: None, + labels: None, + hosts: None, + uris: vec!["/apisix/admin/configs".to_string()], + priority: None, + timeout: None, + vars: None, + methods: None, + enable_websocket: None, + remote_addrs: None, + plugins: None, + filter_func: None, + }], + }), + ..base_service() + }; + Configuration { + services: Some(vec![service]), + consumers: Some(vec![ + adc::Consumer { username: "jack".to_string(), description: None, labels: None, plugins: Some(adc::Plugins::new()), credentials: None }, + adc::Consumer { username: "jane".to_string(), description: None, labels: None, plugins: Some(adc::Plugins::new()), credentials: None }, + ]), + ..empty_configuration() + } +} + +/// `fixture_config`'s service+route, without the two consumers — matches +/// the smaller `config` object the TS suite's own "Partial new instances" +/// scenario uses (unlike its other scenarios, that one never syncs any +/// consumers at all). +fn service_with_route_config(upstream_port: u32) -> Configuration { + let mut config = fixture_config(upstream_port); + config.consumers = None; + config +} + +fn assert_fresh_cache_shape(config: &Configuration) { + assert!(config.services.is_none(), "a never-configured instance has no services yet"); + assert!(config.ssls.is_none()); + assert!(config.consumers.is_none()); + assert!(config.global_rules.is_none()); + assert!(config.plugin_metadata.is_none()); +} + +#[tokio::test] +#[ignore] +async fn single_instance_initializes_caches_and_syncs() { + common::restart_apisix().await; + let cache_key = "cache-e2e-single"; + let backend = backend(cache_key); + + assert!(common::cache().config(cache_key).is_none()); + assert!(common::cache().raw_config(cache_key).is_none()); + + let initial = dump(&backend).await; + assert!(common::cache().config(cache_key).is_some()); + let raw = common::cache().raw_config(cache_key).expect("dump populates the raw config cache"); + assert_fresh_cache_shape(&initial); + // A never-configured instance reports every conf_version as a present 0, + // not absent — the document itself already exists, just empty. + assert_eq!(raw.routes_conf_version, Some(0)); + assert_eq!(raw.services_conf_version, Some(0)); + assert_eq!(raw.consumers_conf_version, Some(0)); + assert_eq!(raw.ssls_conf_version, Some(0)); + assert_eq!(raw.global_rules_conf_version, Some(0)); + assert_eq!(raw.plugin_metadata_conf_version, Some(0)); + assert_eq!(raw.upstreams_conf_version, Some(0)); + // Not asserted like the others: stream routes are a newer standalone + // feature, absent from the document's schema entirely (not merely + // zeroed) on some of this suite's older supported versions — the same + // reason the TS suite's own equivalent check only walks whatever keys + // the raw document actually has, rather than a fixed list. + assert!(matches!(raw.stream_routes_conf_version, None | Some(0))); + + // A second dump is served from cache — same result, no new fetch. + let again = dump(&backend).await; + assert_eq!(again, initial); + + let before = dump(&backend).await; + let local = fixture_config(9180); + let events = diff(&local, &before); + assert_eq!(events.len(), 4, "service + route + 2 consumers"); + assert!(events.iter().all(|e| e.event_type() == adc_sdk::EventType::Create)); + + let results = sync_ok(&backend, events).await; + assert_eq!(results.len(), 1, "a single-server backend writes to exactly one server"); + assert_eq!(results[0].server.as_deref(), Some(common::SERVER1)); + + let config = common::cache().config(cache_key).unwrap(); + let services = config.services.unwrap(); + assert_eq!(services[0].name, "service1"); + let consumers = config.consumers.unwrap(); + assert!(consumers.iter().any(|c| c.username == "jack")); + assert!(consumers.iter().any(|c| c.username == "jane")); + + // Every resource created in this one sync call shares the same + // timestamp, on both its own `modifiedIndex` and its collection's + // `conf_version` — and that's also what got cached as `latest_version`. + let raw = common::cache().raw_config(cache_key).unwrap(); + let timestamp = raw.services.as_ref().unwrap()[0].modified_index; + assert_eq!(raw.services_conf_version, Some(timestamp)); + assert_eq!(raw.routes.as_ref().unwrap()[0].modified_index, timestamp); + assert_eq!(raw.routes_conf_version, Some(timestamp)); + assert_eq!(raw.upstreams.as_ref().unwrap()[0].modified_index, timestamp); + assert_eq!(raw.upstreams_conf_version, Some(timestamp)); + for consumer in raw.consumers.as_ref().unwrap() { + let consumer = consumer.as_consumer().expect("this fixture has no credentials, only plain consumers"); + assert_eq!(consumer.modified_index, timestamp); + } + assert_eq!(raw.consumers_conf_version, Some(timestamp)); + assert_eq!(common::cache().latest_version(cache_key), Some(timestamp)); +} + +#[tokio::test] +#[ignore] +async fn multiple_fresh_instances_all_receive_the_sync() { + common::restart_apisix().await; + let cache_key = "cache-e2e-multi-fresh"; + let backend = backend_multi(cache_key); + + let initial = dump(&backend).await; + assert_fresh_cache_shape(&initial); + let raw = common::cache().raw_config(cache_key).unwrap(); + assert_eq!(raw.services_conf_version, Some(0)); + + let before = dump(&backend).await; + let events = diff(&fixture_config(9180), &before); + assert_eq!(events.len(), 4); + + let results = sync_ok(&backend, events).await; + assert_eq!(results.len(), 3, "a 3-server backend writes to every server"); + let mut servers: Vec<&str> = results.iter().filter_map(|r| r.server.as_deref()).collect(); + servers.sort_unstable(); + assert_eq!(servers, vec![common::SERVER1, common::SERVER2, common::SERVER3]); +} + +#[tokio::test] +#[ignore] +async fn a_multi_server_dump_picks_up_whichever_server_was_updated_most_recently() { + common::restart_apisix().await; + let cache_key = "cache-e2e-partial"; + + // Write independently to server1 (older) ... + common::cache().invalidate(cache_key); + let backend1 = backend_for(common::SERVER1, cache_key); + let events = diff(&service_with_route_config(5432), &empty_configuration()); + assert_eq!(events.len(), 2, "service + route; no consumers in this fixture"); + sync_ok(&backend1, events).await; + + // ... then a moment later, independently to server2 (newer). A real + // sleep, not a mocked clock: server2's write must land at a genuinely + // later wall-clock timestamp than server1's for `find_latest` to be + // able to tell them apart by `X-Last-Modified`. + tokio::time::sleep(Duration::from_millis(200)).await; + + common::cache().invalidate(cache_key); + let backend2 = backend_for(common::SERVER2, cache_key); + let events = diff(&service_with_route_config(3306), &empty_configuration()); + sync_ok(&backend2, events).await; + + // server3 was never written at all — a real 3-way race between an + // untouched, an older, and a newer instance. + common::cache().invalidate(cache_key); + let backend_multi = backend_multi(cache_key); + let config = dump(&backend_multi).await; + + if common::apisix_version() > semver::Version::new(3, 13, 0) { + let services = config.services.expect("the winning server has data"); + let port = services[0].upstream.as_ref().unwrap().nodes.as_ref().unwrap()[0].port; + assert_eq!(port, 3306, "must pick server2's (the more recently written) document, not server1's"); + } else { + assert!(common::cache().raw_config(cache_key).is_some()); + } +} + +#[tokio::test] +#[ignore] +async fn bypass_cache_discards_stale_state_and_refetches() { + common::restart_apisix().await; + let cache_key = "cache-e2e-bypass"; + common::cache().invalidate(cache_key); + + let backend = backend(cache_key); + dump(&backend).await; + let synced = fixture_config(9180); + sync_ok(&backend, diff(&synced, &empty_configuration())).await; + let cached = common::cache().config(cache_key).unwrap(); + assert_eq!(cached.services.unwrap()[0].name, "service1"); + + // Inject data that doesn't exist on the real server, simulating a + // cache that's gone stale relative to it. + let mut stale = fixture_config(80); + stale.services.as_mut().unwrap()[0].name = "stale-service".to_string(); + common::cache().set_config(cache_key, stale.clone()); + assert_eq!(common::cache().config(cache_key).unwrap().services.unwrap()[0].name, "stale-service"); + + // Without bypassing, dump serves the (now stale) cache as-is. + let result = dump(&backend).await; + assert_eq!(result.services.unwrap()[0].name, "stale-service"); + + // A backend with `bypass_cache` discards it and re-fetches for real. + let mut opts = common::backend_options(vec![common::SERVER1.to_string()], cache_key); + opts.bypass_cache = true; + let bypass_backend = Backend::new(opts).unwrap(); + + let result = dump(&bypass_backend).await; + assert_eq!(result.services.as_ref().unwrap()[0].name, "service1", "bypassing must re-fetch the real server state, not the stale cache"); + + // The cache is now repopulated with the fresh data ... + let cached = common::cache().config(cache_key).unwrap(); + assert_eq!(cached.services.as_ref().unwrap()[0].name, "service1"); + + // ... and a subsequent non-bypassing dump serves that repopulated cache. + let result = dump(&backend).await; + assert_eq!(result.services.unwrap()[0].name, "service1"); +} diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_consumer.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_consumer.rs new file mode 100644 index 00000000..e134b005 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_consumer.rs @@ -0,0 +1,108 @@ +//! Ported from `libs/backend-apisix-standalone/e2e/resources/consumer.e2e-spec.ts`. +//! Real network calls against a live 3-instance standalone APISIX cluster — +//! see `common`'s module doc for how to bring one up and run this file. + +use adc_backend_apisix_standalone::Backend; +use adc_sdk::resources::Configuration; +use adc_sdk::Backend as _; +use adc_sdk::{BackendSyncOptions, ResourceType}; +use serde_json::json; + +mod common; +use common::{backend, create_event, delete_event, raw_conf_version, update_event}; + +async fn dump(backend: &Backend) -> Configuration { + backend.dump().await.unwrap() +} + +async fn sync_ok(backend: &Backend, events: Vec) { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}: {:?}", result.server, result.error); + } +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_consumers_with_credentials() { + common::restart_apisix().await; + let backend = backend("consumer-e2e"); + dump(&backend).await; + + let consumer_name = "consumer1"; + let cred1_name = "consumer1-key"; + let cred2_name = "consumer1-key2"; + + sync_ok( + &backend, + vec![ + create_event(ResourceType::Consumer, consumer_name, json!({ "username": consumer_name }), None), + create_event( + ResourceType::ConsumerCredential, + cred1_name, + json!({ "name": cred1_name, "type": "key-auth", "config": { "key": cred1_name } }), + Some(consumer_name), + ), + create_event( + ResourceType::ConsumerCredential, + cred2_name, + json!({ "name": cred2_name, "type": "key-auth", "config": { "key": cred2_name } }), + Some(consumer_name), + ), + ], + ) + .await; + + let config = dump(&backend).await; + let consumers = config.consumers.expect("consumer was just created"); + assert_eq!(consumers.len(), 1); + assert_eq!(consumers[0].username, consumer_name); + let credentials = consumers[0].credentials.clone().expect("credentials were just created"); + assert_eq!(credentials.len(), 2); + assert!(credentials.iter().any(|c| c.name == cred1_name)); + assert!(credentials.iter().any(|c| c.name == cred2_name)); + + let version_before_update = raw_conf_version("consumers_conf_version").await; + sync_ok( + &backend, + vec![update_event( + ResourceType::ConsumerCredential, + cred1_name, + json!({ "name": cred1_name, "type": "key-auth", "config": { "key": "new-key" } }), + json!({ "name": cred1_name, "type": "key-auth", "config": { "key": cred1_name } }), + Some(consumer_name), + )], + ) + .await; + let version_after_update = raw_conf_version("consumers_conf_version").await; + assert!(version_after_update > version_before_update, "updating a credential must bump consumers_conf_version"); + + let config = dump(&backend).await; + let credential1 = config.consumers.as_ref().unwrap()[0] + .credentials + .as_ref() + .unwrap() + .iter() + .find(|c| c.name == cred1_name) + .expect("credential1 still exists"); + assert_eq!(credential1.config.get("key"), Some(&json!("new-key"))); + + sync_ok(&backend, vec![delete_event(ResourceType::ConsumerCredential, cred1_name, Some(consumer_name))]).await; + + let config = dump(&backend).await; + let credentials = config.consumers.as_ref().unwrap()[0].credentials.clone().unwrap(); + assert_eq!(credentials.len(), 1); + assert_eq!(credentials[0].name, cred2_name); + + sync_ok( + &backend, + vec![ + delete_event(ResourceType::Consumer, consumer_name, None), + delete_event(ResourceType::ConsumerCredential, cred2_name, Some(consumer_name)), + ], + ) + .await; + + let config = dump(&backend).await; + assert_eq!(config.consumers.map(|c| c.len()).unwrap_or(0), 0); +} diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_global_rule.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_global_rule.rs new file mode 100644 index 00000000..e538e0cf --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_global_rule.rs @@ -0,0 +1,79 @@ +//! Ported from `libs/backend-apisix-standalone/e2e/resources/global-rule.e2e-spec.ts`. +//! Real network calls against a live 3-instance standalone APISIX cluster — +//! see `common`'s module doc for how to bring one up and run this file. + +use adc_backend_apisix_standalone::Backend; +use adc_sdk::resources::Configuration; +use adc_sdk::Backend as _; +use adc_sdk::{BackendSyncOptions, ResourceType}; +use serde_json::json; + +mod common; +use common::{backend, create_event, delete_event, raw_conf_version, update_event}; + +async fn dump(backend: &Backend) -> Configuration { + backend.dump().await.unwrap() +} + +async fn sync_ok(backend: &Backend, events: Vec) { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}: {:?}", result.server, result.error); + } +} + +#[tokio::test] +#[ignore] +async fn creates_dumps_updates_and_deletes_global_rules() { + common::restart_apisix().await; + let backend = backend("global-rule-e2e"); + + // Initialize cache. + dump(&backend).await; + + let plugin1_name = "request-id"; + let plugin2_name = "prometheus"; + sync_ok( + &backend, + vec![ + create_event(ResourceType::GlobalRule, plugin1_name, json!({}), None), + create_event(ResourceType::GlobalRule, plugin2_name, json!({ "prefer_name": true }), None), + ], + ) + .await; + + let config = dump(&backend).await; + let global_rules = config.global_rules.expect("global rules were just created"); + assert_eq!(global_rules.len(), 2); + assert_eq!(global_rules.get(plugin1_name), Some(&json!({}))); + assert_eq!(global_rules.get(plugin2_name).and_then(|v| v.get("prefer_name")), Some(&json!(true))); + + // Regression coverage for #489: re-syncing the already-applied state + // must be recognized as identical (by the digest this crate stamps on + // every PUT — see `crate::operator::Operator::sync`) and not bump the + // server's own conf_version, even though a document still gets sent. + let version_before = raw_conf_version("global_rules_conf_version").await.expect("global_rules_conf_version should be present once the document exists"); + sync_ok(&backend, vec![]).await; + let version_after = raw_conf_version("global_rules_conf_version").await.expect("global_rules_conf_version should be present once the document exists"); + assert_eq!(version_before, version_after, "resyncing unchanged global rules must not bump the conf_version"); + + sync_ok( + &backend, + vec![update_event(ResourceType::GlobalRule, plugin1_name, json!({ "enable": false }), json!({}), None)], + ) + .await; + + let config = dump(&backend).await; + let global_rules = config.global_rules.expect("global rules still exist"); + assert_eq!(global_rules.get(plugin1_name).and_then(|v| v.get("enable")), Some(&json!(false))); + assert_eq!(global_rules.get(plugin2_name).and_then(|v| v.get("prefer_name")), Some(&json!(true))); + + sync_ok( + &backend, + vec![delete_event(ResourceType::GlobalRule, plugin1_name, None), delete_event(ResourceType::GlobalRule, plugin2_name, None)], + ) + .await; + + let config = dump(&backend).await; + assert_eq!(config.global_rules.map(|rules| rules.len()).unwrap_or(0), 0); +} diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service.rs new file mode 100644 index 00000000..15956d58 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service.rs @@ -0,0 +1,176 @@ +//! Ported from `libs/backend-apisix-standalone/e2e/resources/service.e2e-spec.ts`. +//! Real network calls against a live 3-instance standalone APISIX cluster — +//! see `common`'s module doc for how to bring one up and run this file. + +use adc_backend_apisix_standalone::Backend; +use adc_sdk::resources::{self as adc, Configuration}; +use adc_sdk::Backend as _; +use adc_sdk::{BackendSyncOptions, ResourceType}; +use serde_json::json; + +mod common; +use common::{backend, base_service, base_upstream, create_event, delete_event, diff, empty_configuration}; + +async fn dump(backend: &Backend) -> Configuration { + backend.dump().await.unwrap() +} + +async fn sync_ok(backend: &Backend, events: Vec) { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}: {:?}", result.server, result.error); + } +} + +fn config_with_services(services: Vec) -> Configuration { + Configuration { services: Some(services), ..empty_configuration() } +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_services_with_no_routes() { + common::restart_apisix().await; + let backend = backend("service-e2e-empty"); + dump(&backend).await; + + let test_upstream = adc::Upstream { + description: Some("test upstream".to_string()), + scheme: adc::UpstreamScheme::Https, + nodes: Some(vec![adc::UpstreamNode { host: "httpbin.org".to_string(), port: 443, weight: 100, priority: 0, metadata: None }]), + ..base_upstream() + }; + let service1 = adc::Service { + name: "service1".to_string(), + upstream: Some(test_upstream.clone()), + hosts: Some(vec!["example1.com".to_string(), "example2.com".to_string()]), + ..base_service() + }; + let service2 = adc::Service { name: "service2".to_string(), upstream: Some(test_upstream.clone()), ..base_service() }; + + let before = dump(&backend).await; + let events = diff(&config_with_services(vec![service1.clone(), service2.clone()]), &before); + sync_ok(&backend, events).await; + + let config = dump(&backend).await; + let services = config.services.expect("services were just created"); + assert_eq!(services.len(), 2); + let dumped1 = services.iter().find(|s| s.name == "service1").expect("service1 exists"); + assert_eq!(dumped1.hosts, service1.hosts); + assert_eq!(dumped1.upstream.as_ref().map(|u| u.scheme), Some(adc::UpstreamScheme::Https)); + let dumped2 = services.iter().find(|s| s.name == "service2").expect("service2 exists"); + assert_eq!(dumped2.hosts, None); + + let before = dump(&backend).await; + let updated_service1 = adc::Service { description: Some("desc".to_string()), ..service1.clone() }; + let events = diff(&config_with_services(vec![updated_service1, service2.clone()]), &before); + sync_ok(&backend, events).await; + + let config = dump(&backend).await; + let services = config.services.expect("services still exist"); + let dumped2 = services.iter().find(|s| s.name == "service2").expect("service2 untouched by service1's update"); + assert_eq!(dumped2.description, None); + + sync_ok(&backend, vec![delete_event(ResourceType::Service, "service1", None)]).await; + let config = dump(&backend).await; + let services = config.services.expect("service2 remains"); + assert_eq!(services.len(), 1); + assert_eq!(services[0].name, "service2"); + + sync_ok(&backend, vec![delete_event(ResourceType::Service, "service2", None)]).await; + let config = dump(&backend).await; + assert_eq!(config.services.map(|s| s.len()).unwrap_or(0), 0); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_a_service_with_routes() { + common::restart_apisix().await; + let backend = backend("service-e2e-routes"); + + let service_name = "test"; + let route1_name = "route1"; + let route2_name = "route2"; + + sync_ok( + &backend, + vec![ + create_event( + ResourceType::Service, + service_name, + json!({ "name": service_name, "upstream": { "scheme": "https", "nodes": [{ "host": "httpbin.org", "port": 443, "weight": 100 }] } }), + None, + ), + create_event(ResourceType::Route, route1_name, json!({ "name": route1_name, "uris": ["/route1"] }), Some(service_name)), + create_event( + ResourceType::Route, + route2_name, + json!({ "name": route2_name, "uris": ["/route2"], "plugins": { "key-auth": {} } }), + Some(service_name), + ), + ], + ) + .await; + + let config = dump(&backend).await; + let services = config.services.expect("service was just created"); + assert_eq!(services.len(), 1); + let routes = services[0].routes.as_ref().and_then(adc::ServiceRoutes::http).expect("service has http routes"); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].name, route1_name); + assert_eq!(routes[0].uris, vec!["/route1".to_string()]); + assert_eq!(routes[1].name, route2_name); + assert_eq!(routes[1].uris, vec!["/route2".to_string()]); + + sync_ok(&backend, vec![delete_event(ResourceType::Route, route1_name, Some(service_name))]).await; + let config = dump(&backend).await; + let services = config.services.unwrap(); + let routes = services[0].routes.as_ref().and_then(adc::ServiceRoutes::http).expect("route2 remains"); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].name, route2_name); + + sync_ok( + &backend, + vec![ + delete_event(ResourceType::Route, route2_name, Some(service_name)), + delete_event(ResourceType::Service, service_name, None), + ], + ) + .await; + let config = dump(&backend).await; + assert_eq!(config.services.map(|s| s.len()).unwrap_or(0), 0); +} + +#[tokio::test] +#[ignore] +async fn syncs_a_service_with_a_service_discovery_upstream_and_no_static_nodes() { + common::restart_apisix().await; + let backend = backend("service-e2e-discovery"); + + let registry_name = "consul"; + let service_name = "svc-upstream-sd"; + let service = adc::Service { + name: service_name.to_string(), + upstream: Some(adc::Upstream { + r#type: adc::UpstreamBalancer::RoundRobin, + discovery_type: Some(registry_name.to_string()), + service_name: Some(service_name.to_string()), + ..base_upstream() + }), + ..base_service() + }; + + let before = dump(&backend).await; + let events = diff(&config_with_services(vec![service]), &before); + sync_ok(&backend, events).await; + + let raw = common::cache().raw_config("service-e2e-discovery").expect("dump/sync populate the raw config cache"); + let upstreams = raw.upstreams.expect("the service's default upstream was written"); + assert_eq!(upstreams.len(), 1); + assert_eq!(upstreams[0].nodes, None, "a discovery-based upstream has no static node list"); + assert_eq!(upstreams[0].discovery_type.as_deref(), Some(registry_name)); + assert_eq!(upstreams[0].service_name.as_deref(), Some(service_name)); + + let before = dump(&backend).await; + let events = diff(&empty_configuration(), &before); + sync_ok(&backend, events).await; +} diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service_inline_upstream.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service_inline_upstream.rs new file mode 100644 index 00000000..2335c426 --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service_inline_upstream.rs @@ -0,0 +1,138 @@ +//! Ported from +//! `libs/backend-apisix-standalone/e2e/resources/service-inline-upstream.e2e-spec.ts`. +//! Real network calls against a live 3-instance standalone APISIX cluster — +//! see `common`'s module doc for how to bring one up and run this file. +//! +//! The TS suite pins `stableTimestamp()` (via `vi.mock`) to exact values +//! (100/200/300/400) and asserts every `modifiedIndex`/`*_conf_version` +//! against them directly. This crate has no clock-injection seam — adding +//! one purely to make a handful of assertions exact would be a production +//! code change made only to serve a test, not because anything real needs +//! it — so this port checks the same underlying invariants a different way: +//! a service's own `modifiedIndex` must stay fixed across an upstream-only +//! update (and only the `upstreams` collection's version moves), and each +//! subsequent write's timestamp must be strictly greater than the last. + +use adc_backend_apisix_standalone::Backend; +use adc_sdk::resources::{self as adc, Configuration}; +use adc_sdk::Backend as _; +use adc_sdk::BackendSyncOptions; + +mod common; +use common::{backend, base_service, base_upstream, diff, empty_configuration}; + +const CACHE_KEY: &str = "service-inline-upstream-e2e"; +const SERVICE_NAME: &str = "test"; + +async fn dump(backend: &Backend) -> Configuration { + backend.dump().await.unwrap() +} + +async fn sync_ok(backend: &Backend, events: Vec) { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}: {:?}", result.server, result.error); + } +} + +fn service_with_nodes(nodes: Vec) -> Configuration { + let service = adc::Service { + name: SERVICE_NAME.to_string(), + upstream: Some(adc::Upstream { nodes: Some(nodes), ..base_upstream() }), + ..base_service() + }; + Configuration { services: Some(vec![service]), ..empty_configuration() } +} + +fn node(port: u32) -> adc::UpstreamNode { + adc::UpstreamNode { host: "127.0.0.1".to_string(), port, weight: 100, priority: 0, metadata: None } +} + +#[tokio::test] +#[ignore] +async fn a_service_only_update_never_moves_the_services_conf_version_only_upstreams() { + common::restart_apisix().await; + let backend = backend(CACHE_KEY); + dump(&backend).await; + + // --- Create: service with an inline default upstream. --- + let before = dump(&backend).await; + let local = service_with_nodes(vec![node(9180)]); + sync_ok(&backend, diff(&local, &before)).await; + + let raw = common::cache().raw_config(CACHE_KEY).unwrap(); + let service_id = raw.services.as_ref().unwrap()[0].id.clone(); + let service_modified_index = raw.services.as_ref().unwrap()[0].modified_index; + let upstream_modified_index_1 = raw.upstreams.as_ref().unwrap()[0].modified_index; + assert_eq!(raw.upstreams.as_ref().unwrap()[0].id, service_id, "the inline upstream shares the service's own id"); + assert_eq!(raw.upstreams.as_ref().unwrap()[0].name, SERVICE_NAME); + assert_eq!(raw.services_conf_version, Some(service_modified_index)); + assert_eq!(raw.upstreams_conf_version, Some(upstream_modified_index_1)); + // Untouched collections aren't absent — the document already exists + // (this crate's own "Initialize cache" dump established that), so every + // conf_version field is present, just still at its baseline 0. + assert_eq!(raw.consumers_conf_version, Some(0)); + assert_eq!(raw.global_rules_conf_version, Some(0)); + assert_eq!(raw.plugin_metadata_conf_version, Some(0)); + assert_eq!(raw.routes_conf_version, Some(0)); + assert_eq!(raw.ssls_conf_version, Some(0)); + + // --- Update: only the inline upstream's port changes. --- + let before = dump(&backend).await; + let local = service_with_nodes(vec![node(19080)]); + let events = diff(&local, &before); + assert_eq!(events.len(), 1); + assert_eq!(events[0].resource_type, adc_sdk::ResourceType::Service); + sync_ok(&backend, events).await; + + let raw = common::cache().raw_config(CACHE_KEY).unwrap(); + let upstream_modified_index_2 = raw.upstreams.as_ref().unwrap()[0].modified_index; + assert_eq!(raw.services.as_ref().unwrap()[0].modified_index, service_modified_index, "service body itself must be untouched"); + assert!(upstream_modified_index_2 > upstream_modified_index_1); + assert_eq!(raw.services_conf_version, Some(service_modified_index)); + assert_eq!(raw.upstreams_conf_version, Some(upstream_modified_index_2)); + assert_eq!(raw.consumers_conf_version, Some(0)); + assert_eq!(raw.global_rules_conf_version, Some(0)); + assert_eq!(raw.plugin_metadata_conf_version, Some(0)); + assert_eq!(raw.routes_conf_version, Some(0)); + assert_eq!(raw.ssls_conf_version, Some(0)); + + // --- Update again: the inline upstream's nodes become empty. --- + let before = dump(&backend).await; + let local = service_with_nodes(vec![]); + let events = diff(&local, &before); + assert_eq!(events.len(), 1); + assert_eq!(events[0].resource_type, adc_sdk::ResourceType::Service); + sync_ok(&backend, events).await; + + let raw = common::cache().raw_config(CACHE_KEY).unwrap(); + let upstream_modified_index_3 = raw.upstreams.as_ref().unwrap()[0].modified_index; + assert_eq!(raw.upstreams.as_ref().unwrap()[0].nodes, Some(vec![])); + assert_eq!(raw.services.as_ref().unwrap()[0].modified_index, service_modified_index, "service body still untouched"); + assert!(upstream_modified_index_3 > upstream_modified_index_2); + assert_eq!(raw.services_conf_version, Some(service_modified_index)); + assert_eq!(raw.upstreams_conf_version, Some(upstream_modified_index_3)); + assert_eq!(raw.consumers_conf_version, Some(0)); + assert_eq!(raw.global_rules_conf_version, Some(0)); + assert_eq!(raw.plugin_metadata_conf_version, Some(0)); + assert_eq!(raw.routes_conf_version, Some(0)); + assert_eq!(raw.ssls_conf_version, Some(0)); + + // --- Delete: both the service and its inline upstream disappear + // together, sharing the same new timestamp. --- + let before = dump(&backend).await; + let events = diff(&empty_configuration(), &before); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type(), adc_sdk::EventType::Delete); + assert_eq!(events[0].resource_type, adc_sdk::ResourceType::Service); + sync_ok(&backend, events).await; + + let raw = common::cache().raw_config(CACHE_KEY).unwrap(); + assert_eq!(raw.upstreams.map(|u| u.len()).unwrap_or(0), 0); + assert_eq!(raw.services.map(|s| s.len()).unwrap_or(0), 0); + let final_services_version = raw.services_conf_version.unwrap(); + let final_upstreams_version = raw.upstreams_conf_version.unwrap(); + assert_eq!(final_services_version, final_upstreams_version, "delete bumps both collections in the same sync"); + assert!(final_services_version > service_modified_index); + assert!(final_upstreams_version > upstream_modified_index_3); +} diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service_upstream.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service_upstream.rs new file mode 100644 index 00000000..eba759ce --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_resource_service_upstream.rs @@ -0,0 +1,149 @@ +//! Ported from `libs/backend-apisix-standalone/e2e/resources/service-upstream.e2e-spec.ts`. +//! Real network calls against a live 3-instance standalone APISIX cluster — +//! see `common`'s module doc for how to bring one up and run this file. + +use adc_backend_apisix_standalone::tests::typing::ADC_UPSTREAM_SERVICE_ID_LABEL; +use adc_backend_apisix_standalone::Backend; +use adc_sdk::resources::{self as adc, Configuration}; +use adc_sdk::utils::generate_id; +use adc_sdk::Backend as _; +use adc_sdk::BackendSyncOptions; + +mod common; +use common::{backend, diff, empty_configuration}; + +const CACHE_KEY: &str = "service-upstream-e2e"; + +async fn dump(backend: &Backend) -> Configuration { + backend.dump().await.unwrap() +} + +async fn sync_ok(backend: &Backend, events: Vec) { + let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); + for result in &results { + assert!(result.success, "{:?}: {:?}", result.server, result.error); + } +} + +fn node(host: &str) -> adc::UpstreamNode { + adc::UpstreamNode { host: host.to_string(), port: 443, weight: 100, priority: 0, metadata: None } +} + +fn base_upstream() -> adc::Upstream { + adc::Upstream { + id: None, + name: None, + description: None, + labels: None, + r#type: adc::UpstreamBalancer::RoundRobin, + hash_on: None, + key: None, + checks: None, + nodes: None, + scheme: adc::UpstreamScheme::Https, + retries: None, + retry_timeout: None, + timeout: None, + tls: None, + keepalive_pool: None, + pass_host: adc::UpstreamPassHost::default(), + upstream_host: None, + service_name: None, + discovery_type: None, + discovery_args: None, + } +} + +fn service_with_named_upstreams(nd1_host: &str) -> adc::Service { + let nd1 = adc::Upstream { name: Some("nd-upstream1".to_string()), nodes: Some(vec![node(nd1_host)]), ..base_upstream() }; + let nd2 = adc::Upstream { + id: Some("nd-upstream2".to_string()), + name: Some("nd-upstream2".to_string()), + nodes: Some(vec![node("1.0.0.1")]), + ..base_upstream() + }; + adc::Service { + id: None, + name: "test".to_string(), + description: None, + labels: None, + upstream: Some(adc::Upstream { nodes: Some(vec![node("httpbin.org")]), ..base_upstream() }), + upstreams: Some(vec![nd1, nd2]), + plugins: None, + path_prefix: None, + strip_path_prefix: None, + hosts: None, + routes: None, + } +} + +fn assert_original_layout() { + let raw = common::cache().raw_config(CACHE_KEY).expect("sync populated the raw config cache"); + let service_id = generate_id("test"); + assert_eq!(raw.services.as_ref().unwrap()[0].id, service_id); + let upstreams = raw.upstreams.expect("default + 2 named upstreams were written"); + assert_eq!(upstreams.len(), 3); + let default_upstream = upstreams.iter().find(|u| u.name == "test").expect("the service's own default upstream"); + let nd1 = upstreams.iter().find(|u| u.name == "nd-upstream1").expect("nd-upstream1"); + let nd2 = upstreams.iter().find(|u| u.name == "nd-upstream2").expect("nd-upstream2"); + assert!(default_upstream.labels.is_none(), "a service's default upstream never carries the service-id bookkeeping label"); + assert_eq!(nd1.labels.as_ref().and_then(|l| l.get(ADC_UPSTREAM_SERVICE_ID_LABEL)), Some(&service_id)); + assert_eq!(nd2.labels.as_ref().and_then(|l| l.get(ADC_UPSTREAM_SERVICE_ID_LABEL)), Some(&service_id)); + + let config = common::cache().config(CACHE_KEY).expect("sync populated the config cache"); + let services = config.services.expect("service exists"); + assert_eq!(services.len(), 1); + let named = services[0].upstreams.as_ref().expect("named upstreams are nested under the service"); + assert_eq!(named.len(), 2); + // The bookkeeping label must not leak into the ADC-facing model. + assert!(named.iter().all(|u| u.labels.as_ref().is_none_or(|l| !l.contains_key(ADC_UPSTREAM_SERVICE_ID_LABEL)))); +} + +#[tokio::test] +#[ignore] +async fn syncs_and_dumps_a_service_with_multiple_named_upstreams() { + common::restart_apisix().await; + let backend = backend(CACHE_KEY); + dump(&backend).await; + + let service = service_with_named_upstreams("1.1.1.1"); + let before = dump(&backend).await; + let local = Configuration { services: Some(vec![service.clone()]), ..empty_configuration() }; + let events = diff(&local, &before); + sync_ok(&backend, events).await; + + assert_original_layout(); + + // Re-syncing the identical desired state produces no events at all. + let before = dump(&backend).await; + let events = diff(&local, &before); + assert!(events.is_empty(), "an unchanged desired state must diff to no events"); + sync_ok(&backend, events).await; + + assert_original_layout(); + + // Change nd-upstream1's node host; nd-upstream2 and the default + // upstream must be untouched. + let updated_service = service_with_named_upstreams("8.8.8.8"); + let before = dump(&backend).await; + let events = diff(&Configuration { services: Some(vec![updated_service]), ..empty_configuration() }, &before); + sync_ok(&backend, events).await; + + let raw = common::cache().raw_config(CACHE_KEY).unwrap(); + let upstreams = raw.upstreams.unwrap(); + assert_eq!(upstreams.len(), 3); + let nd1 = upstreams.iter().find(|u| u.name == "nd-upstream1").expect("nd-upstream1"); + assert_eq!( + nd1.labels.as_ref().and_then(|l| l.get(ADC_UPSTREAM_SERVICE_ID_LABEL)), + Some(&generate_id("test")) + ); + assert_eq!(nd1.nodes.as_ref().unwrap()[0].host, "8.8.8.8"); + + let config = common::cache().config(CACHE_KEY).unwrap(); + let services = config.services.unwrap(); + let named = services[0].upstreams.as_ref().unwrap(); + assert_eq!(named.len(), 2); + let named_nd1 = named.iter().find(|u| u.name.as_deref() == Some("nd-upstream1")).expect("nd-upstream1"); + assert!(named_nd1.labels.as_ref().is_none_or(|l| !l.contains_key(ADC_UPSTREAM_SERVICE_ID_LABEL))); + assert_eq!(named_nd1.nodes.as_ref().unwrap()[0].host, "8.8.8.8"); +} diff --git a/rust/crates/adc-backend-apisix-standalone/tests/e2e_validate.rs b/rust/crates/adc-backend-apisix-standalone/tests/e2e_validate.rs new file mode 100644 index 00000000..c1f37d7f --- /dev/null +++ b/rust/crates/adc-backend-apisix-standalone/tests/e2e_validate.rs @@ -0,0 +1,127 @@ +//! Ported from `libs/backend-apisix-standalone/e2e/validate.e2e-spec.ts`. +//! Real network calls against a live standalone APISIX instance — see +//! `common`'s module doc for how to bring one up and run this file. +//! +//! Deliberately thin: `Backend::validate` is a straight delegation to +//! `adc_backend_apisix::Validator` against the first configured server (the +//! same `/apisix/admin/configs/validate` endpoint apisix's own `Backend` +//! uses) — every validation rule this could exercise is already covered by +//! `adc-backend-apisix`'s own `tests/e2e_validate.rs`. This file only checks +//! that standalone's wiring actually reaches it and reports what it says. +//! +//! Requires apisix >= 3.17.0 (the endpoint itself doesn't exist before +//! that — confirmed against a real instance by `adc-backend-apisix`'s own +//! e2e suite), not the 3.16.0 the TS suite's `validate.e2e-spec.ts` gates +//! on for this backend specifically. + +use adc_sdk::resources::{self as adc, Configuration}; +use adc_sdk::Backend as _; + +mod common; +use common::{apisix_version, backend, base_service, base_upstream, diff, empty_configuration}; + +macro_rules! skip_below_3_17_0 { + () => { + if apisix_version() < semver::Version::new(3, 17, 0) { + eprintln!("skipping: validate requires apisix >= 3.17.0"); + return; + } + }; +} + +#[tokio::test] +#[ignore] +async fn succeeds_with_an_empty_configuration() { + skip_below_3_17_0!(); + let backend = backend("validate-e2e"); + + 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() { + skip_below_3_17_0!(); + let backend = backend("validate-e2e"); + + let service = adc::Service { + name: "validate-test-svc".to_string(), + upstream: Some(adc::Upstream { + nodes: Some(vec![adc::UpstreamNode { host: "httpbin.org".to_string(), port: 80, weight: 100, priority: 0, metadata: None }]), + scheme: adc::UpstreamScheme::Http, + ..base_upstream() + }), + routes: Some(adc::ServiceRoutes::Http { + routes: vec![adc::Route { + id: None, + name: "validate-test-route".to_string(), + description: None, + labels: None, + hosts: None, + uris: vec!["/validate-test".to_string()], + priority: None, + timeout: None, + vars: None, + methods: Some(vec![adc::HttpMethod::Get]), + enable_websocket: None, + remote_addrs: None, + plugins: None, + filter_func: None, + }], + }), + ..base_service() + }; + let local = Configuration { services: Some(vec![service]), ..empty_configuration() }; + let events = diff(&local, &empty_configuration()); + + let result = backend.validate(&events).await.unwrap(); + assert!(result.success, "{:?}", result.errors); + assert!(result.errors.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn fails_with_an_invalid_plugin_configuration() { + skip_below_3_17_0!(); + let backend = backend("validate-e2e"); + + let mut plugins = adc::Plugins::new(); + // limit-count requires `count`/`time_window`; both are missing. + plugins.insert("limit-count".to_string(), serde_json::json!({})); + let service = adc::Service { + name: "validate-bad-plugin-svc".to_string(), + upstream: Some(adc::Upstream { + nodes: Some(vec![adc::UpstreamNode { host: "httpbin.org".to_string(), port: 80, weight: 100, priority: 0, metadata: None }]), + scheme: adc::UpstreamScheme::Http, + ..base_upstream() + }), + routes: Some(adc::ServiceRoutes::Http { + routes: vec![adc::Route { + id: None, + name: "validate-bad-plugin-route".to_string(), + description: None, + labels: None, + hosts: None, + uris: vec!["/bad-plugin".to_string()], + priority: None, + timeout: None, + vars: None, + methods: None, + enable_websocket: None, + remote_addrs: None, + plugins: Some(plugins), + filter_func: None, + }], + }), + ..base_service() + }; + let local = Configuration { services: Some(vec![service]), ..empty_configuration() }; + let events = diff(&local, &empty_configuration()); + + let result = backend.validate(&events).await.unwrap(); + assert!(!result.success); + assert!(!result.errors.is_empty()); + assert_eq!(result.errors[0].resource_type, "routes"); +} diff --git a/rust/crates/adc-backend-apisix/src/lib.rs b/rust/crates/adc-backend-apisix/src/lib.rs index dfd63dd7..f11bbca2 100644 --- a/rust/crates/adc-backend-apisix/src/lib.rs +++ b/rust/crates/adc-backend-apisix/src/lib.rs @@ -7,6 +7,7 @@ mod utils; mod validator; pub use backend::Backend; +pub use validator::Validator; #[cfg(feature = "test-utils")] #[doc(hidden)] diff --git a/rust/crates/adc-backend-apisix/src/operator.rs b/rust/crates/adc-backend-apisix/src/operator.rs index b7c1a10b..0284e923 100644 --- a/rust/crates/adc-backend-apisix/src/operator.rs +++ b/rust/crates/adc-backend-apisix/src/operator.rs @@ -72,7 +72,7 @@ impl Operator { 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 }), + Err((event, error)) => results.push(BackendSyncResult { success: false, event: Some(event), error: Some(error), server: None }), } } } @@ -122,11 +122,11 @@ impl Operator { async fn apply_inner(&self, event: Event) -> Result { if let Err(error) = self.check_version_support(&event) { log::warn!("skipping {:?} {:?} \"{}\": {error}", event.event_type(), event.resource_type, event.resource_name); - return Ok(BackendSyncResult { success: false, event, error: Some(error), server: None }); + return Ok(BackendSyncResult { success: false, event: Some(event), error: Some(error), server: None }); } match self.operate(&event).await { - Ok(()) => Ok(BackendSyncResult { success: true, event, error: None, server: None }), + Ok(()) => Ok(BackendSyncResult { success: true, event: Some(event), error: None, server: None }), Err(error) => Err((event, error)), } } diff --git a/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs b/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs index 22ae5c98..ac35c5d9 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_apisix.rs @@ -62,10 +62,11 @@ async fn sync_ok(events: Vec) { .await .unwrap(); for result in &results { + let event = result.event.as_ref().expect("apisix always reports one result per event"); assert!( result.success, "sync failed for {:?} {}: {:?}", - result.event.resource_type, result.event.resource_id, result.error + event.resource_type, event.resource_id, result.error ); } } @@ -113,8 +114,10 @@ impl Drop for Cleanup { for result in &results { if !result.success { eprintln!( - "cleanup failed for {:?} {}: {:?}", - result.event.resource_type, result.event.resource_id, result.error + "cleanup failed for {:?} {:?}: {:?}", + result.event.as_ref().map(|e| e.resource_type), + result.event.as_ref().map(|e| e.resource_id.as_str()), + result.error ); } } diff --git a/rust/crates/adc-backend-apisix/tests/e2e_operator.rs b/rust/crates/adc-backend-apisix/tests/e2e_operator.rs index 4327313a..a77118f7 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_operator.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_operator.rs @@ -74,7 +74,8 @@ fn plugins_diff() -> ValueDiff { async fn sync_ok(backend: &ApisixBackend, events: Vec) { let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); for result in &results { - assert!(result.success, "{:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + let event = result.event.as_ref().expect("apisix always reports one result per event"); + assert!(result.success, "{:?} {}: {:?}", event.resource_type, event.resource_id, result.error); } } diff --git a/rust/crates/adc-backend-apisix/tests/e2e_resource_service_upstream.rs b/rust/crates/adc-backend-apisix/tests/e2e_resource_service_upstream.rs index 22840a45..d25f9b2e 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_resource_service_upstream.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_resource_service_upstream.rs @@ -49,7 +49,8 @@ fn delete_child(rt: ResourceType, name: &str, parent_name: &str) -> Event { async fn sync_ok(backend: &ApisixBackend, events: Vec) { let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); for result in &results { - assert!(result.success, "{:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + let event = result.event.as_ref().expect("apisix always reports one result per event"); + assert!(result.success, "{:?} {}: {:?}", event.resource_type, event.resource_id, result.error); } } diff --git a/rust/crates/adc-backend-apisix/tests/e2e_sync_and_dump.rs b/rust/crates/adc-backend-apisix/tests/e2e_sync_and_dump.rs index ca003460..1e8337ea 100644 --- a/rust/crates/adc-backend-apisix/tests/e2e_sync_and_dump.rs +++ b/rust/crates/adc-backend-apisix/tests/e2e_sync_and_dump.rs @@ -72,7 +72,8 @@ fn delete_child(rt: ResourceType, name: &str, parent_name: &str) -> Event { async fn sync_ok(backend: &ApisixBackend, events: Vec) { let results = backend.sync(events, BackendSyncOptions::default()).await.unwrap(); for result in &results { - assert!(result.success, "{:?} {}: {:?}", result.event.resource_type, result.event.resource_id, result.error); + let event = result.event.as_ref().expect("apisix always reports one result per event"); + assert!(result.success, "{:?} {}: {:?}", event.resource_type, event.resource_id, result.error); } } diff --git a/rust/crates/adc-cli/src/main.rs b/rust/crates/adc-cli/src/main.rs index a69c3a4f..33a309c0 100644 --- a/rust/crates/adc-cli/src/main.rs +++ b/rust/crates/adc-cli/src/main.rs @@ -164,12 +164,21 @@ async fn cmd_sync(args: SyncArgs) -> Result<(), CliError> { for result in &results { if !result.success { failed += 1; - println!( - "[FAILED] {} {}: \"{}\"", - event_verb(&result.event), - result.event.resource_type.as_str(), - result.event.resource_name - ); + match &result.event { + Some(event) => println!( + "[FAILED] {} {}: \"{}\"", + event_verb(event), + event.resource_type.as_str(), + event.resource_name + ), + // A backend whose sync granularity is per-server rather + // than per-event (apisix-standalone) has no single event to + // blame for the failure — report which server instead. + None => println!( + "[FAILED] sync{}", + result.server.as_deref().map(|server| format!(" to {server}")).unwrap_or_default() + ), + } if let Some(err) = &result.error { println!(" {err}"); } diff --git a/rust/crates/adc-sdk/src/backend/mod.rs b/rust/crates/adc-sdk/src/backend/mod.rs index 11aaa021..771cad29 100644 --- a/rust/crates/adc-sdk/src/backend/mod.rs +++ b/rust/crates/adc-sdk/src/backend/mod.rs @@ -41,7 +41,11 @@ pub struct BackendSyncOptions { #[derive(Debug)] pub struct BackendSyncResult { pub success: bool, - pub event: Event, + /// `None` for a backend whose sync granularity is coarser than "one + /// event, one result" — apisix-standalone applies a whole batch of + /// events as a single atomic document write per server, so one result + /// there describes that write, not any single event within it. + pub event: Option, pub error: Option, pub server: Option, } @@ -98,7 +102,7 @@ pub trait Backend: Send + Sync { async fn dump(&self) -> Result; - /// Applies `events`. Per-event failures are captured as individual + /// Applies `events`. Failures are captured as individual /// `BackendSyncResult`s with `success: false` rather than failing the /// whole call — *unless* `opts.exit_on_failure` is set (the default): /// then the first failure aborts the whole call and is returned as @@ -106,7 +110,12 @@ pub trait Backend: Send + Sync { /// implementation's `Observable` erroring out (via `throwError`) instead /// of completing with a partial list. Concurrency (per /// `opts.concurrent`) is an implementation detail of each backend, not - /// something the trait signature encodes. + /// something the trait signature encodes — as is the granularity of + /// each result: most backends apply one event per request and report + /// one result per event, but a backend whose admin API only accepts a + /// whole document at once (apisix-standalone) reports one result per + /// *server* instead, with `BackendSyncResult::event` left `None` since + /// no single event owns that write. async fn sync(&self, events: Vec, opts: BackendSyncOptions) -> Result, BackendError>; /// Not every backend can pre-validate events against the remote server