From c07ef4568c65a2de1f17fc5bf04eda616331454f Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Wed, 4 Nov 2026 19:14:50 +0900 Subject: [PATCH] refactor(redis-types): resolve Redis settings into one typed Resolution resolve now returns litellm_redis_types::Resolution, the value both client projections build from. Target replaces Endpoint, a url carries the TLS parsed from it only when it is rediss, Sentinel carries its own monitor_auth, and Auth is a tagged union whose Provider variant holds a ProviderHandle the host passes with ConfiguredRedis::with_credential_provider. A credential_provider key with no handle is an error instead of a connect-time failure. Settings the resolver does not model are no longer dropped. Plain ones land in passthrough and object-valued ones in host_objects, so a redis-py projection can reattach them. TlsOptions no longer derives Default, which turned certificate verification off, and TlsOptions::verified() is the one place defaults come from --- litellm-rust/Cargo.lock | 2 + litellm-rust/crates/config/AGENTS.md | 4 +- litellm-rust/crates/config/src/lib.rs | 4 +- litellm-rust/crates/config/src/redis.rs | 57 +++-- litellm-rust/crates/config/src/redis/auth.rs | 21 +- .../crates/config/src/redis/endpoint.rs | 58 +++-- litellm-rust/crates/config/src/redis/error.rs | 4 + litellm-rust/crates/config/src/redis/keys.rs | 22 ++ .../crates/config/src/redis/options.rs | 55 +++- litellm-rust/crates/config/src/redis/tls.rs | 11 +- litellm-rust/crates/config/tests/redis.rs | 240 ++++++++++++------ litellm-rust/crates/gateway/src/redis.rs | 30 +-- .../crates/redis-client/src/client.rs | 2 +- .../crates/redis-client/src/config.rs | 22 +- .../crates/redis-client/src/connection.rs | 49 ++-- litellm-rust/crates/redis-client/src/lib.rs | 2 +- .../crates/redis-client/tests/client.rs | 24 +- .../crates/redis-client/tests/config.rs | 14 +- .../crates/redis-client/tests/connection.rs | 12 +- .../crates/redis-client/tests/support/mod.rs | 20 +- litellm-rust/crates/redis-connect/AGENTS.md | 4 +- .../crates/redis-connect/src/connect.rs | 20 +- .../crates/redis-connect/tests/connect.rs | 11 +- litellm-rust/crates/redis-types/AGENTS.md | 4 +- litellm-rust/crates/redis-types/Cargo.toml | 2 + litellm-rust/crates/redis-types/src/lib.rs | 114 +++++++-- 26 files changed, 525 insertions(+), 283 deletions(-) diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 90c52d20766..8ce2679239e 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -4416,6 +4416,8 @@ name = "litellm-redis-types" version = "0.1.0" dependencies = [ "litellm-secret-value", + "serde_json", + "veil", ] [[package]] diff --git a/litellm-rust/crates/config/AGENTS.md b/litellm-rust/crates/config/AGENTS.md index 51a6e095eec..484871c162e 100644 --- a/litellm-rust/crates/config/AGENTS.md +++ b/litellm-rust/crates/config/AGENTS.md @@ -1,7 +1,7 @@ ## Redis settings -- The boundary is surface in, `ResolvedRedis` out. A surface turns what it was given into a `ConfiguredRedis`, the redis-py kwargs `_get_redis_client_logic` would receive: `RedisSettings::coordination` for `general_settings.coordination_redis`, `RedisSettings::cache_params` for `litellm_settings.cache_params`, and `ConfiguredRedis::redis_cache_kwargs` for a `RedisCache.redis_kwargs` dict. `ConfiguredRedis::resolve` is then the same for every host and returns a `Resolution`: the `litellm_redis_types::ResolvedRedis` plus every accepted-but-ignored setting by source, which the host logs. A new surface is a new constructor, never a flag on `resolve` -- Resolution runs in three steps, one file each. `layers.rs` puts the configured kwargs over their `REDIS_*` values. `options.rs` reads the result into the typed `Options`, rejects unsupported settings, collects ignored ones and fills Python's `get_secret` fallbacks. `endpoint.rs` and `auth.rs` pick the target and the credentials from `Options` alone +- The boundary is surface in, `litellm_redis_types::Resolution` out. A surface turns what it was given into a `ConfiguredRedis`, the redis-py kwargs `_get_redis_client_logic` would receive: `RedisSettings::coordination` for `general_settings.coordination_redis`, `RedisSettings::cache_params` for `litellm_settings.cache_params`, and `ConfiguredRedis::redis_cache_kwargs` for a `RedisCache.redis_kwargs` dict. `ConfiguredRedis::resolve` is then the same for every host and returns the `Resolution`: the target, the auth and the timeouts Rust models, every other plain setting verbatim in `passthrough`, the names of object-valued settings in `host_objects`, and every accepted-but-ignored setting by source, which the host logs. A host passes its `credential_provider` object as a `ProviderHandle` through `ConfiguredRedis::with_credential_provider`, and a `credential_provider` key without one is `RedisError::ProviderWithoutHandle`. A new surface is a new constructor, never a flag on `resolve` +- Resolution runs in three steps, one file each. `layers.rs` puts the configured kwargs over their `REDIS_*` values. `options.rs` reads the result into the typed `Options`, rejects unsupported settings, splits the unmodeled ones into passthrough and host objects, collects ignored ones and fills Python's `get_secret` fallbacks. `endpoint.rs` and `auth.rs` pick the target and the credentials from `Options` alone - `resolve` follows `litellm/_redis.py` `_get_redis_client_logic` plus `_credential_provider_auth_kwargs` and the per-client allowlists of `get_redis_async_client` (redis-py 5.3.1). Python and Rust pods share one Redis during the switch, so a change there is a change to a wire contract - That contract is `resolves_like_python_redis` in `tests/redis.rs` and its mirror `test_redis_settings_resolve_like_the_rust_resolver_expects` in `tests/unit/test_redis.py`. Both hold the same named cases with the expected view written next to the input, the Python side reading the effective redis-py 5.3.1 connection objects without connecting. A new resolution rule is a new case in both files, never a generated fixture. A case where the two sides expect different outcomes is a divergence and is listed below - `resolve` takes a `SecretSnapshot` and an environment `Lookup`. The snapshot holds every name `ConfiguredRedis::secret_names` lists, read the way Python's `get_secret` reads it (secret manager, then environment). The host fills it once before resolving, python-bridge through `get_secret` and rust-proxy from its environment, so the resolver never calls back into the host. The environment lookup is only for what Python reads straight from `os.environ` (`REDIS_USERNAME` for Azure). Reading a name the snapshot does not list fails a debug assertion diff --git a/litellm-rust/crates/config/src/lib.rs b/litellm-rust/crates/config/src/lib.rs index 1e253a11076..b29741bb252 100644 --- a/litellm-rust/crates/config/src/lib.rs +++ b/litellm-rust/crates/config/src/lib.rs @@ -15,8 +15,8 @@ pub use litellm_router_types::{LitellmParams, Spelled}; pub use mcp::{McpAuth, McpServer, McpTransport}; pub use model::Model; pub use redis::{ - CacheSettings, ConfiguredRedis, Expected, RedisError, RedisSettings, Resolution, - SecretSnapshot, environment_has_target, + CacheSettings, ConfiguredRedis, Expected, RedisError, RedisSettings, SecretSnapshot, + environment_has_target, }; pub use settings::{ ClickHouseStoreSettings, GeneralSettings, LiteLlmSettings, RouterSettings, TracingSettings, diff --git a/litellm-rust/crates/config/src/redis.rs b/litellm-rust/crates/config/src/redis.rs index c07217595c4..4ea89e028e0 100644 --- a/litellm-rust/crates/config/src/redis.rs +++ b/litellm-rust/crates/config/src/redis.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use litellm_core_utils::settings::Lookup; -use litellm_redis_types::{Endpoint, RedisAuth, ResolvedRedis}; +use litellm_redis_types::{Auth, ProviderHandle, Resolution, StaticAuth, Target}; use serde::{Deserialize, Deserializer, de::Error as _}; use veil::Redact; @@ -71,6 +71,7 @@ pub struct ConfiguredRedis { #[redact(with = "[REDACTED]")] fields: BTreeMap, nulls: Nulls, + credential_provider: Option, } /// Whether a null setting reaches redis-py as an explicit `None`, which outranks its `REDIS_*` value. @@ -84,14 +85,6 @@ enum Nulls { Kept, } -/// A resolved connection, plus the settings it was given that Rust accepts without honoring. -#[derive(Debug)] -pub struct Resolution { - pub redis: ResolvedRedis, - /// Where each ignored setting came from: its key, or the environment variable that set it. - pub ignored: Vec, -} - impl RedisSettings { /// `general_settings.coordination_redis`, which the proxy validates as a pydantic model first. /// An `os.environ/` port is only known after resolution, where it is read like any other port. @@ -108,6 +101,7 @@ impl RedisSettings { Ok(ConfiguredRedis { fields: self.fields.clone(), nulls: Nulls::Dropped, + credential_provider: None, }) } @@ -130,6 +124,7 @@ impl RedisSettings { ConfiguredRedis { fields, nulls: Nulls::KeptUnlessNamedByCache, + credential_provider: None, } } } @@ -141,6 +136,15 @@ impl ConfiguredRedis { Self { fields, nulls: Nulls::Kept, + credential_provider: None, + } + } + + /// The handle the host holds for the `credential_provider` object in these settings. + pub fn with_credential_provider(self, handle: ProviderHandle) -> Self { + Self { + credential_provider: Some(handle), + ..self } } @@ -149,6 +153,7 @@ impl ConfiguredRedis { Self { fields: BTreeMap::new(), nulls: Nulls::Dropped, + credential_provider: None, } } @@ -158,25 +163,27 @@ impl ConfiguredRedis { environment: &dyn Lookup, ) -> Result { let kwargs = layers::merged(self, secrets); - let (options, ignored) = Options::read(kwargs, secrets)?; - let auth = auth::redis_auth(&options, environment)?; - let endpoint = endpoint::endpoint(&options, &auth)?; - let auth = match (&endpoint, auth) { - (Endpoint::Url { .. }, RedisAuth::Static { username, .. }) => RedisAuth::Static { - username, - password: None, - }, + let (options, unmodeled) = Options::read(kwargs, secrets)?; + let auth = auth::redis_auth(&options, self.credential_provider, environment)?; + let target = endpoint::target(&options, &auth)?; + let auth = match (&target, auth) { + (Target::Url(_), Auth::Static(StaticAuth { username, .. })) => { + Auth::Static(StaticAuth { + username, + password: None, + }) + } (_, auth) => auth, }; Ok(Resolution { - redis: ResolvedRedis { - endpoint, - auth, - namespace: options::text(options.namespace.as_ref()), - timeout: options::seconds(options.socket_timeout.as_ref())?, - connect_timeout: options::seconds(options.socket_connect_timeout.as_ref())?, - }, - ignored, + target, + auth, + namespace: options::text(options.namespace.as_ref()), + timeout: options::seconds(options.socket_timeout.as_ref())?, + connect_timeout: options::seconds(options.socket_connect_timeout.as_ref())?, + passthrough: unmodeled.passthrough, + host_objects: unmodeled.host_objects, + ignored: unmodeled.ignored, }) } } diff --git a/litellm-rust/crates/config/src/redis/auth.rs b/litellm-rust/crates/config/src/redis/auth.rs index cb2ea0db53a..5d86bdea932 100644 --- a/litellm-rust/crates/config/src/redis/auth.rs +++ b/litellm-rust/crates/config/src/redis/auth.rs @@ -1,5 +1,5 @@ use litellm_core_utils::settings::Lookup; -use litellm_redis_types::{AwsIamAuth, AzureAdAuth, RedisAuth}; +use litellm_redis_types::{Auth, AwsIamAuth, AzureAdAuth, ProviderHandle, StaticAuth}; use super::{ RedisError, @@ -8,10 +8,13 @@ use super::{ pub(super) fn redis_auth( options: &Options, + provider: Option, environment: &dyn Lookup, -) -> Result { - if options.credential_provider.is_some() { - return Ok(RedisAuth::Host); +) -> Result { + match (provider, &options.credential_provider) { + (Some(handle), _) => return Ok(Auth::Provider(handle)), + (None, Some(_)) => return Err(RedisError::ProviderWithoutHandle), + (None, None) => {} } let gcp = text(options.gcp.service_account.as_ref()); let azure = text(options.azure.ad_token.as_ref()) @@ -29,7 +32,7 @@ pub(super) fn redis_auth( "Both GCP IAM (gcp_service_account) and AWS ElastiCache IAM (aws_iam_auth) are configured for Redis. Using GCP IAM. Remove one to avoid misconfiguration." ); } - Ok(RedisAuth::GcpIam { service_account }) + Ok(Auth::GcpIam { service_account }) } (None, true, aws) => { if aws { @@ -37,7 +40,7 @@ pub(super) fn redis_auth( "Both Azure AD (azure_redis_ad_token) and AWS ElastiCache IAM (aws_iam_auth) are configured for Redis. Using Azure AD. Remove one to avoid misconfiguration." ); } - Ok(RedisAuth::AzureAd(AzureAdAuth { + Ok(Auth::AzureAd(AzureAdAuth { client_id: text(options.azure.client_id.as_ref()), tenant_id: text(options.azure.tenant_id.as_ref()), client_secret: secret(options.azure.client_secret.as_ref()), @@ -46,11 +49,11 @@ pub(super) fn redis_auth( .filter(|username| !username.is_empty()), })) } - (None, false, true) => aws_iam(&options.aws, uses_tls(options)?).map(RedisAuth::AwsIam), - (None, false, false) => Ok(RedisAuth::Static { + (None, false, true) => aws_iam(&options.aws, uses_tls(options)?).map(Auth::AwsIam), + (None, false, false) => Ok(Auth::Static(StaticAuth { username: secret(options.username.as_ref()), password: secret(options.password.as_ref()), - }), + })), } } diff --git a/litellm-rust/crates/config/src/redis/endpoint.rs b/litellm-rust/crates/config/src/redis/endpoint.rs index 5e4f8b033a5..ce7c61d09f2 100644 --- a/litellm-rust/crates/config/src/redis/endpoint.rs +++ b/litellm-rust/crates/config/src/redis/endpoint.rs @@ -1,5 +1,5 @@ use litellm_redis_types::{ - ClusterEndpoint, ClusterNode, Endpoint, HostEndpoint, RedisAuth, SentinelEndpoint, TlsOptions, + Auth, Cluster, Node, RedisUrl, Sentinel, Standalone, StaticAuth, Target, TlsOptions, }; use litellm_secret_value::SecretValue; @@ -12,27 +12,27 @@ use super::{ use crate::Value; /// The target, first match wins like `get_redis_client`: cluster, url, Sentinel, then host. -pub(super) fn endpoint(options: &Options, auth: &RedisAuth) -> Result { +pub(super) fn target(options: &Options, auth: &Auth) -> Result { if let Some(nodes) = cluster_nodes(options.startup_nodes.as_ref())? { reject_cluster_db(options.db.as_ref())?; - return Ok(Endpoint::Cluster(ClusterEndpoint { + return Ok(Target::Cluster(Cluster { nodes, tls: tls_for(options, auth)?, })); } if let Some(url) = text(options.url.as_ref()) { - return Ok(Endpoint::Url { + return Ok(Target::Url(RedisUrl { tls: tls::url_tls(&url)?, url: SecretValue::new(match auth { - RedisAuth::Static { .. } => url, + Auth::Static(_) => url, _ => without_userinfo(&url), }), - }); + })); } if let Some(sentinels) = options.sentinel.nodes.as_ref() { - return sentinel(options, auth, sentinels).map(Endpoint::Sentinel); + return sentinel(options, auth, sentinels).map(Target::Sentinel); } - Ok(Endpoint::Host(HostEndpoint { + Ok(Target::Standalone(Standalone { host: text(options.host.as_ref()).ok_or(RedisError::NoTarget)?, port: integer(options.port.as_ref())?.unwrap_or(6379), tls: tls_for(options, auth)?, @@ -40,12 +40,12 @@ pub(super) fn endpoint(options: &Options, auth: &RedisAuth) -> Result Result, RedisError> { +fn tls_for(options: &Options, auth: &Auth) -> Result, RedisError> { if !flag(options.ssl.as_ref())? { return Ok(None); } let gcp_ca_certs = match auth { - RedisAuth::GcpIam { .. } => options.gcp.ssl_ca_certs.as_ref(), + Auth::GcpIam { .. } => options.gcp.ssl_ca_certs.as_ref(), _ => None, }; tls::tls_options(&options.tls, gcp_ca_certs).map(Some) @@ -55,12 +55,8 @@ fn db(options: &Options) -> Result { integer(options.db.as_ref()).map(|db| db.unwrap_or(0)) } -fn sentinel( - options: &Options, - auth: &RedisAuth, - sentinels: &Entry, -) -> Result { - let nodes = sentinel_nodes(&sentinels.value) +fn sentinel(options: &Options, auth: &Auth, sentinels: &Entry) -> Result { + let monitors = sentinel_nodes(&sentinels.value) .ok_or_else(|| invalid(&sentinels.source, Expected::SentinelNodes))?; let service_name = text(options.sentinel.service_name.as_ref()).ok_or(RedisError::NoSentinelService)?; @@ -71,16 +67,30 @@ fn sentinel( setting: entry.source.clone(), }); } - Ok(SentinelEndpoint { - sentinels: nodes, + Ok(Sentinel { + monitors, service_name, - sentinel_password: secret(options.sentinel.password.as_ref()), + monitor_auth: monitor_auth(options, auth), tls: tls_for(options, auth)?, db: db(options)?, }) } -fn cluster_nodes(entry: Option<&Entry>) -> Result>, RedisError> { +// _sentinel_auth_kwargs gives the monitors the data node's connection kwargs with sentinel_password as the +// password, so a static username reaches them too +fn monitor_auth(options: &Options, auth: &Auth) -> Option { + let password = secret(options.sentinel.password.as_ref())?; + let username = match auth { + Auth::Static(StaticAuth { username, .. }) => username.clone(), + _ => None, + }; + Some(StaticAuth { + username, + password: Some(password), + }) +} + +fn cluster_nodes(entry: Option<&Entry>) -> Result>, RedisError> { let Some(entry) = entry else { return Ok(None); }; @@ -94,7 +104,7 @@ fn cluster_nodes(entry: Option<&Entry>) -> Result>, Redi Ok((!nodes.is_empty()).then_some(nodes)) } -fn sentinel_nodes(value: &Value) -> Option> { +fn sentinel_nodes(value: &Value) -> Option> { let nodes = nodes(value, |pair| match pair { Value::Sequence(pair) if pair.len() == 2 => { node_address(as_text(&pair[0]), as_text(&pair[1])) @@ -104,7 +114,7 @@ fn sentinel_nodes(value: &Value) -> Option> { (!nodes.is_empty()).then_some(nodes) } -fn nodes(value: &Value, parse: impl Fn(&Value) -> Option) -> Option> { +fn nodes(value: &Value, parse: impl Fn(&Value) -> Option) -> Option> { match value { Value::String(json) => serde_json::from_str::>(json) .ok()? @@ -116,8 +126,8 @@ fn nodes(value: &Value, parse: impl Fn(&Value) -> Option) -> Option } } -fn node_address(host: Option, port: Option) -> Option { - Some(ClusterNode { +fn node_address(host: Option, port: Option) -> Option { + Some(Node { host: host?, port: port?.parse().ok()?, }) diff --git a/litellm-rust/crates/config/src/redis/error.rs b/litellm-rust/crates/config/src/redis/error.rs index b770f1ed33b..9792723ac3e 100644 --- a/litellm-rust/crates/config/src/redis/error.rs +++ b/litellm-rust/crates/config/src/redis/error.rs @@ -12,6 +12,10 @@ pub enum RedisError { SentinelWithHost { setting: String }, #[error("{setting} must be 0 in cluster mode")] DbInCluster { setting: String }, + #[error("credential_provider is a host object, and the host passed no handle for it")] + ProviderWithoutHandle, + #[error("Redis setting {setting} is not plain data")] + NotPlainData { setting: String }, #[error("AWS ElastiCache IAM Redis authentication requires TLS")] AwsIamWithoutTls, #[error("AWS ElastiCache IAM Redis authentication requires: {}", missing.join(", "))] diff --git a/litellm-rust/crates/config/src/redis/keys.rs b/litellm-rust/crates/config/src/redis/keys.rs index cdb19be12dd..daed1993b8a 100644 --- a/litellm-rust/crates/config/src/redis/keys.rs +++ b/litellm-rust/crates/config/src/redis/keys.rs @@ -163,6 +163,28 @@ pub(super) const SETTINGS: &[Setting] = &[ Setting::new("username", Handling::Read, true, &[]), ]; +// redis-py parameters whose value is a Python object rather than plain data. credential_provider is modeled as +// Auth::Provider instead +const HOST_OBJECTS: &[&str] = &[ + "address_remap", + "cache", + "cache_config", + "connection_class", + "connection_pool", + "driver_info", + "event_dispatcher", + "maint_notifications_config", + "parser_class", + "redis_connect_func", + "retry", + "retry_on_error", + "ssl_ocsp_context", +]; + +pub(super) fn is_host_object(kwarg: &str) -> bool { + HOST_OBJECTS.contains(&kwarg) +} + pub(super) fn setting(kwarg: &str) -> Option<&'static Setting> { SETTINGS.iter().find(|setting| setting.kwarg == kwarg) } diff --git a/litellm-rust/crates/config/src/redis/options.rs b/litellm-rust/crates/config/src/redis/options.rs index 5de83584d8e..1ff6f551547 100644 --- a/litellm-rust/crates/config/src/redis/options.rs +++ b/litellm-rust/crates/config/src/redis/options.rs @@ -1,7 +1,11 @@ -use std::{collections::BTreeMap, time::Duration}; +use std::{ + collections::{BTreeMap, BTreeSet}, + time::Duration, +}; use litellm_core_utils::settings::Lookup; use litellm_python_compat::{BigInt, number}; +use litellm_redis_types::Passthrough; use litellm_secret_value::SecretValue; use super::{ @@ -70,12 +74,49 @@ pub(super) struct TlsInput { pub(super) check_hostname: Option, } +/// The settings the resolver does not model, split the way a host reattaches them. +pub(super) struct Unmodeled { + pub(super) passthrough: Passthrough, + pub(super) host_objects: BTreeSet, + pub(super) ignored: Vec, +} + +impl Unmodeled { + fn read(kwargs: &Kwargs) -> Result { + let unmodeled = || { + kwargs + .iter() + .filter(|(key, _)| handling(key) != Some(Handling::Read)) + }; + let (objects, plain): (Vec<_>, Vec<_>) = + unmodeled().partition(|(key, _)| keys::is_host_object(key)); + let passthrough = plain + .into_iter() + .map(|(key, entry)| { + serde_json::to_value(&entry.value) + .map(|value| (key.clone(), value)) + .map_err(|_| RedisError::NotPlainData { + setting: entry.source.clone(), + }) + }) + .collect::>()?; + Ok(Self { + passthrough: Passthrough::new(passthrough), + host_objects: objects.into_iter().map(|(key, _)| key.clone()).collect(), + ignored: unmodeled() + .filter(|(key, _)| handling(key) == Some(Handling::Warn)) + .map(|(_, entry)| entry.source.clone()) + .collect(), + }) + } +} + impl Options { - /// The typed settings and where each accepted-but-ignored one came from. + /// The typed settings and the ones the resolver leaves to the host. pub(super) fn read( kwargs: Kwargs, lookup: &dyn Lookup, - ) -> Result<(Self, Vec), RedisError> { + ) -> Result<(Self, Unmodeled), RedisError> { if let Some((_, entry)) = kwargs .iter() .find(|(key, _)| handling(key) == Some(Handling::Reject)) @@ -84,11 +125,7 @@ impl Options { setting: entry.source.clone(), }); } - let ignored = kwargs - .iter() - .filter(|(key, _)| handling(key) == Some(Handling::Warn)) - .map(|(_, entry)| entry.source.clone()) - .collect(); + let unmodeled = Unmodeled::read(&kwargs)?; let get = |key: &str| { kwargs .get(key) @@ -139,7 +176,7 @@ impl Options { socket_timeout: get("socket_timeout"), socket_connect_timeout: get("socket_connect_timeout"), }; - Ok((options, ignored)) + Ok((options, unmodeled)) } } diff --git a/litellm-rust/crates/config/src/redis/tls.rs b/litellm-rust/crates/config/src/redis/tls.rs index 1157e01832e..f959b354242 100644 --- a/litellm-rust/crates/config/src/redis/tls.rs +++ b/litellm-rust/crates/config/src/redis/tls.rs @@ -40,9 +40,9 @@ fn verify_certificate(cert_reqs: Option<&Entry>) -> Result { .map(|verify| verify.unwrap_or(true)) } -pub(super) fn url_tls(url: &str) -> Result { +pub(super) fn url_tls(url: &str) -> Result, RedisError> { let Ok(parsed) = url::Url::parse(url) else { - return Ok(TlsOptions::verified()); + return Ok(None); }; let tls_scheme = parsed.scheme().eq_ignore_ascii_case("rediss"); let query: Vec<(String, Entry)> = parsed @@ -65,6 +65,9 @@ pub(super) fn url_tls(url: &str) -> Result { setting: entry.source.clone(), }); } + if !tls_scheme { + return Ok(None); + } let get = |key: &str| { query .iter() @@ -72,14 +75,14 @@ pub(super) fn url_tls(url: &str) -> Result { .find(|(name, _)| name == key) .map(|(_, entry)| entry.clone()) }; - Ok(TlsOptions { + Ok(Some(TlsOptions { ca_certs: text(get("ssl_ca_certs").as_ref()).map(Into::into), certfile: text(get("ssl_certfile").as_ref()).map(Into::into), keyfile: text(get("ssl_keyfile").as_ref()).map(Into::into), verify_certificate: verify_certificate(get("ssl_cert_reqs").as_ref())?, check_hostname: text(get("ssl_check_hostname").as_ref()) .is_some_and(|text| url_bool(&text)), - }) + })) } // redis-py's to_bool: only 0, F, FALSE, N and NO (any case) are false diff --git a/litellm-rust/crates/config/tests/redis.rs b/litellm-rust/crates/config/tests/redis.rs index 93db5b4de70..25908934686 100644 --- a/litellm-rust/crates/config/tests/redis.rs +++ b/litellm-rust/crates/config/tests/redis.rs @@ -1,10 +1,10 @@ use std::collections::{BTreeMap, HashMap}; use litellm_config::{ - CacheSettings, Config, ConfiguredRedis, Expected, RedisError, Resolution, SecretSnapshot, Value, + CacheSettings, Config, ConfiguredRedis, Expected, RedisError, SecretSnapshot, Value, }; use litellm_redis_types::{ - AwsIamAuth, AzureAdAuth, ClusterNode, Endpoint, RedisAuth, ResolvedRedis, TlsOptions, + Auth, AwsIamAuth, AzureAdAuth, Node, ProviderHandle, Resolution, StaticAuth, Target, TlsOptions, }; use rstest::rstest; use serde_json::json; @@ -26,11 +26,22 @@ fn redis_cache_kwargs(kwargs: serde_json::Value) -> ConfiguredRedis { ) } +const PROVIDER: ProviderHandle = ProviderHandle::new(7); + +// python-bridge hands a credential_provider object over as a handle, so the contract cases do the same fn resolve_kwargs( kwargs: serde_json::Value, environment: &HashMap, -) -> Result { - resolution(&redis_cache_kwargs(kwargs), environment).map(|resolution| resolution.redis) +) -> Result { + let has_provider = kwargs + .get("credential_provider") + .is_some_and(|provider| !provider.is_null()); + let configured = redis_cache_kwargs(kwargs); + let configured = match has_provider { + true => configured.with_credential_provider(PROVIDER), + false => configured, + }; + resolution(&configured, environment) } fn coordination_resolution( @@ -50,15 +61,15 @@ fn coordination_resolution( resolution(&configured, &environment) } -fn resolve(yaml: &str, environment: &[(&str, &str)]) -> Result { - coordination_resolution(yaml, environment).map(|resolution| resolution.redis) +fn resolve(yaml: &str, environment: &[(&str, &str)]) -> Result { + coordination_resolution(yaml, environment) } -fn resolve_cache(yaml: &str) -> Result { +fn resolve_cache(yaml: &str) -> Result { resolve_cache_in(yaml, &[]) } -fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result { +fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result { let lookup = |name: &str| { environment .iter() @@ -70,18 +81,16 @@ fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result settings.cache_params(), CacheSettings::Other(_) => panic!("expected a Redis cache"), }; - configured - .resolve( - &SecretSnapshot::read(configured.secret_names(), &lookup), - &lookup, - ) - .map(|resolution| resolution.redis) + configured.resolve( + &SecretSnapshot::read(configured.secret_names(), &lookup), + &lookup, + ) } -fn cluster_ports(resolved: &ResolvedRedis) -> Vec { - match &resolved.endpoint { - Endpoint::Cluster(cluster) => cluster.nodes.iter().map(|node| node.port).collect(), - endpoint => panic!("expected a cluster, got {endpoint:?}"), +fn cluster_ports(resolved: &Resolution) -> Vec { + match &resolved.target { + Target::Cluster(cluster) => cluster.nodes.iter().map(|node| node.port).collect(), + target => panic!("expected a cluster, got {target:?}"), } } @@ -107,10 +116,10 @@ fn resolves_redis_through_the_parsed_config( #[case] expected: &str, ) { let resolved = resolve(yaml, environment).unwrap(); - let description = match resolved.endpoint { - Endpoint::Url { url, .. } => format!("url:{}", url.expose()), - Endpoint::Sentinel(sentinel) => format!("sentinel:{}", sentinel.service_name), - Endpoint::Host(host) => format!( + let description = match resolved.target { + Target::Url(url) => format!("url:{}", url.url.expose()), + Target::Sentinel(sentinel) => format!("sentinel:{}", sentinel.service_name), + Target::Standalone(host) => format!( "host:{}:{}:{}:{}:{}:{}", host.host, host.port, @@ -122,7 +131,7 @@ fn resolves_redis_through_the_parsed_config( .map(|timeout| timeout.as_millis().to_string()) .unwrap_or_else(|| "none".to_owned()) ), - Endpoint::Cluster(cluster) => format!( + Target::Cluster(cluster) => format!( "cluster:{}", cluster .nodes @@ -162,11 +171,11 @@ fn rejected_settings_name_their_source( assert!(matches!(error, RedisError::Unsupported { setting } if setting == expected)); } -fn static_auth(username: Option<&str>, password: Option<&str>) -> RedisAuth { - RedisAuth::Static { +fn static_auth(username: Option<&str>, password: Option<&str>) -> Auth { + Auth::Static(StaticAuth { username: username.map(litellm_secret_value::SecretValue::new), password: password.map(litellm_secret_value::SecretValue::new), - } + }) } #[rstest] @@ -181,11 +190,11 @@ fn an_explicit_host_keeps_environment_credentials() { .unwrap(); assert_eq!(resolved.auth, static_auth(None, Some("environment-secret"))); - assert!(matches!(resolved.endpoint, Endpoint::Host(host) if host.tls.is_some())); + assert!(matches!(resolved.target, Target::Standalone(host) if host.tls.is_some())); } -fn azure(username: Option<&str>) -> RedisAuth { - RedisAuth::AzureAd(AzureAdAuth { +fn azure(username: Option<&str>) -> Auth { + Auth::AzureAd(AzureAdAuth { client_id: Some("client".to_owned()), tenant_id: None, client_secret: None, @@ -193,8 +202,8 @@ fn azure(username: Option<&str>) -> RedisAuth { }) } -fn aws(serverless: bool) -> RedisAuth { - RedisAuth::AwsIam(AwsIamAuth { +fn aws(serverless: bool) -> Auth { + Auth::AwsIam(AwsIamAuth { user_name: "iam-user".to_owned(), cache_name: "cache-1".to_owned(), region: "us-east-1".to_owned(), @@ -208,15 +217,10 @@ fn aws(serverless: bool) -> RedisAuth { &[], static_auth(Some("user"), Some("pass")) )] -#[case::an_explicit_provider_beats_every_cloud_auth( - "general_settings:\n coordination_redis:\n host: redis\n password: pass\n credential_provider: host-object\n gcp_service_account: account\n aws_iam_auth: true", - &[("REDIS_AZURE_AD_TOKEN", "true")], - RedisAuth::Host -)] #[case::gcp_beats_azure_and_aws( "general_settings:\n coordination_redis:\n host: redis\n password: pass\n azure_redis_ad_token: \"True\"\n aws_iam_auth: true", &[("REDIS_GCP_SERVICE_ACCOUNT", "projects/-/serviceAccounts/sa@p.iam.gserviceaccount.com")], - RedisAuth::GcpIam { service_account: "projects/-/serviceAccounts/sa@p.iam.gserviceaccount.com".to_owned() } + Auth::GcpIam { service_account: "projects/-/serviceAccounts/sa@p.iam.gserviceaccount.com".to_owned() } )] #[case::azure_beats_aws_and_takes_the_environment_username( "general_settings:\n coordination_redis:\n host: redis\n username: configured-user\n azure_redis_ad_token: true\n azure_client_id: client\n aws_iam_auth: true", @@ -246,11 +250,45 @@ fn aws(serverless: bool) -> RedisAuth { fn auth_follows_python_precedence( #[case] yaml: &str, #[case] environment: &[(&str, &str)], - #[case] expected: RedisAuth, + #[case] expected: Auth, ) { assert_eq!(resolve(yaml, environment).unwrap().auth, expected); } +#[rstest] +fn a_provider_handle_beats_every_cloud_auth_and_static_credentials() { + let configured = redis_cache_kwargs(json!({ + "host": "redis", "password": "pass", "credential_provider": "host-object", + "gcp_service_account": "account", "aws_iam_auth": true, "azure_redis_ad_token": "true", + })) + .with_credential_provider(PROVIDER); + + let resolved = resolution(&configured, &HashMap::new()).unwrap(); + + assert_eq!(resolved.auth, Auth::Provider(PROVIDER)); +} + +#[rstest] +#[case::coordination( + "general_settings:\n coordination_redis:\n host: redis\n credential_provider: host-object", + &[] +)] +#[case::without_a_target( + "general_settings:\n coordination_redis:\n credential_provider: host-object", + &[("REDIS_HOST", "redis")] +)] +fn a_credential_provider_without_a_host_handle_fails( + #[case] yaml: &str, + #[case] environment: &[(&str, &str)], +) { + let error = resolve(yaml, environment).unwrap_err(); + + assert!( + matches!(error, RedisError::ProviderWithoutHandle), + "{error}" + ); +} + #[rstest] #[case::without_tls( "general_settings:\n coordination_redis:\n host: redis\n aws_iam_auth: true\n aws_iam_user_name: u\n aws_iam_cache_name: c\n aws_iam_region: r", @@ -301,7 +339,7 @@ fn tls_options_resolve_like_redis_py( let resolved = resolve(&yaml, environment).unwrap(); - assert!(matches!(resolved.endpoint, Endpoint::Host(host) if host.tls == expected)); + assert!(matches!(resolved.target, Target::Standalone(host) if host.tls == expected)); } #[rstest] @@ -336,18 +374,18 @@ fn sentinel_nodes_resolve_to_a_sentinel_endpoint( #[case] yaml: &str, #[case] environment: &[(&str, &str)], ) { - let Endpoint::Sentinel(sentinel) = resolve(yaml, environment).unwrap().endpoint else { + let Target::Sentinel(sentinel) = resolve(yaml, environment).unwrap().target else { panic!("expected a sentinel endpoint"); }; assert_eq!( - sentinel.sentinels, + sentinel.monitors, vec![ - ClusterNode { + Node { host: "sentinel-a".to_owned(), port: 26379 }, - ClusterNode { + Node { host: "sentinel-b".to_owned(), port: 26380 }, @@ -355,11 +393,11 @@ fn sentinel_nodes_resolve_to_a_sentinel_endpoint( ); assert_eq!(sentinel.service_name, "primary"); assert_eq!( - sentinel - .sentinel_password - .as_ref() - .map(|password| password.expose()), - Some("sentinel-secret") + sentinel.monitor_auth, + Some(StaticAuth { + username: None, + password: Some(litellm_secret_value::SecretValue::new("sentinel-secret")), + }) ); assert_eq!(sentinel.db, 2); } @@ -371,9 +409,9 @@ fn sentinel_nodes_resolve_to_a_sentinel_endpoint( )] fn sentinel_precedence(#[case] yaml: &str, #[case] expected: &str) { let resolved = resolve(yaml, &[]).unwrap(); - let description = match resolved.endpoint { - Endpoint::Url { url, .. } => format!("url:{}", url.expose()), - Endpoint::Sentinel(sentinel) => format!("sentinel:{}", sentinel.service_name), + let description = match resolved.target { + Target::Url(url) => format!("url:{}", url.url.expose()), + Target::Sentinel(sentinel) => format!("sentinel:{}", sentinel.service_name), other => panic!("unexpected {other:?}"), }; @@ -523,7 +561,7 @@ fn a_null_canonical_startup_nodes_setting_shadows_the_cache_alias() { ) .unwrap(); - assert!(matches!(resolved.endpoint, Endpoint::Host(_))); + assert!(matches!(resolved.target, Target::Standalone(_))); } #[rstest] @@ -533,7 +571,7 @@ fn a_missing_canonical_startup_nodes_reference_shadows_the_cache_alias() { ) .unwrap(); - assert!(matches!(resolved.endpoint, Endpoint::Host(_))); + assert!(matches!(resolved.target, Target::Standalone(_))); } #[rstest] @@ -570,17 +608,18 @@ fn rejects_bad_socket_timeout(#[case] timeout: &str) { #[rstest] fn debug_redacts_redis_secrets_and_url() { let config = Config::from_yaml( - "general_settings:\n coordination_redis:\n url: redis://user:password@redis:6379\n username: credential-user\n password: credential-password", + "general_settings:\n coordination_redis:\n url: redis://user:password@redis:6379\n username: credential-user\n password: credential-password\n sentinel_kwargs: {password: credential-passthrough}", ) .unwrap(); let settings = config.general_settings.coordination_redis.unwrap(); let configured = settings.coordination().unwrap(); - let resolved = resolution(&configured, &HashMap::new()).unwrap().redis; + let resolved = resolution(&configured, &HashMap::new()).unwrap(); let debug = format!("{settings:?} {configured:?} {resolved:?}"); assert!(!debug.contains("redis://user:password")); assert!(!debug.contains("credential-user")); assert!(!debug.contains("credential-password")); + assert!(!debug.contains("credential-passthrough")); assert!(debug.contains("redacted") || debug.contains("REDACTED")); } @@ -618,7 +657,7 @@ fn legacy_unknown_settings_do_not_change_resolution() { ) .unwrap(); - assert!(matches!(resolved.endpoint, Endpoint::Host(_))); + assert!(matches!(resolved.target, Target::Standalone(_))); } #[rstest] @@ -642,6 +681,48 @@ fn ignored_settings_are_named_by_their_source( assert_eq!(resolution.ignored, vec![source.to_owned()]); } +#[rstest] +#[case::configured( + json!({"host": "redis", "client_name": "worker", "read_from_replicas": true, "connection_class": "SSLConnection", "retry_on_error": null}), + &[], + json!({"client_name": "worker", "read_from_replicas": true}), + &["connection_class", "retry_on_error"], +)] +#[case::from_the_environment( + json!({"host": "redis"}), + &[("REDIS_CLIENT_NAME", "worker")], + json!({"client_name": "worker"}), + &[], +)] +fn settings_rust_does_not_model_reach_the_host_instead_of_being_dropped( + #[case] kwargs: serde_json::Value, + #[case] environment: &[(&str, &str)], + #[case] passthrough: serde_json::Value, + #[case] host_objects: &[&str], +) { + let environment: HashMap = environment + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) + .collect(); + + let resolved = resolve_kwargs(kwargs, &environment).unwrap(); + + let passed: serde_json::Map = resolved + .passthrough + .iter() + .map(|(key, value)| (key.to_owned(), value.clone())) + .collect(); + assert_eq!(serde_json::Value::Object(passed), passthrough); + assert_eq!( + resolved + .host_objects + .iter() + .map(String::as_str) + .collect::>(), + host_objects + ); +} + fn tls_view(tls: Option<&TlsOptions>) -> serde_json::Value { tls.map_or(serde_json::Value::Null, |tls| { serde_json::json!({ @@ -654,7 +735,7 @@ fn tls_view(tls: Option<&TlsOptions>) -> serde_json::Value { }) } -fn nodes_view(nodes: &[ClusterNode]) -> Vec { +fn nodes_view(nodes: &[Node]) -> Vec { nodes .iter() .map(|node| format!("{}:{}", node.host, node.port)) @@ -681,48 +762,48 @@ fn expected_view(fields: serde_json::Value) -> serde_json::Value { serde_json::Value::Object(defaults.into_iter().chain(fields).collect()) } -fn view(resolved: &ResolvedRedis) -> serde_json::Value { - let target = match &resolved.endpoint { - Endpoint::Host(host) => serde_json::json!({ +fn view(resolved: &Resolution) -> serde_json::Value { + let target = match &resolved.target { + Target::Standalone(host) => serde_json::json!({ "mode": "host", "host": host.host, "port": host.port, "db": host.db, "tls": tls_view(host.tls.as_ref()), }), - Endpoint::Url { url, tls } => serde_json::json!({ + Target::Url(url) => serde_json::json!({ "mode": "url", - "url": url.expose(), - "tls": tls_view(url.expose().starts_with("rediss://").then_some(tls)), + "url": url.url.expose(), + "tls": tls_view(url.tls.as_ref()), }), - Endpoint::Cluster(cluster) => serde_json::json!({ + Target::Cluster(cluster) => serde_json::json!({ "mode": "cluster", "nodes": nodes_view(&cluster.nodes), "tls": tls_view(cluster.tls.as_ref()), }), - Endpoint::Sentinel(sentinel) => serde_json::json!({ + Target::Sentinel(sentinel) => serde_json::json!({ "mode": "sentinel", - "nodes": nodes_view(&sentinel.sentinels), + "nodes": nodes_view(&sentinel.monitors), "service_name": sentinel.service_name, - "sentinel_password": expose(sentinel.sentinel_password.as_ref()), + "sentinel_password": expose(sentinel.monitor_auth.as_ref().and_then(|auth| auth.password.as_ref())), "db": sentinel.db, "tls": tls_view(sentinel.tls.as_ref()), }), }; let auth = match &resolved.auth { - RedisAuth::Static { username, password } => serde_json::json!({ + Auth::Static(StaticAuth { username, password }) => serde_json::json!({ "kind": "static", "username": expose(username.as_ref()), "password": expose(password.as_ref()), }), - RedisAuth::Host => serde_json::json!({"kind": "host"}), - RedisAuth::GcpIam { service_account } => { + Auth::Provider(_) => serde_json::json!({"kind": "host"}), + Auth::GcpIam { service_account } => { serde_json::json!({"kind": "gcp_iam", "service_account": service_account}) } - RedisAuth::AzureAd(azure) => { + Auth::AzureAd(azure) => { serde_json::json!({"kind": "azure_ad", "username": azure.username}) } - RedisAuth::AwsIam(aws) => serde_json::json!({ + Auth::AwsIam(aws) => serde_json::json!({ "kind": "aws_iam", "user_name": aws.user_name, "cache_name": aws.cache_name, @@ -1089,9 +1170,11 @@ fn secret_manager_values_reach_the_resolver_through_the_snapshot() { _ => None, }); - let resolved = settings.resolve(&secrets, &|_: &str| None).unwrap().redis; + let resolved = settings.resolve(&secrets, &|_: &str| None).unwrap(); - assert!(matches!(&resolved.endpoint, Endpoint::Host(host) if host.host == "from-the-manager")); + assert!( + matches!(&resolved.target, Target::Standalone(host) if host.host == "from-the-manager") + ); assert_eq!(resolved.auth, static_auth(None, Some("manager-secret"))); } @@ -1110,11 +1193,10 @@ fn the_azure_username_comes_from_the_process_environment_like_python( .resolve(&snapshot(secrets), &|name: &str| { environment.get(name).map(|value| (*value).to_owned()) }) - .unwrap() - .redis; + .unwrap(); assert!( - matches!(&resolved.auth, RedisAuth::AzureAd(azure) if azure.username.as_deref() == expected), + matches!(&resolved.auth, Auth::AzureAd(azure) if azure.username.as_deref() == expected), "{:?}", resolved.auth ); @@ -1148,8 +1230,8 @@ fn a_null_overrides_the_environment_only_where_python_passes_it_on( } .unwrap(); - let Endpoint::Host(host) = resolved.endpoint else { - panic!("expected a host endpoint, got {:?}", resolved.endpoint); + let Target::Standalone(host) = resolved.target else { + panic!("expected a standalone target, got {:?}", resolved.target); }; assert_eq!(host.tls.is_some(), tls); } diff --git a/litellm-rust/crates/gateway/src/redis.rs b/litellm-rust/crates/gateway/src/redis.rs index cfe67c04f03..277ffe50c5a 100644 --- a/litellm-rust/crates/gateway/src/redis.rs +++ b/litellm-rust/crates/gateway/src/redis.rs @@ -2,17 +2,17 @@ use std::time::Duration; use litellm_circuit_breaker::{Breaker, Config as BreakerConfig, State}; use litellm_config::{ - CacheSettings, Config, ConfiguredRedis, Expected, RedisError, RedisSettings, Resolution, - SecretSnapshot, Value, environment_has_target, + CacheSettings, Config, ConfiguredRedis, Expected, RedisError, RedisSettings, SecretSnapshot, + Value, environment_has_target, }; use litellm_core_utils::settings::Lookup; use litellm_redis_client::RedisClient; -use litellm_redis_types::ResolvedRedis; +use litellm_redis_types::Resolution; use crate::Error; const ENVIRONMENT_PING_TIMEOUT: Duration = Duration::from_secs(2); -type Params = ResolvedRedis; +type Params = Resolution; pub struct RedisClients { pub coordination: Option, @@ -108,11 +108,11 @@ fn redis_cache_params(config: &Config) -> Option<&RedisSettings> { fn resolve(configured: &ConfiguredRedis, lookup: &dyn Lookup) -> Result { let secrets = SecretSnapshot::read(configured.secret_names(), lookup); - let Resolution { redis, ignored } = configured.resolve(&secrets, lookup)?; - for setting in ignored { + let resolution = configured.resolve(&secrets, lookup)?; + for setting in &resolution.ignored { tracing::warn!(%setting, "Redis setting is accepted but ignored"); } - Ok(redis) + Ok(resolution) } fn environment(lookup: &dyn Lookup) -> Result, Error> { @@ -241,9 +241,9 @@ mod tests { use std::{collections::HashMap, time::Duration}; use litellm_config::{Config, RedisError}; - use litellm_redis_client::{Endpoint, HostEndpoint}; + use litellm_redis_client::{Standalone, StaticAuth}; use litellm_redis_testing::RedisServer; - use litellm_redis_types::RedisAuth; + use litellm_redis_types::{Auth, Passthrough}; use rstest::rstest; use super::{Params, Plan, Target, connect, plan}; @@ -251,19 +251,19 @@ mod tests { fn host(name: &str, port: u16) -> Params { Params { - endpoint: Endpoint::Host(HostEndpoint { + target: litellm_redis_client::Target::Standalone(Standalone { host: name.to_owned(), port, tls: None, db: 0, }), - auth: RedisAuth::Static { - username: None, - password: None, - }, + auth: Auth::Static(StaticAuth::default()), namespace: None, timeout: None, connect_timeout: None, + passthrough: Passthrough::default(), + host_objects: Default::default(), + ignored: Vec::new(), } } @@ -431,7 +431,7 @@ mod tests { | (None, Some(params)) => params, (None, None) => panic!("expected a Redis target"), }; - assert_eq!(target.endpoint, host("env", 6379).endpoint); + assert_eq!(target.target, host("env", 6379).target); } #[rstest] diff --git a/litellm-rust/crates/redis-client/src/client.rs b/litellm-rust/crates/redis-client/src/client.rs index 5918273cc34..309e6ba02bf 100644 --- a/litellm-rust/crates/redis-client/src/client.rs +++ b/litellm-rust/crates/redis-client/src/client.rs @@ -30,7 +30,7 @@ pub enum Ttl { impl RedisClient { pub fn new(config: RedisConfig) -> Result { Ok(Self { - connection: Connection::new(&config.endpoint, &config.credentials, config.timeouts)?, + connection: Connection::new(&config.target, &config.credentials, config.timeouts)?, namespace: config.namespace, breaker: config.breaker, events: config.events, diff --git a/litellm-rust/crates/redis-client/src/config.rs b/litellm-rust/crates/redis-client/src/config.rs index fa4d1bcbe10..c4deadea6aa 100644 --- a/litellm-rust/crates/redis-client/src/config.rs +++ b/litellm-rust/crates/redis-client/src/config.rs @@ -9,13 +9,13 @@ use litellm_circuit_breaker::Breaker; use litellm_secret_value::SecretValue; use crate::{ConnectError, Namespace, RedisEvents}; -use litellm_redis_types::{ClusterEndpoint, Endpoint, TlsOptions}; +use litellm_redis_types::{Cluster, Target, TlsOptions}; // litellm/constants.py REDIS_SOCKET_TIMEOUT, which _coerce_redis_kwargs_types sets when socket_timeout is unset pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); pub struct RedisConfig { - pub endpoint: Endpoint, + pub target: Target, pub credentials: Credentials, pub namespace: Namespace, pub timeouts: Timeouts, @@ -140,27 +140,25 @@ pub(crate) fn addr(host: &str, port: u16, tls: Option<&TlsOptions>) -> Connectio } } -pub(crate) fn cluster_addrs( - endpoint: &ClusterEndpoint, -) -> impl Iterator + '_ { - endpoint +pub(crate) fn cluster_addrs(cluster: &Cluster) -> impl Iterator + '_ { + cluster .nodes .iter() - .map(|node| addr(&node.host, node.port, endpoint.tls.as_ref())) + .map(|node| addr(&node.host, node.port, cluster.tls.as_ref())) } pub(crate) fn connection_info( - endpoint: &Endpoint, + target: &Target, credentials: &Credentials, ) -> Result { - let (info, tls) = match endpoint { - Endpoint::Url { url, tls } => (url.expose().into_connection_info()?, Some(tls)), - Endpoint::Host(host) => { + let (info, tls) = match target { + Target::Url(url) => (url.url.expose().into_connection_info()?, url.tls.as_ref()), + Target::Standalone(host) => { let info = addr(&host.host, host.port, host.tls.as_ref()).into_connection_info()?; let settings = info.redis_settings().clone().set_db(host.db); (info.set_redis_settings(settings), host.tls.as_ref()) } - Endpoint::Cluster(_) | Endpoint::Sentinel(_) => { + Target::Cluster(_) | Target::Sentinel(_) => { unreachable!("cluster and sentinel endpoints build their own connection info") } }; diff --git a/litellm-rust/crates/redis-client/src/connection.rs b/litellm-rust/crates/redis-client/src/connection.rs index 1292ae5beb0..d64eba34c6f 100644 --- a/litellm-rust/crates/redis-client/src/connection.rs +++ b/litellm-rust/crates/redis-client/src/connection.rs @@ -17,7 +17,7 @@ use redis::{ }; use crate::{ - ClusterEndpoint, ConnectError, Credentials, Endpoint, SentinelEndpoint, Timeouts, + Cluster, ConnectError, Credentials, Sentinel, Target, Timeouts, config::{self, addr}, error::Deployment, }; @@ -39,16 +39,16 @@ pub(crate) enum Connection { impl Connection { pub(crate) fn new( - endpoint: &Endpoint, + target: &Target, credentials: &Credentials, timeouts: Timeouts, ) -> Result { - match endpoint { - Endpoint::Url { .. } | Endpoint::Host(_) => { - Self::standalone(config::connection_info(endpoint, credentials)?, timeouts) + match target { + Target::Url(_) | Target::Standalone(_) => { + Self::standalone(config::connection_info(target, credentials)?, timeouts) } - Endpoint::Cluster(cluster) => Self::cluster(cluster, credentials, timeouts), - Endpoint::Sentinel(sentinel) => Ok(Self::Sentinel(SentinelConnection::new( + Target::Cluster(cluster) => Self::cluster(cluster, credentials, timeouts), + Target::Sentinel(sentinel) => Ok(Self::Sentinel(SentinelConnection::new( sentinel, credentials, timeouts, @@ -74,7 +74,7 @@ impl Connection { } fn cluster( - endpoint: &ClusterEndpoint, + endpoint: &Cluster, credentials: &Credentials, timeouts: Timeouts, ) -> Result { @@ -155,7 +155,7 @@ struct SentinelState { discovery: tokio::sync::Mutex, current: Mutex>, generations: AtomicU64, - endpoint: SentinelEndpoint, + endpoint: Sentinel, credentials: Credentials, config: AsyncConnectionConfig, connect_timeout: Duration, @@ -163,12 +163,12 @@ struct SentinelState { impl SentinelConnection { fn new( - endpoint: &SentinelEndpoint, + endpoint: &Sentinel, credentials: &Credentials, timeouts: Timeouts, ) -> Result { let sentinels = endpoint - .sentinels + .monitors .iter() .map(|node| addr(&node.host, node.port, endpoint.tls.as_ref())); let builder = SentinelClientBuilder::new( @@ -176,12 +176,17 @@ impl SentinelConnection { &endpoint.service_name, SentinelServerType::Master, )?; - let builder = match (&endpoint.sentinel_password, &credentials.username) { - (Some(password), Some(username)) => builder - .set_client_to_sentinel_username(username.expose()) - .set_client_to_sentinel_password(password.expose()), - (Some(password), _) => builder.set_client_to_sentinel_password(password.expose()), - (None, _) => builder, + let (username, password) = match &endpoint.monitor_auth { + Some(auth) => (auth.username.as_ref(), auth.password.as_ref()), + None => (None, None), + }; + let builder = match username { + Some(username) => builder.set_client_to_sentinel_username(username.expose()), + None => builder, + }; + let builder = match password { + Some(password) => builder.set_client_to_sentinel_password(password.expose()), + None => builder, }; let builder = match &endpoint.tls { Some(tls) => builder @@ -248,7 +253,7 @@ impl SentinelConnection { ))); } }; - let endpoint = Endpoint::Host(crate::HostEndpoint { + let endpoint = Target::Standalone(crate::Standalone { host: host.to_owned(), port, tls: self.0.endpoint.tls.clone(), @@ -342,7 +347,7 @@ impl ConnectionLike for SentinelConnection { #[cfg(test)] mod tests { use super::*; - use crate::ClusterNode; + use crate::Node; use litellm_redis_testing::{RedisSentinel, SERVICE_NAME}; #[rstest::rstest] @@ -350,13 +355,13 @@ mod tests { async fn a_late_failure_does_not_discard_the_reconnected_sentinel_master() { let server = RedisSentinel::start().await.unwrap(); let (host, port) = server.sentinel(); - let endpoint = SentinelEndpoint { - sentinels: vec![ClusterNode { + let endpoint = Sentinel { + monitors: vec![Node { host: host.to_owned(), port, }], service_name: SERVICE_NAME.to_owned(), - sentinel_password: None, + monitor_auth: None, tls: None, db: 0, }; diff --git a/litellm-rust/crates/redis-client/src/lib.rs b/litellm-rust/crates/redis-client/src/lib.rs index b8cd9f5e301..d2e9417ef28 100644 --- a/litellm-rust/crates/redis-client/src/lib.rs +++ b/litellm-rust/crates/redis-client/src/lib.rs @@ -14,7 +14,7 @@ pub use config::{Credentials, DEFAULT_TIMEOUT, RedisConfig, Timeouts}; pub use error::{ConnectError, Error}; pub use events::{CallEvent, FailureKind, Operation, Outcome, RedisEvents}; pub use litellm_redis_types::{ - ClusterEndpoint, ClusterNode, Endpoint, HostEndpoint, SentinelEndpoint, TlsOptions, + Cluster, Node, RedisUrl, Sentinel, Standalone, StaticAuth, Target, TlsOptions, }; pub use namespace::Namespace; pub use pipeline::Pipeline; diff --git a/litellm-rust/crates/redis-client/tests/client.rs b/litellm-rust/crates/redis-client/tests/client.rs index 35d08a86be5..3a6cd069186 100644 --- a/litellm-rust/crates/redis-client/tests/client.rs +++ b/litellm-rust/crates/redis-client/tests/client.rs @@ -11,9 +11,9 @@ use rstest::rstest; use litellm_circuit_breaker::{Breaker, Config, State}; use litellm_redis_client::{ - Amount, CallEvent, Credentials, DEFAULT_TIMEOUT, Endpoint, Error, Expiry, FailureKind, - HostEndpoint, Namespace, Operation, Outcome, RedisClient, RedisConfig, RedisEvents, Script, - Timeouts, TlsOptions, Ttl, + Amount, CallEvent, Credentials, DEFAULT_TIMEOUT, Error, Expiry, FailureKind, Namespace, + Operation, Outcome, RedisClient, RedisConfig, RedisEvents, RedisUrl, Script, Standalone, + Target, Timeouts, Ttl, }; use litellm_secret_value::SecretValue; use support::{KEEP, connect, connect_guarded, connections, endpoint}; @@ -223,7 +223,7 @@ async fn outage_events_report_the_failure_kind( let server = RedisDeployment::start(topology).await.unwrap(); let recorded = Arc::new(Recorded::default()); let client = RedisClient::new(RedisConfig { - endpoint: endpoint(&server), + target: endpoint(&server), namespace: Namespace::default(), timeouts: Timeouts::new(Duration::from_millis(300), None), breaker: None, @@ -277,7 +277,7 @@ async fn every_call_reports_its_operation_and_outcome( let server = RedisDeployment::start(topology).await.unwrap(); let recorded = Arc::new(Recorded::default()); let client = RedisClient::new(RedisConfig { - endpoint: endpoint(&server), + target: endpoint(&server), credentials: Credentials::NONE, namespace: Namespace::default(), timeouts: Timeouts::default(), @@ -735,9 +735,9 @@ async fn an_open_breaker_fails_every_pipeline_op_without_sending( async fn a_host_endpoint_connects_to_its_database() { let server = RedisServer::start().await.unwrap(); let port = server.url().rsplit(':').next().unwrap().parse().unwrap(); - let connect = |endpoint| { + let connect = |target| { RedisClient::new(RedisConfig { - endpoint, + target, credentials: Credentials::NONE, namespace: Namespace::default(), timeouts: Timeouts::default(), @@ -745,11 +745,13 @@ async fn a_host_endpoint_connects_to_its_database() { events: None, }) }; - let url = |url: String| Endpoint::Url { - url: SecretValue::new(url), - tls: TlsOptions::verified(), + let url = |url: String| { + Target::Url(RedisUrl { + url: SecretValue::new(url), + tls: None, + }) }; - let in_db_3 = connect(Endpoint::Host(HostEndpoint { + let in_db_3 = connect(Target::Standalone(Standalone { host: "127.0.0.1".to_owned(), port, tls: None, diff --git a/litellm-rust/crates/redis-client/tests/config.rs b/litellm-rust/crates/redis-client/tests/config.rs index 206cd906e05..d5c00171c06 100644 --- a/litellm-rust/crates/redis-client/tests/config.rs +++ b/litellm-rust/crates/redis-client/tests/config.rs @@ -1,15 +1,15 @@ use rstest::rstest; -use litellm_redis_client::{Credentials, Endpoint, SentinelEndpoint, TlsOptions}; +use litellm_redis_client::{Credentials, RedisUrl, Sentinel, StaticAuth, Target, TlsOptions}; use litellm_secret_value::SecretValue; #[rstest] -#[case::url(format!("{:?}", Endpoint::Url { url: SecretValue::new("redis://user:credential-value@cache:6379/0"), tls: TlsOptions::verified() }))] +#[case::url(format!("{:?}", Target::Url(RedisUrl { url: SecretValue::new("redis://user:credential-value@cache:6379/0"), tls: None })))] #[case::static_password(format!("{:?}", Credentials { username: None, password: Some(SecretValue::new("credential-value")) }))] -#[case::sentinel_password(format!("{:?}", Endpoint::Sentinel(SentinelEndpoint { - sentinels: Vec::new(), +#[case::sentinel_password(format!("{:?}", Target::Sentinel(Sentinel { + monitors: Vec::new(), service_name: "primary".to_owned(), - sentinel_password: Some(SecretValue::new("credential-value")), + monitor_auth: Some(StaticAuth { username: None, password: Some(SecretValue::new("credential-value")) }), tls: None, db: 0, })))] @@ -47,7 +47,7 @@ async fn ping_over_tls( check_hostname, }; let client = litellm_redis_client::RedisClient::new(litellm_redis_client::RedisConfig { - endpoint: Endpoint::Host(litellm_redis_client::HostEndpoint { + target: Target::Standalone(litellm_redis_client::Standalone { host: host.to_owned(), port: server.port(), tls: Some(tls), @@ -123,7 +123,7 @@ fn a_missing_tls_file_names_the_path() { }; let error = litellm_redis_client::RedisClient::new(litellm_redis_client::RedisConfig { - endpoint: Endpoint::Host(litellm_redis_client::HostEndpoint { + target: Target::Standalone(litellm_redis_client::Standalone { host: "127.0.0.1".to_owned(), port: 6379, tls: Some(tls), diff --git a/litellm-rust/crates/redis-client/tests/connection.rs b/litellm-rust/crates/redis-client/tests/connection.rs index c8e414a4856..39d865599d6 100644 --- a/litellm-rust/crates/redis-client/tests/connection.rs +++ b/litellm-rust/crates/redis-client/tests/connection.rs @@ -8,8 +8,8 @@ use litellm_redis_testing::{ use rstest::rstest; use litellm_redis_client::{ - ClusterNode, Credentials, Endpoint, Error, HostEndpoint, Namespace, RedisClient, RedisConfig, - SentinelEndpoint, Timeouts, + Credentials, Error, Namespace, Node, RedisClient, RedisConfig, Sentinel, Standalone, Target, + Timeouts, }; use litellm_secret_value::SecretValue; use support::connect_as; @@ -20,7 +20,7 @@ async fn an_out_of_range_database_is_a_server_rejection() { let server = RedisServer::start().await.unwrap(); let port = server.url().rsplit(':').next().unwrap().parse().unwrap(); let client = RedisClient::new(RedisConfig { - endpoint: Endpoint::Host(HostEndpoint { + target: Target::Standalone(Standalone { host: "127.0.0.1".into(), port, tls: None, @@ -64,13 +64,13 @@ async fn a_rejected_credential_is_a_transport_failure() { fn sentinel_client(sentinel: &RedisSentinel) -> RedisClient { let (host, port) = sentinel.sentinel(); RedisClient::new(RedisConfig { - endpoint: Endpoint::Sentinel(SentinelEndpoint { - sentinels: vec![ClusterNode { + target: Target::Sentinel(Sentinel { + monitors: vec![Node { host: host.to_owned(), port, }], service_name: SERVICE_NAME.to_owned(), - sentinel_password: None, + monitor_auth: None, tls: None, db: 0, }), diff --git a/litellm-rust/crates/redis-client/tests/support/mod.rs b/litellm-rust/crates/redis-client/tests/support/mod.rs index 850c46147d0..12b3ea1c008 100644 --- a/litellm-rust/crates/redis-client/tests/support/mod.rs +++ b/litellm-rust/crates/redis-client/tests/support/mod.rs @@ -4,24 +4,24 @@ use std::time::Duration; use litellm_circuit_breaker::Breaker; use litellm_redis_client::{ - ClusterEndpoint, ClusterNode, Credentials, Endpoint, Expiry, Namespace, RedisClient, - RedisConfig, Timeouts, TlsOptions, + Cluster, Credentials, Expiry, Namespace, Node, RedisClient, RedisConfig, RedisUrl, Target, + Timeouts, }; use litellm_redis_testing::{ClientConnection, RedisDeployment}; use litellm_secret_value::SecretValue; pub const KEEP: Expiry = Expiry::KeepExisting(Duration::from_secs(60)); -pub fn endpoint(deployment: &RedisDeployment) -> Endpoint { +pub fn endpoint(deployment: &RedisDeployment) -> Target { match deployment { - RedisDeployment::Standalone(server) => Endpoint::Url { + RedisDeployment::Standalone(server) => Target::Url(RedisUrl { url: SecretValue::new(server.url()), - tls: TlsOptions::verified(), - }, - RedisDeployment::Cluster(cluster) => Endpoint::Cluster(ClusterEndpoint { + tls: None, + }), + RedisDeployment::Cluster(cluster) => Target::Cluster(Cluster { nodes: cluster .nodes() - .map(|(host, port)| ClusterNode { + .map(|(host, port)| Node { host: host.to_owned(), port, }) @@ -42,7 +42,7 @@ pub fn connect_guarded( breaker: Option, ) -> RedisClient { RedisClient::new(RedisConfig { - endpoint: endpoint(deployment), + target: endpoint(deployment), credentials: Credentials::NONE, namespace: Namespace::new(namespace), timeouts: Timeouts::new(timeout, None), @@ -54,7 +54,7 @@ pub fn connect_guarded( pub fn connect_as(deployment: &RedisDeployment, credentials: Credentials) -> RedisClient { RedisClient::new(RedisConfig { - endpoint: endpoint(deployment), + target: endpoint(deployment), credentials, namespace: Namespace::default(), timeouts: Timeouts::new(Duration::from_secs(2), None), diff --git a/litellm-rust/crates/redis-connect/AGENTS.md b/litellm-rust/crates/redis-connect/AGENTS.md index cc2d8f3fb11..f1c7d9a8b68 100644 --- a/litellm-rust/crates/redis-connect/AGENTS.md +++ b/litellm-rust/crates/redis-connect/AGENTS.md @@ -1,3 +1,3 @@ -- Turns a `litellm_redis_types::ResolvedRedis` into a `RedisClient`, the same way for every host. It depends on `litellm-redis-types`, never on `litellm-config`, so a host that resolves Redis some other way connects the same way. `redis-client` stays cloud-agnostic, so credentials are mapped here -- `RedisAuth::Host` (a Python `credential_provider` object) fails at connect with an error naming the key, since no Rust host can build one. Cloud auth (GCP, Azure, AWS) fails the same way until its token sources land, so a config that needs it never silently runs without Redis +- Turns a `litellm_redis_types::Resolution` into a `RedisClient`, the same way for every host. It depends on `litellm-redis-types`, never on `litellm-config`, so a host that resolves Redis some other way connects the same way. `redis-client` stays cloud-agnostic, so credentials are mapped here +- `Auth::Provider` (a handle for a Python `credential_provider` object) fails at connect with an error naming the key, since no Rust host can build one. Cloud auth (GCP, Azure, AWS) fails the same way until its token sources land, so a config that needs it never silently runs without Redis - Tests run against real Redis from `litellm-redis-testing` diff --git a/litellm-rust/crates/redis-connect/src/connect.rs b/litellm-rust/crates/redis-connect/src/connect.rs index c2a3b4b6f45..8a805182007 100644 --- a/litellm-rust/crates/redis-connect/src/connect.rs +++ b/litellm-rust/crates/redis-connect/src/connect.rs @@ -2,14 +2,14 @@ use litellm_circuit_breaker::Breaker; use litellm_redis_client::{ Credentials, DEFAULT_TIMEOUT, Namespace, RedisClient, RedisConfig, Timeouts, }; -use litellm_redis_types::{RedisAuth, ResolvedRedis}; +use litellm_redis_types::{Auth, Resolution, StaticAuth}; use crate::Error; -pub fn connect(resolved: ResolvedRedis, breaker: Option) -> Result { - tracing::info!(endpoint = ?resolved.endpoint, namespace = ?resolved.namespace, "redis client configured"); +pub fn connect(resolved: Resolution, breaker: Option) -> Result { + tracing::info!(target = ?resolved.target, namespace = ?resolved.namespace, "redis client configured"); Ok(RedisClient::new(RedisConfig { - endpoint: resolved.endpoint, + target: resolved.target, credentials: credentials(resolved.auth)?, namespace: resolved.namespace.map(Namespace::new).unwrap_or_default(), timeouts: Timeouts::new( @@ -21,17 +21,17 @@ pub fn connect(resolved: ResolvedRedis, breaker: Option) -> Result Result { +fn credentials(auth: Auth) -> Result { match auth { - RedisAuth::Static { username, password } => Ok(Credentials { username, password }), - RedisAuth::Host => Err(Error::NoHostCredentialProvider), - RedisAuth::GcpIam { .. } => Err(Error::CloudAuth { + Auth::Static(StaticAuth { username, password }) => Ok(Credentials { username, password }), + Auth::Provider(_) => Err(Error::NoHostCredentialProvider), + Auth::GcpIam { .. } => Err(Error::CloudAuth { setting: "gcp_service_account", }), - RedisAuth::AzureAd(_) => Err(Error::CloudAuth { + Auth::AzureAd(_) => Err(Error::CloudAuth { setting: "azure_redis_ad_token", }), - RedisAuth::AwsIam(_) => Err(Error::CloudAuth { + Auth::AwsIam(_) => Err(Error::CloudAuth { setting: "aws_iam_auth", }), } diff --git a/litellm-rust/crates/redis-connect/tests/connect.rs b/litellm-rust/crates/redis-connect/tests/connect.rs index ba0530cb425..fd0495005b4 100644 --- a/litellm-rust/crates/redis-connect/tests/connect.rs +++ b/litellm-rust/crates/redis-connect/tests/connect.rs @@ -3,21 +3,26 @@ use std::{collections::HashMap, time::Duration}; use litellm_config::{RedisSettings, SecretSnapshot}; use litellm_redis_connect::{Error, connect}; use litellm_redis_testing::{RedisSentinel, RedisServer, RedisTlsServer, SERVICE_NAME, eventually}; -use litellm_redis_types::ResolvedRedis; +use litellm_redis_types::{ProviderHandle, Resolution}; use rstest::rstest; -fn resolved(kwargs: serde_json::Value, environment: &[(&str, &str)]) -> ResolvedRedis { +// python-bridge hands a credential_provider object over as a handle, so these tests do the same +fn resolved(kwargs: serde_json::Value, environment: &[(&str, &str)]) -> Resolution { + let kwargs_name_a_provider = kwargs.get("credential_provider").is_some(); let settings: RedisSettings = serde_json::from_value(kwargs).unwrap(); let environment: HashMap<&str, &str> = environment.iter().copied().collect(); let lookup = |name: &str| environment.get(name).map(|value| (*value).to_owned()); let configured = settings.coordination().unwrap(); + let configured = match kwargs_name_a_provider { + true => configured.with_credential_provider(ProviderHandle::new(1)), + false => configured, + }; configured .resolve( &SecretSnapshot::read(configured.secret_names(), &lookup), &lookup, ) .unwrap() - .redis } fn port(url: &str) -> u16 { diff --git a/litellm-rust/crates/redis-types/AGENTS.md b/litellm-rust/crates/redis-types/AGENTS.md index c9d18fe3da1..e86ce0b60ca 100644 --- a/litellm-rust/crates/redis-types/AGENTS.md +++ b/litellm-rust/crates/redis-types/AGENTS.md @@ -1,2 +1,2 @@ -- `ResolvedRedis` and the endpoint, node, TLS and auth types inside it: what `config` resolves to, `redis-connect` turns into a client and `redis-client` connects with, so none of them converts another's copy -- Data only. It depends on `secret-value` and nothing else, so `config` never pulls in redis-rs or tokio. Anything that needs redis-rs, like building a `ConnectionInfo` or reading TLS files, lives in `redis-client` +- `Resolution` and the target, node, TLS and auth types inside it: what `config` resolves to, `redis-connect` turns into a client and `redis-client` connects with, so none of them converts another's copy. A host builds its redis-py client from the same `Resolution`, reattaching `passthrough` and `host_objects` +- Data only. It depends on `secret-value`, `serde_json` and `veil` and nothing else, so `config` never pulls in redis-rs or tokio. Anything that needs redis-rs, like building a `ConnectionInfo` or reading TLS files, lives in `redis-client` diff --git a/litellm-rust/crates/redis-types/Cargo.toml b/litellm-rust/crates/redis-types/Cargo.toml index 229325a27bd..05f681ddbdc 100644 --- a/litellm-rust/crates/redis-types/Cargo.toml +++ b/litellm-rust/crates/redis-types/Cargo.toml @@ -7,3 +7,5 @@ repository.workspace = true [dependencies] litellm-secret-value.workspace = true +serde_json.workspace = true +veil.workspace = true diff --git a/litellm-rust/crates/redis-types/src/lib.rs b/litellm-rust/crates/redis-types/src/lib.rs index 7b1365d50b9..bc8f11e644d 100644 --- a/litellm-rust/crates/redis-types/src/lib.rs +++ b/litellm-rust/crates/redis-types/src/lib.rs @@ -1,28 +1,41 @@ -use std::{path::PathBuf, time::Duration}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::PathBuf, + time::Duration, +}; use litellm_secret_value::SecretValue; +use veil::Redact; -/// One Redis connection with every setting, environment value and secret already applied, the -/// value a host hands to the connector. +/// One Redis connection with every setting, environment value and secret already applied. Each +/// client library builds its client from this one value, so they cannot read a setting differently. #[derive(Clone, Debug, PartialEq)] -pub struct ResolvedRedis { - pub endpoint: Endpoint, - pub auth: RedisAuth, +pub struct Resolution { + pub target: Target, + pub auth: Auth, pub namespace: Option, pub timeout: Option, pub connect_timeout: Option, + /// Plain settings the resolver does not model, kept verbatim for the redis-py projection. + pub passthrough: Passthrough, + /// Settings whose value is a host object, like redis-py's `retry` or `connection_class`, which + /// only the host can reattach. + pub host_objects: BTreeSet, + /// Where each setting the Rust client accepts without honoring came from: its key, or the + /// environment variable that set it. + pub ignored: Vec, } #[derive(Clone, Debug, PartialEq)] -pub enum Endpoint { - Url { url: SecretValue, tls: TlsOptions }, - Host(HostEndpoint), - Cluster(ClusterEndpoint), - Sentinel(SentinelEndpoint), +pub enum Target { + Standalone(Standalone), + Url(RedisUrl), + Cluster(Cluster), + Sentinel(Sentinel), } #[derive(Clone, Debug, PartialEq)] -pub struct HostEndpoint { +pub struct Standalone { pub host: String, pub port: u16, pub tls: Option, @@ -30,27 +43,35 @@ pub struct HostEndpoint { } #[derive(Clone, Debug, PartialEq)] -pub struct ClusterEndpoint { - pub nodes: Vec, +pub struct RedisUrl { + pub url: SecretValue, + /// Set only for a `rediss://` url, from its query parameters. pub tls: Option, } #[derive(Clone, Debug, PartialEq)] -pub struct ClusterNode { +pub struct Cluster { + pub nodes: Vec, + pub tls: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Node { pub host: String, pub port: u16, } #[derive(Clone, Debug, PartialEq)] -pub struct SentinelEndpoint { - pub sentinels: Vec, +pub struct Sentinel { + pub monitors: Vec, pub service_name: String, - pub sentinel_password: Option, + /// How to authenticate to the monitors, which are separate servers from the data node. + pub monitor_auth: Option, pub tls: Option, pub db: i64, } -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct TlsOptions { pub ca_certs: Option, pub certfile: Option, @@ -62,20 +83,20 @@ pub struct TlsOptions { impl TlsOptions { pub fn verified() -> Self { Self { + ca_certs: None, + certfile: None, + keyfile: None, verify_certificate: true, - ..Self::default() + check_hostname: false, } } } #[derive(Clone, Debug, PartialEq)] -pub enum RedisAuth { - Static { - username: Option, - password: Option, - }, - /// The host supplies credentials, like a redis-py `credential_provider` object. - Host, +pub enum Auth { + Static(StaticAuth), + /// Credentials come from a host object, like a redis-py `credential_provider`. + Provider(ProviderHandle), GcpIam { service_account: String, }, @@ -83,6 +104,27 @@ pub enum RedisAuth { AwsIam(AwsIamAuth), } +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StaticAuth { + pub username: Option, + pub password: Option, +} + +/// Names one credential provider the host holds. Two resolutions with the same handle use the +/// same provider. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ProviderHandle(u64); + +impl ProviderHandle { + pub const fn new(id: u64) -> Self { + Self(id) + } + + pub const fn id(self) -> u64 { + self.0 + } +} + #[derive(Clone, Debug, PartialEq)] pub struct AzureAdAuth { pub client_id: Option, @@ -98,3 +140,21 @@ pub struct AwsIamAuth { pub region: String, pub serverless: bool, } + +/// Settings passed through untouched. They can hold credentials, so they never print. +#[derive(Clone, Default, PartialEq, Redact)] +pub struct Passthrough(#[redact(with = "[REDACTED]")] BTreeMap); + +impl Passthrough { + pub fn new(settings: BTreeMap) -> Self { + Self(settings) + } + + pub fn get(&self, key: &str) -> Option<&serde_json::Value> { + self.0.get(key) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(key, value)| (key.as_str(), value)) + } +}