mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
refactor(redis): interpret merged kwargs in redis-resolve and merge in gateway
config now only deserializes the Redis YAML. The new litellm-redis-resolve crate turns an already merged redis-py kwargs dict into a Resolution without reading the environment or secrets, so both hosts can share it. The REDIS_* layering, the get_secret fallbacks and the Azure REDIS_USERNAME read move to gateway, the only host that needs a Rust copy of the merge. litellm/_redis.py gets _merge_redis_kwargs, the merge stage of _get_redis_client_logic, and the contract cases gain a merged column that pins the Python merge, the Rust merge and the Rust interpret step to the same dict.
This commit is contained in:
parent
5ca4703c3f
commit
b7e07948c8
33 changed files with 1724 additions and 1148 deletions
24
litellm-rust/Cargo.lock
generated
24
litellm-rust/Cargo.lock
generated
|
|
@ -3711,10 +3711,7 @@ dependencies = [
|
|||
name = "litellm-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"litellm-core-utils",
|
||||
"litellm-error",
|
||||
"litellm-python-compat",
|
||||
"litellm-redis-types",
|
||||
"litellm-router-types",
|
||||
"litellm-secret-value",
|
||||
"rstest",
|
||||
|
|
@ -3724,8 +3721,6 @@ dependencies = [
|
|||
"strum",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"url",
|
||||
"veil",
|
||||
]
|
||||
|
||||
|
|
@ -3840,6 +3835,7 @@ dependencies = [
|
|||
"litellm-llms",
|
||||
"litellm-redis-client",
|
||||
"litellm-redis-connect",
|
||||
"litellm-redis-resolve",
|
||||
"litellm-redis-testing",
|
||||
"litellm-redis-types",
|
||||
"litellm-secret-value",
|
||||
|
|
@ -3857,6 +3853,7 @@ dependencies = [
|
|||
"tower-sessions-moka-store",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"veil",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4448,8 +4445,8 @@ name = "litellm-redis-connect"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"litellm-circuit-breaker",
|
||||
"litellm-config",
|
||||
"litellm-redis-client",
|
||||
"litellm-redis-resolve",
|
||||
"litellm-redis-testing",
|
||||
"litellm-redis-types",
|
||||
"rstest",
|
||||
|
|
@ -4460,6 +4457,21 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-redis-resolve"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"litellm-python-compat",
|
||||
"litellm-redis-types",
|
||||
"litellm-secret-value",
|
||||
"rstest",
|
||||
"serde_json",
|
||||
"strum",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-redis-testing"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ litellm-circuit-breaker = { path = "crates/circuit-breaker" }
|
|||
litellm-redis-client = { path = "crates/redis-client" }
|
||||
litellm-redis-connect = { path = "crates/redis-connect" }
|
||||
litellm-redis-testing = { path = "crates/redis-testing" }
|
||||
litellm-redis-resolve = { path = "crates/redis-resolve" }
|
||||
litellm-redis-types = { path = "crates/redis-types" }
|
||||
litellm-cost = { path = "crates/cost" }
|
||||
litellm-model-catalog = { path = "crates/model-catalog" }
|
||||
|
|
|
|||
|
|
@ -1,27 +1,3 @@
|
|||
## Redis settings
|
||||
|
||||
- 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 setting the Rust client ignores as an `Ignored` with its source and consequence, 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`, splits the unmodeled settings 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
|
||||
- `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
|
||||
- Every Redis setting Python reads is one row in `SETTINGS` in `src/redis/keys.rs`: how Rust handles it (read, or warn and drop), whether `_get_redis_env_kwarg_mapping` reads it as `REDIS_<KWARG>`, and the names `_get_redis_client_logic` falls back to. The env layer, the ignored list and `secret_names` all come from that table. A read row is also a field of `Options`. `redis-py-kwargs.json` records which redis-py version accepts each kwarg next to its row, so check it when adding or changing a row
|
||||
- Lookups go through `litellm_core_utils::settings::Lookup`, never a closure type of our own
|
||||
- A url carries its own TLS. redis-py's url allowlist is the plain `Connection` signature, so every `ssl_*` setting outside the url is dropped, and a credential provider strips the url's userinfo. A `rediss://` url's `ssl_cert_reqs`, `ssl_ca_certs`, `ssl_ca_data`, `ssl_ca_path`, `ssl_certfile`, `ssl_keyfile`, `ssl_password` and `ssl_check_hostname` query parameters are read like `parse_url` reads them
|
||||
- TLS options and `db` are only read for the endpoint that uses them, so a bad `ssl_cert_reqs` never fails a url target. A cluster with a non-zero `db`, and Sentinel with a `host` or `port`, are errors because redis-py raises building those clients
|
||||
- An empty value (`""`, `[]`, `{}`) is the same as an unset one, so it falls through to its environment fallback the way Python's `x or get_secret(...)` does. Nulls depend on the surface. `coordination_redis` drops every null like `model_dump(exclude_none=True)`, `cache_params` keeps a null as an explicit `None` that hides the `REDIS_*` value unless `Cache` or `RedisCache` names that parameter, and `RedisCache.redis_kwargs` keeps every null. A missing `os.environ/` reference is a null, as `load_config` resolves it
|
||||
- Precedence matches Python. The target is cluster, then URL, then Sentinel (`sentinel_nodes` plus `service_name`), then host. Auth is an explicit `credential_provider`, then GCP, then Azure, then AWS, and the first one wins with a warning
|
||||
- Numbers are read with `litellm_python_compat::number`, so whitespace and digit underscores parse like Python's `int()` and `float()`. A value they cannot parse (`port: abc`, `socket_timeout: slow`) is dropped with a warning and the default is used, like `_coerce_redis_kwargs_types`. A number that parses but is out of range (negative seconds, a port above 65535) fails with `RedisError::Invalid`
|
||||
- Flags (`ssl`, `ssl_check_hostname`, `aws_iam_auth`, `aws_iam_serverless`) are read one way on every surface. `true`, `1` and `yes` are true and `false`, `0`, `no`, `off`, `f` and `n` are false, the text redis-py's `_str_to_bool` and the proxy's pydantic models agree on. Any other text is `RedisError::Invalid`, so a TLS setting is never guessed
|
||||
- `RedisSettings::coordination` mirrors the proxy's `CoordinationRedisParams` pydantic model, which rejects a malformed `port` before redis-py coerces anything
|
||||
- The resolver fails on a setting it cannot honor only when that setting is a security check. A `Warn` row is dropped and returned as an `Ignored` naming its source (`ssl_ciphers` or `REDIS_SSL_CIPHERS`) and the consequence from `keys::consequence`, because a Python deployment that worked must keep working when its stores move to Rust. Only a value that is malformed (`RedisError::Invalid`) or a target that is missing fails. Promoting a `Warn` row to `Read` is then a pure improvement with no behavior cliff
|
||||
- `ssl_ciphers`, `ssl_min_version` above TLS 1.2, `ssl_ocsp_context`, `ssl_ocsp_expected_cert`, `connection_pool`, `event_dispatcher`, `redis_connect_func` and `retry` are ignored
|
||||
- A `Reject` row fails closed. `ssl_validate_ocsp` and `ssl_validate_ocsp_stapled` set to true, from the kwargs, `REDIS_*` or a url query, are `RedisError::Unenforceable`, because rustls has no client-side OCSP and connecting without the check would silently drop it. redis-py 5.3.1's async client refuses both with a `TypeError`, so this matches the proxy's path. Set to false they ask for nothing and are dropped without a warning
|
||||
- `ssl_ca_data` and `ssl_ca_path` are read into `TlsOptions` and trusted next to `ssl_ca_certs`. `ssl_password` is read into `TlsOptions::key_password`, which `redis-client` uses to decrypt a PKCS#8 key like the sync client's `load_cert_chain(password=...)`. redis-py 5.3.1's async client refuses it, so this is a superset of the path the proxy uses, like `ssl_ca_path` `unix_socket_path` resolves to `Target::Unix`, which like redis-py's `UnixDomainSocketConnection` replaces host, port and TLS. Python still requires `host` next to it, so Rust does too
|
||||
- A url's `?db=`, `?username=` and `?password=` are read like `parse_url` reads them, so `?db=` wins over the path and the userinfo wins over `?username=` and `?password=`. Any other query parameter except the TLS ones on a `rediss://` url is ignored naming `url ?<name>`. redis-py turns each one into a client kwarg
|
||||
- `retry_on_timeout`, `retry_on_error`, `protocol`, `cache`, `cache_config`, `socket_keepalive` and `max_connections` are ignored. Cluster clients still turn TCP keepalive on, like Python's async cluster default
|
||||
- `health_check_interval` is read as an `int` of seconds like `_coerce_redis_kwargs_types` into `HealthCheck`. Zero is `Disabled` and stays off on every target, a negative interval checks before every command like redis-py, and only `Unset` takes a default. `redis-connect` gives an unset cluster Python's 25s default and `redis-client` pings every node before the next command once the client sat idle that long, outside the breaker and the call events
|
||||
- Cases where Rust and Python disagree on purpose are cases in both contract tests with a different expected outcome on each side
|
||||
- `ssl_ca_path`: redis-py 5.3.1's async `Redis` has no such parameter and raises `TypeError` building the client, while Rust trusts the directory like `load_verify_locations(capath=...)`
|
||||
- `on`, `t` and `y` for a flag are `RedisError::Invalid` on every surface. pydantic reads them as true for `coordination_redis` and `_str_to_bool` reads them as false everywhere else, so Rust refuses to pick one. Other unrecognized text is false in Python and an error here
|
||||
- Empty `sentinel_nodes` with no `REDIS_SENTINEL_NODES` is no target at all, so it fails as "either host or url must be specified". redis-py keeps the empty list and fails building the Sentinel client
|
||||
- This crate only deserializes YAML. `RedisSettings` is the raw `coordination_redis` or `cache_params` map and `CacheSettings` tells a `type: redis` cache apart from the rest. Reading those maps the way `litellm/_redis.py` does belongs to `gateway/src/redis`, and turning merged kwargs into a `Resolution` belongs to `redis-resolve`
|
||||
|
|
|
|||
|
|
@ -6,21 +6,16 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
litellm-secret-value.workspace = true
|
||||
litellm-core-utils.workspace = true
|
||||
litellm-router-types.workspace = true
|
||||
litellm-redis-types.workspace = true
|
||||
litellm-python-compat.workspace = true
|
||||
litellm-secret-value.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml_ng = "0.10.0"
|
||||
strum.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
veil.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
litellm-error.workspace = true
|
||||
rstest.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@ pub use error::Error;
|
|||
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, SecretSnapshot,
|
||||
environment_has_target,
|
||||
};
|
||||
pub use redis::{CacheSettings, RedisSettings};
|
||||
pub use settings::{
|
||||
ClickHouseStoreSettings, GeneralSettings, LiteLlmSettings, RouterSettings, TracingSettings,
|
||||
TracingStoreSettings,
|
||||
|
|
|
|||
|
|
@ -1,40 +1,11 @@
|
|||
use std::{collections::BTreeMap, time::Duration};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_redis_types::{Auth, HealthCheck, ProviderHandle, Resolution, StaticAuth, Target};
|
||||
use serde::{Deserialize, Deserializer, de::Error as _};
|
||||
use veil::Redact;
|
||||
|
||||
use crate::{Object, Value};
|
||||
|
||||
mod auth;
|
||||
mod endpoint;
|
||||
mod error;
|
||||
mod keys;
|
||||
mod layers;
|
||||
mod options;
|
||||
mod secrets;
|
||||
mod tls;
|
||||
|
||||
pub use error::{Expected, RedisError};
|
||||
pub use secrets::SecretSnapshot;
|
||||
|
||||
use options::Options;
|
||||
|
||||
const CACHE_ONLY: &[&str] = &[
|
||||
"type",
|
||||
"mode",
|
||||
"ttl",
|
||||
"default_in_memory_ttl",
|
||||
"default_in_redis_ttl",
|
||||
"supported_call_types",
|
||||
"similarity_threshold",
|
||||
"max_messages",
|
||||
"_backend",
|
||||
];
|
||||
|
||||
/// A Redis section as `config.yaml` spells it. Each surface reads it its own way, so it resolves
|
||||
/// only through [`RedisSettings::coordination`] or [`RedisSettings::cache_params`].
|
||||
/// A Redis section as `config.yaml` spells it, before any surface reads it.
|
||||
#[derive(Clone, Default, Deserialize, Redact)]
|
||||
#[serde(transparent)]
|
||||
pub struct RedisSettings {
|
||||
|
|
@ -42,6 +13,12 @@ pub struct RedisSettings {
|
|||
fields: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
impl RedisSettings {
|
||||
pub fn fields(&self) -> &BTreeMap<String, Value> {
|
||||
&self.fields
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CacheSettings {
|
||||
Redis(Box<RedisSettings>),
|
||||
|
|
@ -65,151 +42,6 @@ impl<'de> Deserialize<'de> for CacheSettings {
|
|||
}
|
||||
}
|
||||
|
||||
/// The redis-py kwargs one surface hands `_get_redis_client_logic`, before `REDIS_*` and secrets.
|
||||
#[derive(Clone, Redact)]
|
||||
pub struct ConfiguredRedis {
|
||||
#[redact(with = "[REDACTED]")]
|
||||
fields: BTreeMap<String, Value>,
|
||||
nulls: Nulls,
|
||||
credential_provider: Option<ProviderHandle>,
|
||||
}
|
||||
|
||||
/// Whether a null setting reaches redis-py as an explicit `None`, which outranks its `REDIS_*` value.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Nulls {
|
||||
/// `coordination_redis` reaches redis-py through `model_dump(exclude_none=True)`.
|
||||
Dropped,
|
||||
/// `Cache` drops a null only for the parameters it names and passes any other key on.
|
||||
KeptUnlessNamedByCache,
|
||||
/// `RedisCache.redis_kwargs` already holds only the nulls redis-py receives.
|
||||
Kept,
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn coordination(&self) -> Result<ConfiguredRedis, RedisError> {
|
||||
if let Some(port) = self.fields.get("port").and_then(options::as_text)
|
||||
&& !port.starts_with("os.environ/")
|
||||
&& port.parse::<u16>().is_err()
|
||||
{
|
||||
return Err(RedisError::Invalid {
|
||||
setting: "port".to_owned(),
|
||||
expected: Expected::Integer,
|
||||
});
|
||||
}
|
||||
Ok(ConfiguredRedis {
|
||||
fields: self.fields.clone(),
|
||||
nulls: Nulls::Dropped,
|
||||
credential_provider: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `litellm_settings.cache_params` with `type: redis`, as `litellm.Cache` hands it to `RedisCache`.
|
||||
pub fn cache_params(&self) -> ConfiguredRedis {
|
||||
let alias = self
|
||||
.fields
|
||||
.get("redis_startup_nodes")
|
||||
.filter(|_| !self.fields.contains_key("startup_nodes"))
|
||||
.map(|nodes| ("startup_nodes".to_owned(), nodes.clone()));
|
||||
let fields = self
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
key.as_str() != "redis_startup_nodes" && !CACHE_ONLY.contains(&key.as_str())
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.chain(alias)
|
||||
.collect();
|
||||
ConfiguredRedis {
|
||||
fields,
|
||||
nulls: Nulls::KeptUnlessNamedByCache,
|
||||
credential_provider: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfiguredRedis {
|
||||
/// `RedisCache.redis_kwargs`, the dict every Python surface ends in and the one python-bridge
|
||||
/// hands over.
|
||||
pub fn redis_cache_kwargs(fields: BTreeMap<String, Value>) -> Self {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// No configured settings, so only `REDIS_*` and the other environment fallbacks apply.
|
||||
pub fn environment_only() -> Self {
|
||||
Self {
|
||||
fields: BTreeMap::new(),
|
||||
nulls: Nulls::Dropped,
|
||||
credential_provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(
|
||||
&self,
|
||||
secrets: &SecretSnapshot,
|
||||
environment: &dyn Lookup,
|
||||
) -> Result<Resolution, RedisError> {
|
||||
let kwargs = layers::merged(self, secrets);
|
||||
let (options, unmodeled) = Options::read(kwargs, secrets)?;
|
||||
let auth = auth::redis_auth(&options, self.credential_provider, environment)?;
|
||||
let (target, url_ignored) = 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 {
|
||||
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())?,
|
||||
health_check: health_check(options.health_check_interval.as_ref())?,
|
||||
passthrough: unmodeled.passthrough,
|
||||
host_objects: unmodeled.host_objects,
|
||||
ignored: unmodeled.ignored.into_iter().chain(url_ignored).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// redis-py checks health only when the interval is truthy, so zero turns it off and a negative interval checks
|
||||
// before every command
|
||||
fn health_check(entry: Option<&layers::Entry>) -> Result<HealthCheck, RedisError> {
|
||||
Ok(match options::integer::<i64>(entry)? {
|
||||
None => HealthCheck::Unset,
|
||||
Some(0) => HealthCheck::Disabled,
|
||||
Some(seconds) => HealthCheck::Every(Duration::from_secs(seconds.try_into().unwrap_or(0))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn environment_has_target(lookup: &dyn Lookup) -> bool {
|
||||
[
|
||||
"REDIS_HOST",
|
||||
"REDIS_URL",
|
||||
"REDIS_CLUSTER_NODES",
|
||||
"REDIS_SENTINEL_NODES",
|
||||
]
|
||||
.iter()
|
||||
.any(|name| lookup.get(name).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RedisSettings;
|
||||
|
|
@ -219,18 +51,12 @@ mod tests {
|
|||
#[case::scalar(serde_json::json!("credential-value"))]
|
||||
#[case::object(serde_json::json!({"secret": "credential-value"}))]
|
||||
#[case::array(serde_json::json!(["credential-value"]))]
|
||||
fn credentials_are_redacted(
|
||||
#[case] password: serde_json::Value,
|
||||
#[values(false, true)] configured: bool,
|
||||
) {
|
||||
fn credentials_are_redacted(#[case] password: serde_json::Value) {
|
||||
let settings: RedisSettings = serde_json::from_value(serde_json::json!({
|
||||
"host": "redis", "password": password,
|
||||
}))
|
||||
.unwrap();
|
||||
let debug = match configured {
|
||||
false => format!("{settings:?}"),
|
||||
true => format!("{:?}", settings.cache_params()),
|
||||
};
|
||||
let debug = format!("{settings:?}");
|
||||
assert!(!debug.contains("credential-value"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
use litellm_core_utils::settings::Lookup;
|
||||
|
||||
use super::{ConfiguredRedis, Nulls, keys, options::Kwargs};
|
||||
use crate::Value;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Entry {
|
||||
pub(super) value: Value,
|
||||
pub(super) source: String,
|
||||
}
|
||||
|
||||
const NAMED_BY_CACHE: &[&str] = &[
|
||||
"host",
|
||||
"port",
|
||||
"password",
|
||||
"socket_timeout",
|
||||
"startup_nodes",
|
||||
"namespace",
|
||||
"gcp_service_account",
|
||||
"gcp_ssl_ca_certs",
|
||||
];
|
||||
|
||||
/// The configured settings over their `REDIS_*` values, `{**environment_kwargs, **env_overrides}`
|
||||
/// in `_get_redis_client_logic`. A null left in the result is an explicit `None` that hid its
|
||||
/// environment value.
|
||||
pub(super) fn merged(configured: &ConfiguredRedis, lookup: &dyn Lookup) -> Kwargs {
|
||||
let block: Kwargs = configured
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(key, value)| (key, environment_reference(value, lookup)))
|
||||
.filter(|(key, value)| !value.is_null() || keeps_null(configured.nulls, key))
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.clone(),
|
||||
Entry {
|
||||
value,
|
||||
source: key.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let names_a_target = ["host", "startup_nodes", "sentinel_nodes"]
|
||||
.iter()
|
||||
.any(|key| is_set(&block, key));
|
||||
let drop_environment_url = names_a_target && !is_set(&block, "url");
|
||||
environment(lookup)
|
||||
.filter(|(key, _)| !(key == "url" && drop_environment_url))
|
||||
.chain(block)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keeps_null(nulls: Nulls, key: &str) -> bool {
|
||||
match nulls {
|
||||
Nulls::Dropped => false,
|
||||
Nulls::KeptUnlessNamedByCache => !NAMED_BY_CACHE.contains(&key),
|
||||
Nulls::Kept => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn environment(lookup: &dyn Lookup) -> impl Iterator<Item = (String, Entry)> + '_ {
|
||||
keys::SETTINGS.iter().filter_map(|setting| {
|
||||
let source = setting.environment_name()?;
|
||||
let value = lookup.get(&source)?;
|
||||
Some((
|
||||
setting.kwarg.to_owned(),
|
||||
Entry {
|
||||
value: Value::String(value),
|
||||
source,
|
||||
},
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// load_config resolves os.environ/ to None when unset, so a missing reference is a null like any other
|
||||
fn environment_reference(value: &Value, lookup: &dyn Lookup) -> Value {
|
||||
match value {
|
||||
Value::String(text) => match text.strip_prefix("os.environ/") {
|
||||
Some(name) => lookup.get(name).map_or(Value::Null, Value::String),
|
||||
None => value.clone(),
|
||||
},
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_set(kwargs: &Kwargs, key: &str) -> bool {
|
||||
kwargs.get(key).is_some_and(|entry| !entry.value.is_null())
|
||||
}
|
||||
|
|
@ -440,3 +440,29 @@ fn mcp_config_redacts_nested_credentials_and_preserves_policy_for_validation() {
|
|||
fn rejects_invalid_typed_mcp_settings(#[case] setting: &str) {
|
||||
assert!(Config::from_yaml(&format!("mcp_servers:\n docs:\n {setting}\n")).is_err());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn inactive_redis_cache_keeps_malformed_known_inputs_deferred() {
|
||||
let config = Config::from_yaml(
|
||||
"litellm_settings:\n cache: false\n cache_params:\n type: redis\n host: []\n port: invalid\n socket_timeout: invalid",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
config.litellm_settings.cache_params,
|
||||
Some(litellm_config::CacheSettings::Redis(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn non_redis_cache_settings_remain_opaque() {
|
||||
let config = Config::from_yaml(
|
||||
"litellm_settings:\n cache_params:\n type: local\n custom_option:\n nested: true",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
config.litellm_settings.cache_params,
|
||||
Some(litellm_config::CacheSettings::Other(_))
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@
|
|||
- Test mounting and server lifecycle through public HTTP behavior. Test endpoint semantics in the owning crate
|
||||
|
||||
## Redis
|
||||
- `build_redis` builds the Redis clients once at boot (`src/redis.rs`). Components get a clone as a field, never a per-request extractor or a pool. redis-rs multiplexes one connection per node, so there is no connection pool and pool settings like `max_connections` are accepted and ignored
|
||||
- `build_redis` builds the Redis clients once at boot (`src/redis/mod.rs`). Components get a clone as a field, never a per-request extractor or a pool. redis-rs multiplexes one connection per node, so there is no connection pool and pool settings like `max_connections` are accepted and ignored
|
||||
- Which Redis backs which role mirrors the proxy: coordination is `general_settings.coordination_redis`, else the `cache_params` Redis when `cache: true` and `type: redis`, else `REDIS_*` env. The transaction buffer reuses coordination, else env when `use_redis_transaction_buffer` is on. The response cache gets its own client so large values never queue in front of coordination commands
|
||||
- `litellm-config` follows `litellm/_redis.py`'s merge of config blocks and `REDIS_*` env. A redis-py setting we can't honor (sentinel, IAM, custom TLS files, ...) fails boot with its name, never silently dropped
|
||||
- `src/redis/layers.rs` is the Rust copy of `_merge_redis_kwargs` in `litellm/_redis.py`, which only this host needs because the Python host merges in Python. It puts the configured kwargs over their `REDIS_*` values and fills the `get_secret` fallbacks from the `SETTINGS` rows in `redis-resolve`, under the same conditions Python reads them, then hands the result to `litellm_redis_resolve::interpret`. Values come from a `SecretSnapshot` filled once at boot, read like `get_secret` (secret manager, then environment, with `true` and `false` read as bools), and the only straight environment read is Azure's `REDIS_USERNAME`
|
||||
- A surface is a `ConfiguredRedis` constructor: `coordination` for `general_settings.coordination_redis`, which rejects a malformed `port` like the proxy's `CoordinationRedisParams` pydantic model, `cache_params` for `litellm_settings.cache_params`, which drops the cache-only keys and reads the `redis_startup_nodes` alias, and `redis_cache_kwargs` for a `RedisCache.redis_kwargs` dict in the contract tests. A new surface is a new constructor, never a flag
|
||||
- Nulls depend on the surface. `coordination_redis` drops every null like `model_dump(exclude_none=True)`, `cache_params` keeps a null as an explicit `None` that hides the `REDIS_*` value unless `Cache` or `RedisCache` names that parameter, and `RedisCache.redis_kwargs` keeps every null. A missing `os.environ/` reference is a null, as `load_config` resolves it
|
||||
- A setting the Rust client cannot honor warns and is dropped, and only a security check it cannot enforce fails boot, per `redis-resolve/AGENTS.md`
|
||||
- Boot never waits for a configured Redis (clients connect lazily). Only the env-only coordination fallback is pinged, for 2s, and treated as absent when unreachable, as in Python
|
||||
- `/health/readiness` never touches Redis, matching the proxy
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ litellm-error.workspace = true
|
|||
litellm-circuit-breaker.workspace = true
|
||||
litellm-redis-client.workspace = true
|
||||
litellm-redis-connect.workspace = true
|
||||
litellm-redis-resolve.workspace = true
|
||||
litellm-redis-types.workspace = true
|
||||
rustls.workspace = true
|
||||
litellm-http.workspace = true
|
||||
|
|
@ -35,6 +36,7 @@ serde.workspace = true
|
|||
tracing.workspace = true
|
||||
tokio = { workspace = true, features = ["signal"] }
|
||||
uuid.workspace = true
|
||||
veil.workspace = true
|
||||
tower-sessions-moka-store = "0.15.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
Config(#[from] litellm_config::Error),
|
||||
#[error(transparent)]
|
||||
Redis(#[from] litellm_config::RedisError),
|
||||
Redis(#[from] litellm_redis_resolve::RedisError),
|
||||
#[error(transparent)]
|
||||
RedisConnect(#[from] litellm_redis_connect::Error),
|
||||
#[error("MCP requires working gateway authentication")]
|
||||
|
|
|
|||
150
litellm-rust/crates/gateway/src/redis/configured.rs
Normal file
150
litellm-rust/crates/gateway/src/redis/configured.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use litellm_config::{RedisSettings, Value};
|
||||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_redis_resolve::{Expected, Kwargs, RedisError};
|
||||
use litellm_redis_types::{Auth, AzureAdAuth, ProviderHandle, Resolution};
|
||||
use veil::Redact;
|
||||
|
||||
use super::{layers, secrets::SecretSnapshot};
|
||||
|
||||
const CACHE_ONLY: &[&str] = &[
|
||||
"type",
|
||||
"mode",
|
||||
"ttl",
|
||||
"default_in_memory_ttl",
|
||||
"default_in_redis_ttl",
|
||||
"supported_call_types",
|
||||
"similarity_threshold",
|
||||
"max_messages",
|
||||
"_backend",
|
||||
];
|
||||
|
||||
/// The redis-py kwargs one surface hands `_get_redis_client_logic`, before `REDIS_*` and secrets.
|
||||
#[derive(Clone, Redact)]
|
||||
pub(crate) struct ConfiguredRedis {
|
||||
#[redact(with = "[REDACTED]")]
|
||||
pub(super) fields: BTreeMap<String, Value>,
|
||||
pub(super) nulls: Nulls,
|
||||
pub(super) credential_provider: Option<ProviderHandle>,
|
||||
}
|
||||
|
||||
/// Whether a null setting reaches redis-py as an explicit `None`, which outranks its `REDIS_*` value.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum Nulls {
|
||||
/// `coordination_redis` reaches redis-py through `model_dump(exclude_none=True)`.
|
||||
Dropped,
|
||||
/// `Cache` drops a null only for the parameters it names and passes any other key on.
|
||||
KeptUnlessNamedByCache,
|
||||
/// `RedisCache.redis_kwargs` already holds only the nulls redis-py receives.
|
||||
#[cfg(test)]
|
||||
Kept,
|
||||
}
|
||||
|
||||
impl ConfiguredRedis {
|
||||
/// `general_settings.coordination_redis`, which the proxy validates as a pydantic model first.
|
||||
/// An `os.environ/` port is only known after merging, where it is read like any other port.
|
||||
pub(crate) fn coordination(settings: &RedisSettings) -> Result<Self, RedisError> {
|
||||
if let Some(port) = settings.fields().get("port").and_then(text)
|
||||
&& !port.starts_with("os.environ/")
|
||||
&& port.parse::<u16>().is_err()
|
||||
{
|
||||
return Err(RedisError::Invalid {
|
||||
setting: "port".to_owned(),
|
||||
expected: Expected::Integer,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
fields: settings.fields().clone(),
|
||||
nulls: Nulls::Dropped,
|
||||
credential_provider: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `litellm_settings.cache_params` with `type: redis`, as `litellm.Cache` hands it to `RedisCache`.
|
||||
pub(crate) fn cache_params(settings: &RedisSettings) -> Self {
|
||||
let fields = settings.fields();
|
||||
let alias = fields
|
||||
.get("redis_startup_nodes")
|
||||
.filter(|_| !fields.contains_key("startup_nodes"))
|
||||
.map(|nodes| ("startup_nodes".to_owned(), nodes.clone()));
|
||||
let fields = fields
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
key.as_str() != "redis_startup_nodes" && !CACHE_ONLY.contains(&key.as_str())
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.chain(alias)
|
||||
.collect();
|
||||
Self {
|
||||
fields,
|
||||
nulls: Nulls::KeptUnlessNamedByCache,
|
||||
credential_provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `RedisCache.redis_kwargs`, the dict every Python surface ends in.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn redis_cache_kwargs(fields: BTreeMap<String, Value>) -> Self {
|
||||
Self {
|
||||
fields,
|
||||
nulls: Nulls::Kept,
|
||||
credential_provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The handle the host holds for the `credential_provider` object in these settings.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_credential_provider(self, handle: ProviderHandle) -> Self {
|
||||
Self {
|
||||
credential_provider: Some(handle),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// No configured settings, so only `REDIS_*` and the other environment fallbacks apply.
|
||||
pub(crate) fn environment_only() -> Self {
|
||||
Self {
|
||||
fields: BTreeMap::new(),
|
||||
nulls: Nulls::Dropped,
|
||||
credential_provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `_merge_redis_kwargs` in `litellm/_redis.py`: the kwargs over their `REDIS_*` values with every
|
||||
/// `get_secret` fallback filled.
|
||||
pub(crate) fn merged(&self, secrets: &SecretSnapshot) -> Result<Kwargs, RedisError> {
|
||||
layers::merged(self, secrets)
|
||||
}
|
||||
|
||||
/// The environment is only for what Python reads straight from `os.environ`, `REDIS_USERNAME` for Azure.
|
||||
pub(crate) fn resolve(
|
||||
&self,
|
||||
secrets: &SecretSnapshot,
|
||||
environment: &dyn Lookup,
|
||||
) -> Result<Resolution, RedisError> {
|
||||
let resolution =
|
||||
litellm_redis_resolve::interpret(&self.merged(secrets)?, self.credential_provider)?;
|
||||
Ok(match resolution.auth {
|
||||
Auth::AzureAd(azure) => Resolution {
|
||||
auth: Auth::AzureAd(AzureAdAuth {
|
||||
username: environment
|
||||
.get("REDIS_USERNAME")
|
||||
.filter(|username| !username.is_empty()),
|
||||
..azure
|
||||
}),
|
||||
..resolution
|
||||
},
|
||||
_ => resolution,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn text(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Bool(flag) => Some(flag.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
228
litellm-rust/crates/gateway/src/redis/layers.rs
Normal file
228
litellm-rust/crates/gateway/src/redis/layers.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
use litellm_config::Value;
|
||||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_redis_resolve::{Entry, Kwargs, RedisError, SETTINGS, is_present, setting};
|
||||
|
||||
use super::configured::{ConfiguredRedis, Nulls};
|
||||
|
||||
const NAMED_BY_CACHE: &[&str] = &[
|
||||
"host",
|
||||
"port",
|
||||
"password",
|
||||
"socket_timeout",
|
||||
"startup_nodes",
|
||||
"namespace",
|
||||
"gcp_service_account",
|
||||
"gcp_ssl_ca_certs",
|
||||
];
|
||||
|
||||
/// How `_merge_redis_kwargs` reads a fallback: `get_secret` turns `true` and `false` into a bool, and
|
||||
/// `get_secret_str` treats that bool as unset.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Read {
|
||||
Secret,
|
||||
SecretStr,
|
||||
}
|
||||
|
||||
const TARGETS: &[(&str, Read)] = &[
|
||||
("startup_nodes", Read::Secret),
|
||||
("sentinel_nodes", Read::Secret),
|
||||
("sentinel_password", Read::SecretStr),
|
||||
("service_name", Read::Secret),
|
||||
];
|
||||
|
||||
const IDENTITIES: &[(&str, Read)] = &[
|
||||
("gcp_service_account", Read::SecretStr),
|
||||
("gcp_ssl_ca_certs", Read::SecretStr),
|
||||
("azure_redis_ad_token", Read::Secret),
|
||||
];
|
||||
|
||||
const AZURE: &[(&str, Read)] = &[
|
||||
("azure_client_id", Read::SecretStr),
|
||||
("azure_tenant_id", Read::SecretStr),
|
||||
("azure_client_secret", Read::SecretStr),
|
||||
];
|
||||
|
||||
const AWS: &[(&str, Read)] = &[("aws_iam_region", Read::SecretStr)];
|
||||
|
||||
/// The configured settings over their `REDIS_*` values, `{**environment_kwargs, **env_overrides}`
|
||||
/// in `_merge_redis_kwargs`, with the `get_secret` fallbacks it reads filled in. A null left in the result is
|
||||
/// an explicit `None` that hid its environment value.
|
||||
pub(super) fn merged(
|
||||
configured: &ConfiguredRedis,
|
||||
lookup: &dyn Lookup,
|
||||
) -> Result<Kwargs, RedisError> {
|
||||
let block: Kwargs = configured
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(key, value)| (key, environment_reference(value, lookup)))
|
||||
.filter(|(key, value)| !value.is_null() || keeps_null(configured.nulls, key))
|
||||
.map(|(key, value)| {
|
||||
let value = serde_json::to_value(&value).map_err(|_| RedisError::NotPlainData {
|
||||
setting: key.clone(),
|
||||
})?;
|
||||
let entry = Entry {
|
||||
value,
|
||||
source: key.clone(),
|
||||
};
|
||||
Ok((key.clone(), entry))
|
||||
})
|
||||
.collect::<Result<_, RedisError>>()?;
|
||||
let names_a_target = ["host", "startup_nodes", "sentinel_nodes"]
|
||||
.iter()
|
||||
.any(|key| is_set(&block, key));
|
||||
let drop_environment_url = names_a_target && !is_set(&block, "url");
|
||||
let layered: Kwargs = environment(lookup)
|
||||
.filter(|(key, _)| !(key == "url" && drop_environment_url))
|
||||
.chain(block)
|
||||
.collect();
|
||||
let fallbacks = fallbacks(&layered, configured.credential_provider.is_some(), lookup);
|
||||
Ok(layered.into_iter().chain(fallbacks).collect())
|
||||
}
|
||||
|
||||
// The fallbacks are read under the same conditions as _merge_redis_kwargs, so a secret Python never asks
|
||||
// for is never filled in
|
||||
fn fallbacks(layered: &Kwargs, has_provider: bool, lookup: &dyn Lookup) -> Kwargs {
|
||||
let fill = |kwargs: &[(&str, Read)]| -> Kwargs {
|
||||
kwargs
|
||||
.iter()
|
||||
.filter_map(|(kwarg, read)| fallback(layered, kwarg, *read, lookup))
|
||||
.collect()
|
||||
};
|
||||
let targets = fill(TARGETS);
|
||||
if has_provider || is_set(layered, "credential_provider") {
|
||||
return targets;
|
||||
}
|
||||
let identities = fill(IDENTITIES);
|
||||
let value = |kwarg: &str| {
|
||||
layered
|
||||
.get(kwarg)
|
||||
.filter(|entry| is_present(&entry.value))
|
||||
.or_else(|| identities.get(kwarg))
|
||||
.map(|entry| &entry.value)
|
||||
};
|
||||
let gcp = value("gcp_service_account").is_some();
|
||||
let azure = value("azure_redis_ad_token")
|
||||
.is_some_and(|token| text(token).is_some_and(|token| token.eq_ignore_ascii_case("true")));
|
||||
let aws = layered
|
||||
.get("aws_iam_auth")
|
||||
.is_some_and(|entry| coerces_to_true(&entry.value));
|
||||
let azure_settings = match azure && !gcp {
|
||||
true => fill(AZURE),
|
||||
false => Kwargs::new(),
|
||||
};
|
||||
let aws_settings = match aws && !gcp && !azure {
|
||||
true => fill(AWS),
|
||||
false => Kwargs::new(),
|
||||
};
|
||||
targets
|
||||
.into_iter()
|
||||
.chain(identities)
|
||||
.chain(azure_settings)
|
||||
.chain(aws_settings)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fallback(
|
||||
layered: &Kwargs,
|
||||
kwarg: &str,
|
||||
read: Read,
|
||||
lookup: &dyn Lookup,
|
||||
) -> Option<(String, Entry)> {
|
||||
if layered
|
||||
.get(kwarg)
|
||||
.is_some_and(|entry| is_present(&entry.value))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let names = setting(kwarg).map_or(&[][..], |setting| setting.fallback);
|
||||
names.iter().find_map(|name| {
|
||||
let value = lookup
|
||||
.get(name)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(secret)?;
|
||||
match (read, &value) {
|
||||
(Read::SecretStr, serde_json::Value::Bool(_)) => None,
|
||||
_ => {
|
||||
let entry = Entry {
|
||||
value,
|
||||
source: (*name).to_owned(),
|
||||
};
|
||||
Some((kwarg.to_owned(), entry))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// get_secret reads an environment value of true or false, in any case and padding, as a bool
|
||||
fn secret_flag(value: &str) -> Option<bool> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn secret(value: String) -> serde_json::Value {
|
||||
secret_flag(&value).map_or(serde_json::Value::String(value), serde_json::Value::Bool)
|
||||
}
|
||||
|
||||
// _coerces_to_true: text is read like _str_to_bool, anything else by its truthiness
|
||||
fn coerces_to_true(value: &serde_json::Value) -> bool {
|
||||
match value {
|
||||
serde_json::Value::String(text) => {
|
||||
matches!(text.to_ascii_lowercase().as_str(), "true" | "1" | "yes")
|
||||
}
|
||||
serde_json::Value::Null => false,
|
||||
serde_json::Value::Bool(flag) => *flag,
|
||||
serde_json::Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
|
||||
serde_json::Value::Array(items) => !items.is_empty(),
|
||||
serde_json::Value::Object(entries) => !entries.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: &serde_json::Value) -> Option<String> {
|
||||
match value {
|
||||
serde_json::Value::String(text) => Some(text.clone()),
|
||||
serde_json::Value::Number(number) => Some(number.to_string()),
|
||||
serde_json::Value::Bool(flag) => Some(flag.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn keeps_null(nulls: Nulls, key: &str) -> bool {
|
||||
match nulls {
|
||||
Nulls::Dropped => false,
|
||||
Nulls::KeptUnlessNamedByCache => !NAMED_BY_CACHE.contains(&key),
|
||||
#[cfg(test)]
|
||||
Nulls::Kept => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn environment(lookup: &dyn Lookup) -> impl Iterator<Item = (String, Entry)> + '_ {
|
||||
SETTINGS.iter().filter_map(|setting| {
|
||||
let source = setting.environment_name()?;
|
||||
let value = lookup.get(&source)?;
|
||||
let entry = Entry {
|
||||
value: secret(value),
|
||||
source,
|
||||
};
|
||||
Some((setting.kwarg.to_owned(), entry))
|
||||
})
|
||||
}
|
||||
|
||||
// load_config resolves os.environ/ to None when unset, so a missing reference is a null like any other
|
||||
fn environment_reference(value: &Value, lookup: &dyn Lookup) -> Value {
|
||||
match value {
|
||||
Value::String(text) => match text.strip_prefix("os.environ/") {
|
||||
Some(name) => lookup.get(name).map_or(Value::Null, |value| {
|
||||
secret_flag(&value).map_or(Value::String(value), Value::Bool)
|
||||
}),
|
||||
None => value.clone(),
|
||||
},
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_set(kwargs: &Kwargs, key: &str) -> bool {
|
||||
kwargs.get(key).is_some_and(|entry| !entry.value.is_null())
|
||||
}
|
||||
|
|
@ -1,16 +1,23 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use litellm_circuit_breaker::{Breaker, Config as BreakerConfig, State};
|
||||
use litellm_config::{
|
||||
CacheSettings, Config, ConfiguredRedis, Expected, RedisError, RedisSettings, SecretSnapshot,
|
||||
Value, environment_has_target,
|
||||
};
|
||||
use litellm_config::{CacheSettings, Config, RedisSettings, Value};
|
||||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_redis_client::RedisClient;
|
||||
use litellm_redis_resolve::{Expected, RedisError};
|
||||
use litellm_redis_types::Resolution;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
mod configured;
|
||||
mod layers;
|
||||
mod secrets;
|
||||
#[cfg(test)]
|
||||
mod surface_tests;
|
||||
|
||||
use configured::ConfiguredRedis;
|
||||
use secrets::SecretSnapshot;
|
||||
|
||||
const ENVIRONMENT_PING_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
type Params = Resolution;
|
||||
|
||||
|
|
@ -35,11 +42,11 @@ enum Target {
|
|||
|
||||
pub(crate) fn plan(config: &Config, lookup: &dyn Lookup) -> Result<Plan, Error> {
|
||||
let response_cache = redis_cache_params(config)
|
||||
.map(|settings| resolve(&settings.cache_params(), lookup))
|
||||
.map(|settings| resolve(&ConfiguredRedis::cache_params(settings), lookup))
|
||||
.transpose()?;
|
||||
let coordination = match &config.general_settings.coordination_redis {
|
||||
Some(settings) => Some(Target::Configured(resolve(
|
||||
&settings.coordination()?,
|
||||
&ConfiguredRedis::coordination(settings)?,
|
||||
lookup,
|
||||
)?)),
|
||||
None => match (&response_cache, config.litellm_settings.cache == Some(true)) {
|
||||
|
|
@ -126,6 +133,17 @@ fn environment(lookup: &dyn Lookup) -> Result<Option<Params>, Error> {
|
|||
}
|
||||
}
|
||||
|
||||
fn environment_has_target(lookup: &dyn Lookup) -> bool {
|
||||
[
|
||||
"REDIS_HOST",
|
||||
"REDIS_URL",
|
||||
"REDIS_CLUSTER_NODES",
|
||||
"REDIS_SENTINEL_NODES",
|
||||
]
|
||||
.iter()
|
||||
.any(|name| lookup.get(name).is_some())
|
||||
}
|
||||
|
||||
fn optional_environment(lookup: &dyn Lookup) -> Option<Params> {
|
||||
match environment(lookup) {
|
||||
Ok(params) => params,
|
||||
|
|
@ -244,8 +262,9 @@ fn environment_value<T>(
|
|||
mod tests {
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use litellm_config::{Config, RedisError};
|
||||
use litellm_config::Config;
|
||||
use litellm_redis_client::{Standalone, StaticAuth};
|
||||
use litellm_redis_resolve::RedisError;
|
||||
use litellm_redis_testing::RedisServer;
|
||||
use litellm_redis_types::{Auth, HealthCheck, Passthrough};
|
||||
use rstest::rstest;
|
||||
|
|
@ -3,17 +3,16 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_secret_value::SecretValue;
|
||||
|
||||
use super::{ConfiguredRedis, Value, keys::SETTINGS};
|
||||
use litellm_config::Value;
|
||||
use litellm_redis_resolve::SETTINGS;
|
||||
|
||||
use super::configured::ConfiguredRedis;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SecretSnapshot(BTreeMap<String, Option<SecretValue>>);
|
||||
pub(crate) struct SecretSnapshot(BTreeMap<String, Option<SecretValue>>);
|
||||
|
||||
impl SecretSnapshot {
|
||||
pub fn new(values: BTreeMap<String, Option<SecretValue>>) -> Self {
|
||||
Self(values)
|
||||
}
|
||||
|
||||
pub fn read(names: BTreeSet<String>, lookup: &dyn Lookup) -> Self {
|
||||
pub(crate) fn read(names: BTreeSet<String>, lookup: &dyn Lookup) -> Self {
|
||||
Self(
|
||||
names
|
||||
.into_iter()
|
||||
|
|
@ -46,7 +45,7 @@ impl Lookup for SecretSnapshot {
|
|||
}
|
||||
|
||||
impl ConfiguredRedis {
|
||||
pub fn secret_names(&self) -> BTreeSet<String> {
|
||||
pub(crate) fn secret_names(&self) -> BTreeSet<String> {
|
||||
let references = self.fields.values().filter_map(|value| match value {
|
||||
Value::String(text) => text.strip_prefix("os.environ/").map(str::to_owned),
|
||||
_ => None,
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use litellm_config::{
|
||||
CacheSettings, Config, ConfiguredRedis, Expected, RedisError, SecretSnapshot, Value,
|
||||
};
|
||||
use litellm_config::{CacheSettings, Config, Value};
|
||||
use litellm_redis_resolve::{Expected, RedisError};
|
||||
use litellm_redis_types::{
|
||||
Auth, AwsIamAuth, AzureAdAuth, HealthCheck, Node, ProviderHandle, Resolution, StaticAuth,
|
||||
Target, TlsOptions,
|
||||
Auth, AwsIamAuth, AzureAdAuth, Node, ProviderHandle, Resolution, StaticAuth, Target, TlsOptions,
|
||||
};
|
||||
use litellm_secret_value::SecretValue;
|
||||
use rstest::rstest;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{configured::ConfiguredRedis, secrets::SecretSnapshot};
|
||||
|
||||
fn resolution(
|
||||
configured: &ConfiguredRedis,
|
||||
environment: &HashMap<String, String>,
|
||||
|
|
@ -22,28 +22,26 @@ fn resolution(
|
|||
)
|
||||
}
|
||||
|
||||
fn redis_cache_kwargs(kwargs: serde_json::Value) -> ConfiguredRedis {
|
||||
ConfiguredRedis::redis_cache_kwargs(
|
||||
serde_json::from_value::<BTreeMap<String, Value>>(kwargs).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
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 redis_cache_kwargs(kwargs: serde_json::Value) -> ConfiguredRedis {
|
||||
let has_provider = kwargs
|
||||
.get("credential_provider")
|
||||
.is_some_and(|provider| !provider.is_null());
|
||||
let configured = ConfiguredRedis::redis_cache_kwargs(
|
||||
serde_json::from_value::<BTreeMap<String, Value>>(kwargs).unwrap(),
|
||||
);
|
||||
match has_provider {
|
||||
true => configured.with_credential_provider(PROVIDER),
|
||||
false => configured,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_kwargs(
|
||||
kwargs: serde_json::Value,
|
||||
environment: &HashMap<String, String>,
|
||||
) -> Result<Resolution, RedisError> {
|
||||
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)
|
||||
resolution(&redis_cache_kwargs(kwargs), environment)
|
||||
}
|
||||
|
||||
fn coordination_resolution(
|
||||
|
|
@ -58,8 +56,8 @@ fn coordination_resolution(
|
|||
let configured = config
|
||||
.general_settings
|
||||
.coordination_redis
|
||||
.unwrap_or_default()
|
||||
.coordination()?;
|
||||
.unwrap_or_default();
|
||||
let configured = ConfiguredRedis::coordination(&configured)?;
|
||||
resolution(&configured, &environment)
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +78,7 @@ fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result<Resoluti
|
|||
};
|
||||
let config = Config::from_yaml(yaml).unwrap();
|
||||
let configured = match config.litellm_settings.cache_params.unwrap() {
|
||||
CacheSettings::Redis(settings) => settings.cache_params(),
|
||||
CacheSettings::Redis(settings) => ConfiguredRedis::cache_params(&settings),
|
||||
CacheSettings::Other(_) => panic!("expected a Redis cache"),
|
||||
};
|
||||
configured.resolve(
|
||||
|
|
@ -311,19 +309,6 @@ fn auth_follows_python_precedence(
|
|||
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",
|
||||
|
|
@ -398,22 +383,6 @@ fn tls_options_resolve_like_redis_py(
|
|||
assert!(matches!(resolved.target, Target::Standalone(host) if host.tls == expected));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_rediss_url_reads_ssl_password_from_its_query() {
|
||||
let resolved = resolve_kwargs(
|
||||
json!({"url": "rediss://cache:6380/0?ssl_password=query-key-secret"}),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let Target::Url(parsed) = resolved.target else {
|
||||
panic!("expected a url target, got {:?}", resolved.target);
|
||||
};
|
||||
let key_password = parsed.tls.and_then(|tls| tls.key_password);
|
||||
assert_eq!(expose(key_password.as_ref()), Some("query-key-secret"));
|
||||
assert!(resolved.ignored.is_empty(), "{:?}", resolved.ignored);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn an_unknown_cert_requirement_fails() {
|
||||
let error = resolve(
|
||||
|
|
@ -684,7 +653,7 @@ fn debug_redacts_redis_secrets_and_url() {
|
|||
)
|
||||
.unwrap();
|
||||
let settings = config.general_settings.coordination_redis.unwrap();
|
||||
let configured = settings.coordination().unwrap();
|
||||
let configured = ConfiguredRedis::coordination(&settings).unwrap();
|
||||
let resolved = resolution(&configured, &HashMap::new()).unwrap();
|
||||
let debug = format!("{settings:?} {configured:?} {resolved:?}");
|
||||
|
||||
|
|
@ -695,32 +664,6 @@ fn debug_redacts_redis_secrets_and_url() {
|
|||
assert!(debug.contains("redacted") || debug.contains("REDACTED"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn inactive_redis_cache_keeps_malformed_known_inputs_deferred() {
|
||||
let config = Config::from_yaml(
|
||||
"litellm_settings:\n cache: false\n cache_params:\n type: redis\n host: []\n port: invalid\n socket_timeout: invalid",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
config.litellm_settings.cache_params,
|
||||
Some(litellm_config::CacheSettings::Redis(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn non_redis_cache_settings_remain_opaque() {
|
||||
let config = Config::from_yaml(
|
||||
"litellm_settings:\n cache_params:\n type: local\n custom_option:\n nested: true",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
config.litellm_settings.cache_params,
|
||||
Some(litellm_config::CacheSettings::Other(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn legacy_unknown_settings_do_not_change_resolution() {
|
||||
let resolved = resolve(
|
||||
|
|
@ -800,558 +743,6 @@ fn settings_rust_does_not_model_reach_the_host_instead_of_being_dropped(
|
|||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::read_on_a_plain_url(
|
||||
"redis://cache:6379/1?db=3&username=query-user&password=query-secret",
|
||||
Some(3),
|
||||
Some("query-user"),
|
||||
Some("query-secret")
|
||||
)]
|
||||
#[case::absent("rediss://cache:6380/1", None, None, None)]
|
||||
fn a_url_query_carries_db_and_credentials_like_parse_url(
|
||||
#[case] url: &str,
|
||||
#[case] db: Option<i64>,
|
||||
#[case] username: Option<&str>,
|
||||
#[case] password: Option<&str>,
|
||||
) {
|
||||
let resolved = resolve_kwargs(json!({"url": url}), &HashMap::new()).unwrap();
|
||||
|
||||
let Target::Url(parsed) = resolved.target else {
|
||||
panic!("expected a url target, got {:?}", resolved.target);
|
||||
};
|
||||
assert_eq!(parsed.db, db);
|
||||
assert_eq!(expose(parsed.username.as_ref()), username);
|
||||
assert_eq!(expose(parsed.password.as_ref()), password);
|
||||
assert!(resolved.ignored.is_empty(), "{:?}", resolved.ignored);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_url_query_db_that_is_not_an_integer_fails_like_parse_url() {
|
||||
let error = resolve_kwargs(
|
||||
json!({"url": "redis://cache:6379?db=first"}),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, RedisError::Invalid { setting, expected: Expected::Integer } if setting == "url ?db")
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::tls_1_2_is_already_met(json!("TLSv1_2"), false)]
|
||||
#[case::the_enum_name(json!("TLSVersion.TLSv1_2"), false)]
|
||||
#[case::the_enum_value(json!(771), false)]
|
||||
#[case::tls_1_3_is_not_enforced(json!("TLSv1_3"), true)]
|
||||
fn ssl_min_version_warns_only_when_rustls_cannot_meet_it(
|
||||
#[case] version: serde_json::Value,
|
||||
#[case] warns: bool,
|
||||
) {
|
||||
let resolved = resolve_kwargs(
|
||||
json!({"host": "redis", "ssl": true, "ssl_min_version": version}),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
!resolved.ignored.is_empty(),
|
||||
warns,
|
||||
"{:?}",
|
||||
resolved.ignored
|
||||
);
|
||||
}
|
||||
|
||||
fn tls_view(tls: Option<&TlsOptions>) -> serde_json::Value {
|
||||
tls.map_or(serde_json::Value::Null, |tls| {
|
||||
serde_json::json!({
|
||||
"ca_certs": tls.ca_certs,
|
||||
"ca_data": tls.ca_data,
|
||||
"ca_path": tls.ca_path,
|
||||
"certfile": tls.certfile,
|
||||
"keyfile": tls.keyfile,
|
||||
"verify_certificate": tls.verify_certificate,
|
||||
"check_hostname": tls.check_hostname,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn nodes_view(nodes: &[Node]) -> Vec<String> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| format!("{}:{}", node.host, node.port))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expose(value: Option<&litellm_secret_value::SecretValue>) -> Option<&str> {
|
||||
value.map(litellm_secret_value::SecretValue::expose)
|
||||
}
|
||||
|
||||
fn view(resolved: &Resolution) -> serde_json::Value {
|
||||
let target = match &resolved.target {
|
||||
Target::Unix(socket) => serde_json::json!({
|
||||
"mode": "unix", "path": socket.path, "tls": null,
|
||||
}),
|
||||
Target::Standalone(host) => serde_json::json!({
|
||||
"mode": "host",
|
||||
"host": host.host,
|
||||
"port": host.port,
|
||||
"db": host.db,
|
||||
"tls": tls_view(host.tls.as_ref()),
|
||||
}),
|
||||
Target::Url(url) => serde_json::json!({
|
||||
"mode": "url",
|
||||
"url": url.url.expose(),
|
||||
"tls": tls_view(url.tls.as_ref()),
|
||||
}),
|
||||
Target::Cluster(cluster) => serde_json::json!({
|
||||
"mode": "cluster",
|
||||
"nodes": nodes_view(&cluster.nodes),
|
||||
"tls": tls_view(cluster.tls.as_ref()),
|
||||
}),
|
||||
Target::Sentinel(sentinel) => serde_json::json!({
|
||||
"mode": "sentinel",
|
||||
"nodes": nodes_view(&sentinel.monitors),
|
||||
"service_name": sentinel.service_name,
|
||||
"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 {
|
||||
Auth::Static(StaticAuth { username, password }) => serde_json::json!({
|
||||
"kind": "static",
|
||||
"username": expose(username.as_ref()),
|
||||
"password": expose(password.as_ref()),
|
||||
}),
|
||||
Auth::Provider(_) => serde_json::json!({"kind": "host"}),
|
||||
Auth::GcpIam { service_account } => {
|
||||
serde_json::json!({"kind": "gcp_iam", "service_account": service_account})
|
||||
}
|
||||
Auth::AzureAd(azure) => {
|
||||
serde_json::json!({"kind": "azure_ad", "username": azure.username})
|
||||
}
|
||||
Auth::AwsIam(aws) => serde_json::json!({
|
||||
"kind": "aws_iam",
|
||||
"user_name": aws.user_name,
|
||||
"cache_name": aws.cache_name,
|
||||
"region": aws.region,
|
||||
"serverless": aws.serverless,
|
||||
}),
|
||||
};
|
||||
let serde_json::Value::Object(target) = target else {
|
||||
unreachable!("every target view is an object")
|
||||
};
|
||||
let rest = serde_json::json!({
|
||||
"auth": auth,
|
||||
"socket_timeout": resolved.timeout.map_or(5.0, |timeout| timeout.as_secs_f64()),
|
||||
"socket_connect_timeout": resolved.connect_timeout.or(resolved.timeout).map_or(5.0, |timeout| timeout.as_secs_f64()),
|
||||
"health_check_interval": match (&resolved.target, resolved.health_check) {
|
||||
(_, HealthCheck::Every(interval)) => Some(interval.as_secs_f64()),
|
||||
(Target::Cluster(_), HealthCheck::Unset) => Some(25.0),
|
||||
(_, HealthCheck::Unset | HealthCheck::Disabled) => None,
|
||||
},
|
||||
});
|
||||
let serde_json::Value::Object(rest) = rest else {
|
||||
unreachable!("the view is an object")
|
||||
};
|
||||
serde_json::Value::Object(target.into_iter().chain(rest).collect())
|
||||
}
|
||||
|
||||
fn expected_view(fields: serde_json::Value) -> serde_json::Value {
|
||||
let serde_json::Value::Object(fields) = fields else {
|
||||
unreachable!("expected views are objects")
|
||||
};
|
||||
let defaults = json!({
|
||||
"auth": {"kind": "static", "username": null, "password": null},
|
||||
"tls": null,
|
||||
"socket_timeout": 5.0,
|
||||
"socket_connect_timeout": 5.0,
|
||||
"health_check_interval": null,
|
||||
});
|
||||
let serde_json::Value::Object(defaults) = defaults else {
|
||||
unreachable!("the defaults are an object")
|
||||
};
|
||||
serde_json::Value::Object(defaults.into_iter().chain(fields).collect())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::host_defaults(
|
||||
json!({"host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::empty_cluster_nodes_use_the_environment(
|
||||
json!({"startup_nodes": []}),
|
||||
&[("REDIS_CLUSTER_NODES", r#"[{"host": "node-a", "port": 7000}]"#)],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::empty_sentinel_nodes_use_the_environment(
|
||||
json!({"sentinel_nodes": [], "service_name": "primary"}),
|
||||
&[("REDIS_SENTINEL_NODES", r#"[["sentinel-a", 26379]]"#)],
|
||||
Ok(expected_view(json!({"mode": "sentinel", "nodes": ["sentinel-a:26379"], "service_name": "primary", "sentinel_password": null, "db": 0}))),
|
||||
)]
|
||||
#[case::empty_service_name_uses_the_environment(
|
||||
json!({"sentinel_nodes": [["sentinel-a", 26379]], "service_name": ""}),
|
||||
&[("REDIS_SERVICE_NAME", "primary")],
|
||||
Ok(expected_view(json!({"mode": "sentinel", "nodes": ["sentinel-a:26379"], "service_name": "primary", "sentinel_password": null, "db": 0}))),
|
||||
)]
|
||||
#[case::plaintext_ignores_tls_requirements(
|
||||
json!({"host": "redis", "ssl_cert_reqs": "strict"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::an_ambiguous_flag_is_refused(
|
||||
json!({"host": "redis", "ssl": "on"}),
|
||||
&[],
|
||||
Err("invalid Redis setting ssl: expected a boolean"),
|
||||
)]
|
||||
#[case::empty_sentinel_nodes_without_the_environment_name_no_target(
|
||||
json!({"sentinel_nodes": [], "service_name": "primary"}),
|
||||
&[],
|
||||
Err("either host or url must be specified for redis"),
|
||||
)]
|
||||
#[case::ssl_ca_path_is_unsupported(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ca_path": "/ca"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": "/ca", "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::ssl_validate_ocsp_is_unsupported(
|
||||
json!({"host": "redis", "ssl": true, "ssl_validate_ocsp": true}),
|
||||
&[],
|
||||
Err("ssl_validate_ocsp asks for OCSP revocation checks, which rustls cannot perform, so Redis is not connected without them"),
|
||||
)]
|
||||
#[case::ssl_password_is_unsupported(
|
||||
json!({"host": "redis", "ssl": true, "ssl_certfile": "/client.pem", "ssl_password": "key-secret"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": "/client.pem", "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::host_with_every_basic_setting(
|
||||
json!({"db": 2, "host": "redis", "password": "pass", "port": 6380, "socket_timeout": 1.5, "ssl": true, "username": "user"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6380, "db": 2, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "static", "username": "user", "password": "pass"}, "socket_timeout": 1.5, "socket_connect_timeout": 1.5}))),
|
||||
)]
|
||||
#[case::environment_host_port_and_password(
|
||||
json!({}),
|
||||
&[("REDIS_HOST", "environment"), ("REDIS_PASSWORD", "environment-secret"), ("REDIS_PORT", "6390"), ("REDIS_SSL", "True")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "environment", "port": 6390, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "static", "username": null, "password": "environment-secret"}}))),
|
||||
)]
|
||||
#[case::string_numbers_are_coerced(
|
||||
json!({"db": "3", "host": "redis", "port": "6380", "socket_timeout": "2.5"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6380, "db": 3, "socket_timeout": 2.5, "socket_connect_timeout": 2.5}))),
|
||||
)]
|
||||
#[case::padded_and_underscored_numbers_parse_like_python_int_and_float(
|
||||
json!({"host": "redis", "port": "6380\n", "db": " 2 ", "socket_timeout": "1_0.5\n"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"db": 2, "host": "redis", "mode": "host", "port": 6380, "socket_timeout": 10.5, "socket_connect_timeout": 10.5}))),
|
||||
)]
|
||||
#[case::padded_environment_numbers_parse_like_python_int(
|
||||
json!({}),
|
||||
&[("REDIS_HOST", "redis"), ("REDIS_PORT", "6_380"), ("REDIS_DB", "3\n")],
|
||||
Ok(expected_view(json!({"db": 3, "host": "redis", "mode": "host", "port": 6380}))),
|
||||
)]
|
||||
#[case::socket_timeout_from_the_environment(
|
||||
json!({"host": "redis"}),
|
||||
&[("REDIS_SOCKET_TIMEOUT", "0.75")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "socket_timeout": 0.75, "socket_connect_timeout": 0.75}))),
|
||||
)]
|
||||
#[case::environment_url(
|
||||
json!({}),
|
||||
&[("REDIS_URL", "redis://environment:6379/1")],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "redis://environment:6379/1"}))),
|
||||
)]
|
||||
#[case::environment_url_beats_the_environment_host(
|
||||
json!({}),
|
||||
&[("REDIS_HOST", "ignored"), ("REDIS_URL", "redis://from-url:6379")],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "redis://from-url:6379"}))),
|
||||
)]
|
||||
#[case::an_explicit_host_drops_the_environment_url(
|
||||
json!({"host": "configured"}),
|
||||
&[("REDIS_URL", "redis://environment:6379")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "configured", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::a_url_drops_host_db_and_password_and_keeps_the_username(
|
||||
json!({"db": 4, "host": "dropped", "password": "dropped", "url": "rediss://cache:6380", "username": "user"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache:6380", "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "static", "username": "user", "password": null}}))),
|
||||
)]
|
||||
#[case::a_url_ignores_tls_settings_outside_the_url(
|
||||
json!({"ssl_ca_certs": "/ca.pem", "ssl_cert_reqs": "none", "ssl_check_hostname": true, "url": "rediss://cache:6380"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache:6380", "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::startup_nodes_beat_a_url(
|
||||
json!({"password": "cluster-secret", "startup_nodes": [{"host": "node-a", "port": 7000}], "url": "redis://single:6379"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "auth": {"kind": "static", "username": null, "password": "cluster-secret"}, "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::environment_cluster_nodes(
|
||||
json!({}),
|
||||
&[("REDIS_CLUSTER_NODES", r#"[{"host": "node-a", "port": "7000"}, {"host": "node-b", "port": 7001}]"#)],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000", "node-b:7001"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::cluster_tls_options(
|
||||
json!({"ssl": "true", "ssl_ca_certs": "/ca.pem", "ssl_check_hostname": true, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "tls": {"ca_certs": "/ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": true}, "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::sentinel_from_settings(
|
||||
json!({"password": "data-secret", "sentinel_nodes": [["sentinel-a", 26379], ["sentinel-b", 26380]], "sentinel_password": "sentinel-secret", "service_name": "primary"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "sentinel", "nodes": ["sentinel-a:26379", "sentinel-b:26380"], "service_name": "primary", "sentinel_password": "sentinel-secret", "db": 0, "auth": {"kind": "static", "username": null, "password": "data-secret"}}))),
|
||||
)]
|
||||
#[case::sentinel_from_the_environment(
|
||||
json!({}),
|
||||
&[("REDIS_SENTINEL_NODES", r#"[["sentinel-a", 26379]]"#), ("REDIS_SENTINEL_PASSWORD", "sentinel-secret"), ("REDIS_SERVICE_NAME", "primary")],
|
||||
Ok(expected_view(json!({"mode": "sentinel", "nodes": ["sentinel-a:26379"], "service_name": "primary", "sentinel_password": "sentinel-secret", "db": 0}))),
|
||||
)]
|
||||
#[case::a_host_breaks_sentinel(
|
||||
json!({"host": "ignored", "sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary"}),
|
||||
&[],
|
||||
Err("host cannot be combined with sentinel_nodes, which redis-py rejects at startup"),
|
||||
)]
|
||||
#[case::a_url_beats_sentinels(
|
||||
json!({"sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary", "url": "redis://single:6379"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "redis://single:6379"}))),
|
||||
)]
|
||||
#[case::an_explicit_credential_provider_beats_cloud_auth(
|
||||
json!({"aws_iam_auth": true, "credential_provider": true, "gcp_service_account": "account", "host": "redis", "password": "dropped"}),
|
||||
&[("REDIS_AZURE_AD_TOKEN", "true")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "auth": {"kind": "host"}}))),
|
||||
)]
|
||||
#[case::gcp_beats_azure_and_aws(
|
||||
json!({"aws_iam_auth": true, "azure_redis_ad_token": "True", "host": "redis", "password": "dropped"}),
|
||||
&[("REDIS_GCP_SERVICE_ACCOUNT", "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "auth": {"kind": "gcp_iam", "service_account": "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com"}}))),
|
||||
)]
|
||||
#[case::gcp_ca_certs_replace_the_configured_ones_under_ssl(
|
||||
json!({"gcp_service_account": "account", "gcp_ssl_ca_certs": "/gcp-ca.pem", "host": "redis", "ssl": true, "ssl_ca_certs": "/configured-ca.pem"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": "/gcp-ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "gcp_iam", "service_account": "account"}}))),
|
||||
)]
|
||||
#[case::azure_beats_aws_and_takes_the_environment_username(
|
||||
json!({"aws_iam_auth": true, "azure_client_id": "client", "azure_client_secret": "secret", "azure_redis_ad_token": true, "azure_tenant_id": "tenant", "host": "redis", "username": "dropped"}),
|
||||
&[("REDIS_USERNAME", "environment-user")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "auth": {"kind": "azure_ad", "username": "environment-user"}}))),
|
||||
)]
|
||||
#[case::azure_without_a_username_sends_only_the_token(
|
||||
json!({"azure_client_id": "client", "azure_client_secret": "secret", "azure_redis_ad_token": true, "azure_tenant_id": "tenant", "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "auth": {"kind": "azure_ad", "username": null}}))),
|
||||
)]
|
||||
#[case::azure_needs_the_literal_true(
|
||||
json!({"azure_client_id": "client", "azure_client_secret": "secret", "azure_redis_ad_token": "1", "azure_tenant_id": "tenant", "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::aws_iam_over_tls_with_the_default_region(
|
||||
json!({"aws_iam_auth": "yes", "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "password": "dropped", "ssl": true}),
|
||||
&[("AWS_DEFAULT_REGION", "us-east-1")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "cache-1", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "aws_iam", "user_name": "iam-user", "cache_name": "cache-1", "region": "us-east-1", "serverless": false}}))),
|
||||
)]
|
||||
#[case::aws_region_beats_the_default_region(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "ssl": true}),
|
||||
&[("AWS_DEFAULT_REGION", "us-east-1"), ("AWS_REGION", "eu-central-1")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "cache-1", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "aws_iam", "user_name": "iam-user", "cache_name": "cache-1", "region": "eu-central-1", "serverless": false}}))),
|
||||
)]
|
||||
#[case::aws_iam_serverless_over_a_rediss_url_drops_its_userinfo(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_region": "eu-west-1", "aws_iam_serverless": true, "aws_iam_user_name": "iam-user", "url": "rediss://user:pass@cache-1:6379"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache-1:6379", "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "aws_iam", "user_name": "iam-user", "cache_name": "cache-1", "region": "eu-west-1", "serverless": true}}))),
|
||||
)]
|
||||
#[case::aws_iam_without_tls_fails(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_region": "us-east-1", "aws_iam_user_name": "iam-user", "host": "cache-1"}),
|
||||
&[],
|
||||
Err("AWS ElastiCache IAM Redis authentication requires TLS"),
|
||||
)]
|
||||
#[case::aws_iam_without_a_region_fails(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "ssl": true}),
|
||||
&[],
|
||||
Err("AWS ElastiCache IAM Redis authentication requires: aws_iam_region"),
|
||||
)]
|
||||
#[case::tls_options_from_settings(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ca_certs": "/ca.pem", "ssl_cert_reqs": "none", "ssl_check_hostname": true}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": "/ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": false, "check_hostname": true}}))),
|
||||
)]
|
||||
#[case::tls_options_from_the_environment(
|
||||
json!({"host": "redis"}),
|
||||
&[("REDIS_SSL", "true"), ("REDIS_SSL_CA_CERTS", "/env-ca.pem"), ("REDIS_SSL_CERT_REQS", "optional")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": "/env-ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::connect_timeout_is_separate_from_the_socket_timeout(
|
||||
json!({"host": "redis", "socket_connect_timeout": 0.25, "socket_timeout": 2.5}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "socket_timeout": 2.5, "socket_connect_timeout": 0.25}))),
|
||||
)]
|
||||
#[case::environment_references_resolve(
|
||||
json!({"host": "os.environ/COORD_HOST", "password": "os.environ/COORD_PASSWORD"}),
|
||||
&[("COORD_HOST", "referenced"), ("COORD_PASSWORD", "referenced-secret")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "referenced", "port": 6379, "db": 0, "auth": {"kind": "static", "username": null, "password": "referenced-secret"}}))),
|
||||
)]
|
||||
#[case::no_target_fails(
|
||||
json!({"port": 6379}),
|
||||
&[],
|
||||
Err("either host or url must be specified for redis"),
|
||||
)]
|
||||
#[case::client_certificates(
|
||||
json!({"host": "redis", "ssl": true, "ssl_certfile": "/client.pem", "ssl_keyfile": "/client.key"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": "/client.pem", "keyfile": "/client.key", "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::a_rediss_url_takes_tls_options_from_its_query(
|
||||
json!({"url": "rediss://cache:6380/0?ssl_cert_reqs=none&ssl_ca_certs=/ca.pem&ssl_check_hostname=yes"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache:6380/0?ssl_cert_reqs=none&ssl_ca_certs=/ca.pem&ssl_check_hostname=yes", "tls": {"ca_certs": "/ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": false, "check_hostname": true}}))),
|
||||
)]
|
||||
#[case::a_url_query_setting_without_a_redis_rs_equivalent(
|
||||
json!({"url": "redis://cache:6379/0?socket_timeout=2"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "redis://cache:6379/0?socket_timeout=2"}))),
|
||||
)]
|
||||
#[case::tls_settings_outside_a_url_never_fail_it(
|
||||
json!({"db": "99999999999999999999", "ssl_cert_reqs": "strict", "url": "rediss://cache:6380"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache:6380", "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::db_is_not_read_for_a_cluster(
|
||||
json!({"db": 99_999_999_999_i64, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::cluster_db_true(
|
||||
json!({"db": true, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::cluster_db_false(
|
||||
json!({"db": false, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::cluster_db_fraction(
|
||||
json!({"db": 0.5, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::cluster_db_float_zero(
|
||||
json!({"db": 0.0, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::cluster_db_nonempty_list(
|
||||
json!({"db": [1], "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::cluster_db_empty_list(
|
||||
json!({"db": [], "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::ssl_ciphers(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ciphers": "HIGH"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::retry_on_timeout(
|
||||
json!({"host": "redis", "retry_on_timeout": true}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::malformed_port(
|
||||
json!({"host": "redis", "port": "abc"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::malformed_db(
|
||||
json!({"db": "first", "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::socket_timeout_not_a_number(
|
||||
json!({"host": "redis", "socket_timeout": "slow"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::malformed_port_from_the_environment(
|
||||
json!({}),
|
||||
&[("REDIS_HOST", "redis"), ("REDIS_PORT", "abc")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::protocol_3(
|
||||
json!({"host": "redis", "protocol": 3}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::retry_on_error_from_the_environment(
|
||||
json!({"host": "redis"}),
|
||||
&[("REDIS_RETRY_ON_ERROR", "true")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::unix_socket_path(
|
||||
json!({"host": "redis", "unix_socket_path": "/tmp/redis.sock"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "unix", "path": "/tmp/redis.sock"}))),
|
||||
)]
|
||||
#[case::unix_socket_path_keeps_tls_settings_out_of_the_target(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ca_certs": "/ca.pem", "unix_socket_path": "/tmp/redis.sock"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "unix", "path": "/tmp/redis.sock"}))),
|
||||
)]
|
||||
#[case::ssl_ca_data(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ca_data": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----", "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::ssl_ca_path_from_the_environment(
|
||||
json!({"host": "redis"}),
|
||||
&[("REDIS_SSL", "true"), ("REDIS_SSL_CA_PATH", "/etc/ssl/private-cas")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": "/etc/ssl/private-cas", "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::health_check_interval_is_whole_seconds(
|
||||
json!({"health_check_interval": "7", "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "health_check_interval": 7.0}))),
|
||||
)]
|
||||
#[case::health_check_interval_zero_is_off(
|
||||
json!({"health_check_interval": 0, "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::health_check_interval_zero_keeps_a_cluster_off(
|
||||
json!({"health_check_interval": 0, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"]}))),
|
||||
)]
|
||||
#[case::a_cluster_rejects_a_nonzero_db(
|
||||
json!({"db": 3, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::unix_socket_path_without_a_host_fails_like_python(
|
||||
json!({"unix_socket_path": "/tmp/redis.sock"}),
|
||||
&[],
|
||||
Err("either host or url must be specified for redis"),
|
||||
)]
|
||||
fn resolves_like_python_redis(
|
||||
#[case] kwargs: serde_json::Value,
|
||||
#[case] environment: &[(&str, &str)],
|
||||
#[case] expected: Result<serde_json::Value, &str>,
|
||||
) {
|
||||
let environment: HashMap<String, String> = environment
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
|
||||
.collect();
|
||||
|
||||
let outcome = resolve_kwargs(kwargs, &environment)
|
||||
.map(|resolved| view(&resolved))
|
||||
.map_err(|error| error.to_string());
|
||||
|
||||
assert_eq!(outcome, expected.map_err(str::to_owned));
|
||||
}
|
||||
|
||||
fn snapshot(values: &[(&str, &str)]) -> SecretSnapshot {
|
||||
let values: HashMap<&str, &str> = values.iter().copied().collect();
|
||||
SecretSnapshot::read(
|
||||
|
|
@ -1434,3 +825,161 @@ fn a_null_overrides_the_environment_only_where_python_passes_it_on(
|
|||
};
|
||||
assert_eq!(host.tls.is_some(), tls);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::empty_cluster_nodes_use_the_environment(
|
||||
json!({"startup_nodes": []}),
|
||||
&[("REDIS_CLUSTER_NODES", r#"[{"host": "node-a", "port": 7000}]"#)],
|
||||
json!({"startup_nodes": "[{\"host\": \"node-a\", \"port\": 7000}]"}),
|
||||
&[("startup_nodes", "REDIS_CLUSTER_NODES")],
|
||||
)]
|
||||
#[case::empty_sentinel_nodes_use_the_environment(
|
||||
json!({"sentinel_nodes": [], "service_name": "primary"}),
|
||||
&[("REDIS_SENTINEL_NODES", r#"[["sentinel-a", 26379]]"#)],
|
||||
json!({"sentinel_nodes": "[[\"sentinel-a\", 26379]]", "service_name": "primary"}),
|
||||
&[("sentinel_nodes", "REDIS_SENTINEL_NODES")],
|
||||
)]
|
||||
#[case::empty_service_name_uses_the_environment(
|
||||
json!({"sentinel_nodes": [["sentinel-a", 26379]], "service_name": ""}),
|
||||
&[("REDIS_SERVICE_NAME", "primary")],
|
||||
json!({"sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary"}),
|
||||
&[("service_name", "REDIS_SERVICE_NAME")],
|
||||
)]
|
||||
#[case::environment_host_port_and_password(
|
||||
json!({}),
|
||||
&[("REDIS_HOST", "environment"), ("REDIS_PASSWORD", "environment-secret"), ("REDIS_PORT", "6390"), ("REDIS_SSL", "True")],
|
||||
json!({"port": "6390", "ssl": true, "host": "environment", "password": "environment-secret"}),
|
||||
&[("host", "REDIS_HOST"), ("password", "REDIS_PASSWORD"), ("port", "REDIS_PORT"), ("ssl", "REDIS_SSL")],
|
||||
)]
|
||||
#[case::padded_environment_numbers_parse_like_python_int(
|
||||
json!({}),
|
||||
&[("REDIS_HOST", "redis"), ("REDIS_PORT", "6_380"), ("REDIS_DB", "3\n")],
|
||||
json!({"port": "6_380", "host": "redis", "db": "3\n"}),
|
||||
&[("db", "REDIS_DB"), ("host", "REDIS_HOST"), ("port", "REDIS_PORT")],
|
||||
)]
|
||||
#[case::socket_timeout_from_the_environment(
|
||||
json!({"host": "redis"}),
|
||||
&[("REDIS_SOCKET_TIMEOUT", "0.75")],
|
||||
json!({"socket_timeout": "0.75", "host": "redis"}),
|
||||
&[("socket_timeout", "REDIS_SOCKET_TIMEOUT")],
|
||||
)]
|
||||
#[case::environment_url(
|
||||
json!({}),
|
||||
&[("REDIS_URL", "redis://environment:6379/1")],
|
||||
json!({"url": "redis://environment:6379/1"}),
|
||||
&[("url", "REDIS_URL")],
|
||||
)]
|
||||
#[case::environment_url_beats_the_environment_host(
|
||||
json!({}),
|
||||
&[("REDIS_HOST", "ignored"), ("REDIS_URL", "redis://from-url:6379")],
|
||||
json!({"host": "ignored", "url": "redis://from-url:6379"}),
|
||||
&[("host", "REDIS_HOST"), ("url", "REDIS_URL")],
|
||||
)]
|
||||
#[case::an_explicit_host_drops_the_environment_url(
|
||||
json!({"host": "configured"}),
|
||||
&[("REDIS_URL", "redis://environment:6379")],
|
||||
json!({"host": "configured"}),
|
||||
&[],
|
||||
)]
|
||||
#[case::environment_cluster_nodes(
|
||||
json!({}),
|
||||
&[("REDIS_CLUSTER_NODES", r#"[{"host": "node-a", "port": "7000"}, {"host": "node-b", "port": 7001}]"#)],
|
||||
json!({"startup_nodes": "[{\"host\": \"node-a\", \"port\": \"7000\"}, {\"host\": \"node-b\", \"port\": 7001}]"}),
|
||||
&[("startup_nodes", "REDIS_CLUSTER_NODES")],
|
||||
)]
|
||||
#[case::sentinel_from_the_environment(
|
||||
json!({}),
|
||||
&[("REDIS_SENTINEL_NODES", r#"[["sentinel-a", 26379]]"#), ("REDIS_SENTINEL_PASSWORD", "sentinel-secret"), ("REDIS_SERVICE_NAME", "primary")],
|
||||
json!({"sentinel_nodes": "[[\"sentinel-a\", 26379]]", "sentinel_password": "sentinel-secret", "service_name": "primary"}),
|
||||
&[("sentinel_nodes", "REDIS_SENTINEL_NODES"), ("sentinel_password", "REDIS_SENTINEL_PASSWORD"), ("service_name", "REDIS_SERVICE_NAME")],
|
||||
)]
|
||||
#[case::an_explicit_credential_provider_beats_cloud_auth(
|
||||
json!({"aws_iam_auth": true, "credential_provider": true, "gcp_service_account": "account", "host": "redis", "password": "dropped"}),
|
||||
&[("REDIS_AZURE_AD_TOKEN", "true")],
|
||||
json!({"aws_iam_auth": true, "credential_provider": true, "gcp_service_account": "account", "host": "redis", "password": "dropped"}),
|
||||
&[],
|
||||
)]
|
||||
#[case::gcp_beats_azure_and_aws(
|
||||
json!({"aws_iam_auth": true, "azure_redis_ad_token": "True", "host": "redis", "password": "dropped"}),
|
||||
&[("REDIS_GCP_SERVICE_ACCOUNT", "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com")],
|
||||
json!({"gcp_service_account": "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com", "aws_iam_auth": true, "azure_redis_ad_token": "True", "host": "redis", "password": "dropped"}),
|
||||
&[("gcp_service_account", "REDIS_GCP_SERVICE_ACCOUNT")],
|
||||
)]
|
||||
#[case::azure_beats_aws_and_takes_the_environment_username(
|
||||
json!({"aws_iam_auth": true, "azure_client_id": "client", "azure_client_secret": "secret", "azure_redis_ad_token": true, "azure_tenant_id": "tenant", "host": "redis", "username": "dropped"}),
|
||||
&[("REDIS_USERNAME", "environment-user")],
|
||||
json!({"aws_iam_auth": true, "azure_client_id": "client", "azure_client_secret": "secret", "azure_redis_ad_token": true, "azure_tenant_id": "tenant", "host": "redis", "username": "dropped"}),
|
||||
&[],
|
||||
)]
|
||||
#[case::aws_iam_over_tls_with_the_default_region(
|
||||
json!({"aws_iam_auth": "yes", "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "password": "dropped", "ssl": true}),
|
||||
&[("AWS_DEFAULT_REGION", "us-east-1")],
|
||||
json!({"aws_iam_auth": "yes", "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "password": "dropped", "ssl": true, "aws_iam_region": "us-east-1"}),
|
||||
&[("aws_iam_region", "AWS_DEFAULT_REGION")],
|
||||
)]
|
||||
#[case::aws_region_beats_the_default_region(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "ssl": true}),
|
||||
&[("AWS_DEFAULT_REGION", "us-east-1"), ("AWS_REGION", "eu-central-1")],
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "ssl": true, "aws_iam_region": "eu-central-1"}),
|
||||
&[("aws_iam_region", "AWS_REGION")],
|
||||
)]
|
||||
#[case::tls_options_from_the_environment(
|
||||
json!({"host": "redis"}),
|
||||
&[("REDIS_SSL", "true"), ("REDIS_SSL_CA_CERTS", "/env-ca.pem"), ("REDIS_SSL_CERT_REQS", "optional")],
|
||||
json!({"ssl_ca_certs": "/env-ca.pem", "ssl": true, "ssl_cert_reqs": "optional", "host": "redis"}),
|
||||
&[("ssl", "REDIS_SSL"), ("ssl_ca_certs", "REDIS_SSL_CA_CERTS"), ("ssl_cert_reqs", "REDIS_SSL_CERT_REQS")],
|
||||
)]
|
||||
#[case::environment_references_resolve(
|
||||
json!({"host": "os.environ/COORD_HOST", "password": "os.environ/COORD_PASSWORD"}),
|
||||
&[("COORD_HOST", "referenced"), ("COORD_PASSWORD", "referenced-secret")],
|
||||
json!({"host": "referenced", "password": "referenced-secret"}),
|
||||
&[],
|
||||
)]
|
||||
#[case::malformed_port_from_the_environment(
|
||||
json!({}),
|
||||
&[("REDIS_HOST", "redis"), ("REDIS_PORT", "abc")],
|
||||
json!({"port": "abc", "host": "redis"}),
|
||||
&[("host", "REDIS_HOST"), ("port", "REDIS_PORT")],
|
||||
)]
|
||||
#[case::retry_on_error_from_the_environment(
|
||||
json!({"host": "redis"}),
|
||||
&[("REDIS_RETRY_ON_ERROR", "true")],
|
||||
json!({"retry_on_error": true, "host": "redis"}),
|
||||
&[("retry_on_error", "REDIS_RETRY_ON_ERROR")],
|
||||
)]
|
||||
#[case::ssl_ca_path_from_the_environment(
|
||||
json!({"host": "redis"}),
|
||||
&[("REDIS_SSL", "true"), ("REDIS_SSL_CA_PATH", "/etc/ssl/private-cas")],
|
||||
json!({"ssl": true, "ssl_ca_path": "/etc/ssl/private-cas", "host": "redis"}),
|
||||
&[("ssl", "REDIS_SSL"), ("ssl_ca_path", "REDIS_SSL_CA_PATH")],
|
||||
)]
|
||||
fn merges_like_python_redis(
|
||||
#[case] kwargs: serde_json::Value,
|
||||
#[case] environment: &[(&str, &str)],
|
||||
#[case] merged: serde_json::Value,
|
||||
#[case] sources: &[(&str, &str)],
|
||||
) {
|
||||
let configured = redis_cache_kwargs(kwargs);
|
||||
let lookup = |name: &str| {
|
||||
environment
|
||||
.iter()
|
||||
.find(|(key, _)| *key == name)
|
||||
.map(|(_, value)| (*value).to_owned())
|
||||
};
|
||||
|
||||
let kwargs = configured
|
||||
.merged(&SecretSnapshot::read(configured.secret_names(), &lookup))
|
||||
.unwrap();
|
||||
|
||||
let values: serde_json::Map<String, serde_json::Value> = kwargs
|
||||
.iter()
|
||||
.map(|(kwarg, entry)| (kwarg.clone(), entry.value.clone()))
|
||||
.collect();
|
||||
let renamed: Vec<(&str, &str)> = kwargs
|
||||
.iter()
|
||||
.filter(|(kwarg, entry)| *kwarg != &entry.source)
|
||||
.map(|(kwarg, entry)| (kwarg.as_str(), entry.source.as_str()))
|
||||
.collect();
|
||||
assert_eq!(serde_json::Value::Object(values), merged);
|
||||
assert_eq!(renamed, sources);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
- The one async Redis client for the gateway and the Rust cache crates. `cache-redis` (the response cache behind `python-bridge/src/cache/native`) still opens its own r2d2 pool from a URL, without TLS options, cloud auth, Sentinel or cluster settings, until that keyspace ports onto this client. It owns connecting, timeouts, error classes and the key namespace
|
||||
- The one async Redis client for the gateway and the Rust cache crates. It owns connecting, timeouts, error classes and the key namespace
|
||||
- It knows nothing about domains, so key builders, TTL policy, value formats and fallback decisions stay in the crate that owns the state. Every write takes its TTL from the caller, and there is no default
|
||||
- Async only. Build one `RedisClient` per Redis instance at the process boundary and clone it. Never add a pool, a sync API, `spawn_blocking`, or a client built inside a route
|
||||
- Every connection sets TCP_NODELAY, like redis-py's `socket.setsockopt(IPPROTO_TCP, TCP_NODELAY, 1)`. Set every redis-rs timeout and retry explicitly, citing the redis-py 5.3.1 or `litellm/` source next to each constant. Its defaults (500ms response, 1s connect, 6 reconnect retries) are not the Python proxy's. `Timeouts` takes `socket_timeout` and `socket_connect_timeout`, and the connect timeout falls back to the socket timeout like redis-py. Standalone reconnects on the next command with no retry of the failed one, matching redis-py's `Retry(NoBackoff(), 0)`. Cluster keeps redis-py's 3 `cluster_error_retry_attempts` and waits a flat 250ms between them, which is redis-py's CLUSTERDOWN sleep. redis-py's jittered per-node reconnect backoff has no redis-rs equivalent. The retry set also differs, redis-rs retries LOADING and READONLY internally, while redis-py 5.3.1 raises them. Do not claim exact retry parity
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ thiserror.workspace = true
|
|||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
litellm-config.workspace = true
|
||||
litellm-redis-resolve.workspace = true
|
||||
litellm-redis-testing.workspace = true
|
||||
rustls.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,28 +1,17 @@
|
|||
use std::{collections::HashMap, time::Duration};
|
||||
use std::{collections::BTreeMap, 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::{ProviderHandle, Resolution};
|
||||
use rstest::rstest;
|
||||
|
||||
// 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()
|
||||
// a host hands its credential_provider object over as a handle, so these tests do the same
|
||||
fn resolved(kwargs: serde_json::Value) -> Resolution {
|
||||
let provider = kwargs
|
||||
.get("credential_provider")
|
||||
.map(|_| ProviderHandle::new(1));
|
||||
let kwargs = serde_json::from_value::<BTreeMap<String, serde_json::Value>>(kwargs).unwrap();
|
||||
litellm_redis_resolve::interpret(&litellm_redis_resolve::kwargs(kwargs), provider).unwrap()
|
||||
}
|
||||
|
||||
fn port(url: &str) -> u16 {
|
||||
|
|
@ -46,21 +35,16 @@ async fn python_tls_kwargs_reach_the_server(#[case] with_ca: bool, #[case] conne
|
|||
"socket_timeout": 2,
|
||||
});
|
||||
|
||||
let pinged = connect(resolved(kwargs, &[]), None).unwrap().ping().await;
|
||||
let pinged = connect(resolved(kwargs), None).unwrap().ping().await;
|
||||
|
||||
assert_eq!(pinged.is_ok(), connects, "{pinged:?}");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::configured_password(Some("right"), &[], true)]
|
||||
#[case::environment_password(None, &[("REDIS_PASSWORD", "right")], true)]
|
||||
#[case::wrong_password(Some("wrong"), &[], false)]
|
||||
#[case::right_password("right", true)]
|
||||
#[case::wrong_password("wrong", false)]
|
||||
#[tokio::test]
|
||||
async fn static_credentials_from_kwargs_or_environment_authenticate(
|
||||
#[case] password: Option<&str>,
|
||||
#[case] environment: &[(&str, &str)],
|
||||
#[case] connects: bool,
|
||||
) {
|
||||
async fn static_credentials_authenticate(#[case] password: &str, #[case] connects: bool) {
|
||||
let server = RedisServer::start().await.unwrap();
|
||||
server
|
||||
.cli(&["CONFIG", "SET", "requirepass", "right"])
|
||||
|
|
@ -73,10 +57,7 @@ async fn static_credentials_from_kwargs_or_environment_authenticate(
|
|||
"socket_timeout": 2,
|
||||
});
|
||||
|
||||
let pinged = connect(resolved(kwargs, environment), None)
|
||||
.unwrap()
|
||||
.ping()
|
||||
.await;
|
||||
let pinged = connect(resolved(kwargs), None).unwrap().ping().await;
|
||||
|
||||
assert_eq!(pinged.is_ok(), connects, "{pinged:?}");
|
||||
}
|
||||
|
|
@ -91,7 +72,7 @@ async fn python_sentinel_kwargs_follow_the_master_through_a_failover() {
|
|||
"service_name": SERVICE_NAME,
|
||||
"socket_timeout": 1,
|
||||
});
|
||||
let client = connect(resolved(kwargs, &[]), None).unwrap();
|
||||
let client = connect(resolved(kwargs), None).unwrap();
|
||||
client
|
||||
.set("before", "1", Duration::from_secs(600))
|
||||
.await
|
||||
|
|
@ -115,20 +96,18 @@ async fn python_sentinel_kwargs_follow_the_master_through_a_failover() {
|
|||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::credential_provider(serde_json::json!({"host": "redis", "credential_provider": true}), &[], None)]
|
||||
#[case::gcp(serde_json::json!({"host": "redis", "gcp_service_account": "account"}), &[], Some("gcp_service_account"))]
|
||||
#[case::azure(serde_json::json!({"host": "redis"}), &[("REDIS_AZURE_AD_TOKEN", "true")], Some("azure_redis_ad_token"))]
|
||||
#[case::credential_provider(serde_json::json!({"host": "redis", "credential_provider": true}), None)]
|
||||
#[case::gcp(serde_json::json!({"host": "redis", "gcp_service_account": "account"}), Some("gcp_service_account"))]
|
||||
#[case::azure(serde_json::json!({"host": "redis", "azure_redis_ad_token": true}), Some("azure_redis_ad_token"))]
|
||||
#[case::aws(
|
||||
serde_json::json!({"host": "cache", "ssl": true, "aws_iam_auth": true, "aws_iam_user_name": "user", "aws_iam_cache_name": "cache", "aws_iam_region": "us-east-1"}),
|
||||
&[],
|
||||
Some("aws_iam_auth")
|
||||
)]
|
||||
fn auth_this_host_cannot_build_fails_at_boot_naming_the_setting(
|
||||
#[case] kwargs: serde_json::Value,
|
||||
#[case] environment: &[(&str, &str)],
|
||||
#[case] cloud_setting: Option<&str>,
|
||||
) {
|
||||
let error = connect(resolved(kwargs, environment), None).err().unwrap();
|
||||
let error = connect(resolved(kwargs), None).err().unwrap();
|
||||
|
||||
match (error, cloud_setting) {
|
||||
(Error::NoHostCredentialProvider, None) => {}
|
||||
|
|
|
|||
24
litellm-rust/crates/redis-resolve/AGENTS.md
Normal file
24
litellm-rust/crates/redis-resolve/AGENTS.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
## Interpreting merged kwargs
|
||||
|
||||
- The boundary is merged redis-py kwargs in, `litellm_redis_types::Resolution` out. `interpret` takes the dict a host already merged the way `_merge_redis_kwargs` in `litellm/_redis.py` does, with its `REDIS_*` values and `get_secret` fallbacks filled, and stands in for redis-py's constructors and `from_url`. It never reads the environment or a secret, so both hosts share it. Every `Entry` keeps the source a warning or error names, which `kwargs` sets to the kwarg name and the gateway merge sets to `REDIS_<KWARG>` or the fallback name
|
||||
- The `Resolution` holds 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 setting the Rust client ignores as an `Ignored` with its source and consequence, which the host logs. A host passes its `credential_provider` object as a `ProviderHandle`, and a `credential_provider` key without one is `RedisError::ProviderWithoutHandle`. Azure's username is read from `os.environ` by Python, so `interpret` leaves `AzureAdAuth::username` unset and the host fills it in
|
||||
- `options.rs` reads the kwargs into the typed `Options` and splits the unmodeled settings into passthrough and host objects and collects ignored ones. `endpoint.rs`, `auth.rs` and `tls.rs` pick the target, the credentials and TLS from `Options` alone
|
||||
- Rust never introspects redis-py. Every Redis setting Python reads is one row in `SETTINGS` in `src/keys.rs`: how Rust handles it (`Read`, `Warn` or `Reject`), whether `_get_redis_env_kwarg_mapping` reads it as `REDIS_<KWARG>`, and the names `_merge_redis_kwargs` falls back to. The last two columns are only read by the gateway merge, and they stay on the row so there is one table. A `Read` row is also a field of `Options`. A kwarg with no row goes to `passthrough`, or to `host_objects` if `HOST_OBJECTS` lists it, without a warning. `redis-py-kwargs.json` records which redis-py version accepts each kwarg next to its row, so check it when adding or changing a row
|
||||
- The contract is `resolves_like_python_redis` in `tests/interpret.rs` and its mirror `test_redis_settings_resolve_like_the_rust_resolver_expects` in `tests/unit/test_redis.py`. Each case holds the merged dict, the sources the merge gave it, and the expected view written next to it. The Python side asserts that `_merge_redis_kwargs` returns that merged dict and reads the effective redis-py 5.3.1 connection objects without connecting, and `merges_like_python_redis` in `gateway/src/redis/surface_tests.rs` pins the same merged dicts from the Rust merge. A new rule is a new case in every file, never a generated fixture. Python and Rust pods share one Redis during the switch, so a change here is a change to a wire contract
|
||||
- A url carries its own TLS. redis-py's url allowlist is the plain `Connection` signature, so every `ssl_*` setting outside the url is dropped, and a credential provider strips the url's userinfo. A `rediss://` url's `ssl_cert_reqs`, `ssl_ca_certs`, `ssl_ca_data`, `ssl_ca_path`, `ssl_certfile`, `ssl_keyfile`, `ssl_password` and `ssl_check_hostname` query parameters are read like `parse_url` reads them
|
||||
- TLS options and `db` are only read for the endpoint that uses them, so a bad `ssl_cert_reqs` never fails a url target. A cluster with a non-zero `db`, and Sentinel with a `host` or `port`, are errors because redis-py raises building those clients
|
||||
- An empty value (`""`, `[]`, `{}`) is the same as an unset one, like Python's `x or ...`
|
||||
- Precedence matches Python. The target is cluster, then URL, then Sentinel (`sentinel_nodes` plus `service_name`), then host. Auth is a `credential_provider` handle, then GCP, then Azure, then AWS, and the first one wins with a warning
|
||||
- Numbers are read with `litellm_python_compat::number`, so whitespace and digit underscores parse like Python's `int()` and `float()`. A value they cannot parse (`port: abc`, `socket_timeout: slow`) is dropped with a warning and the default is used, like `_coerce_redis_kwargs_types`. A number that parses but is out of range (negative seconds, a port above 65535) fails with `RedisError::Invalid`
|
||||
- Flags (`ssl`, `ssl_check_hostname`, `aws_iam_auth`, `aws_iam_serverless`) are read one way on every surface. `true`, `1` and `yes` are true and `false`, `0`, `no`, `off`, `f` and `n` are false, the text redis-py's `_str_to_bool` and the proxy's pydantic models agree on. Any other text is `RedisError::Invalid`, so a TLS setting is never guessed
|
||||
- The resolver fails on a setting it cannot honor only when that setting is a security check. A `Warn` row is dropped and returned as an `Ignored` naming its source (`ssl_ciphers` or `REDIS_SSL_CIPHERS`) and the consequence from `keys::consequence`, because a Python deployment that worked must keep working when its stores move to Rust. Only a value that is malformed (`RedisError::Invalid`) or a target that is missing fails. Promoting a `Warn` row to `Read` is then a pure improvement with no behavior cliff
|
||||
- `ssl_ciphers`, `ssl_min_version` above TLS 1.2, `ssl_ocsp_context`, `ssl_ocsp_expected_cert`, `connection_pool`, `event_dispatcher`, `redis_connect_func` and `retry` are ignored
|
||||
- A `Reject` row fails closed. `ssl_validate_ocsp` and `ssl_validate_ocsp_stapled` set to true, from the kwargs, `REDIS_*` or a url query, are `RedisError::Unenforceable`, because rustls has no client-side OCSP and connecting without the check would silently drop it. redis-py 5.3.1's async client refuses both with a `TypeError`, so this matches the proxy's path. Set to false they ask for nothing and are dropped without a warning
|
||||
- `ssl_ca_data` and `ssl_ca_path` are read into `TlsOptions` and trusted next to `ssl_ca_certs`. `ssl_password` is read into `TlsOptions::key_password`, which `redis-client` uses to decrypt a PKCS#8 key like the sync client's `load_cert_chain(password=...)`. redis-py 5.3.1's async client refuses it, so this is a superset of the path the proxy uses, like `ssl_ca_path` `unix_socket_path` resolves to `Target::Unix`, which like redis-py's `UnixDomainSocketConnection` replaces host, port and TLS. Python still requires `host` next to it, so Rust does too
|
||||
- A url's `?db=`, `?username=` and `?password=` are read like `parse_url` reads them, so `?db=` wins over the path and the userinfo wins over `?username=` and `?password=`. Any other query parameter except the TLS ones on a `rediss://` url is ignored naming `url ?<name>`. redis-py turns each one into a client kwarg
|
||||
- `retry_on_timeout`, `retry_on_error`, `protocol`, `cache`, `cache_config`, `socket_keepalive` and `max_connections` are ignored. Cluster clients still turn TCP keepalive on, like Python's async cluster default
|
||||
- `health_check_interval` is read as an `int` of seconds like `_coerce_redis_kwargs_types` into `HealthCheck`. Zero is `Disabled` and stays off on every target, a negative interval checks before every command like redis-py, and only `Unset` takes a default. `redis-connect` gives an unset cluster Python's 25s default and `redis-client` pings every node before the next command once the client sat idle that long, outside the breaker and the call events
|
||||
- Cases where Rust and Python disagree on purpose are cases in both contract tests with a different expected outcome on each side
|
||||
- `ssl_ca_path`: redis-py 5.3.1's async `Redis` has no such parameter and raises `TypeError` building the client, while Rust trusts the directory like `load_verify_locations(capath=...)`
|
||||
- `on`, `t` and `y` for a flag are `RedisError::Invalid` on every surface. pydantic reads them as true for `coordination_redis` and `_str_to_bool` reads them as false everywhere else, so Rust refuses to pick one. Other unrecognized text is false in Python and an error here
|
||||
- Empty `sentinel_nodes` with no `REDIS_SENTINEL_NODES` is no target at all, so it fails as "either host or url must be specified". redis-py keeps the empty list and fails building the Sentinel client
|
||||
19
litellm-rust/crates/redis-resolve/Cargo.toml
Normal file
19
litellm-rust/crates/redis-resolve/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "litellm-redis-resolve"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
litellm-python-compat.workspace = true
|
||||
litellm-redis-types.workspace = true
|
||||
litellm-secret-value.workspace = true
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"meta": {
|
||||
"description": "Every kwarg litellm/_redis.py can pass to redis-py, by tested redis-py version, next to how SETTINGS in src/redis/keys.rs handles it",
|
||||
"description": "Every kwarg litellm/_redis.py can pass to redis-py, by tested redis-py version, next to how SETTINGS in litellm-rust/crates/redis-resolve/src/keys.rs handles it",
|
||||
"generated": "2026-11-05",
|
||||
"redis_py_versions": [
|
||||
"5.3.1",
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"unlisted": "no SETTINGS row, so it lands in passthrough for a redis-py host and the Rust client never uses it, without a warning",
|
||||
"unlisted_host_object": "no SETTINGS row and listed in HOST_OBJECTS, so it lands in host_objects and the Rust client never uses it, without a warning"
|
||||
},
|
||||
"field": "the Options field in src/redis/options.rs that a Read row fills, null otherwise"
|
||||
"field": "the Options field in litellm-rust/crates/redis-resolve/src/options.rs that a Read row fills, null otherwise"
|
||||
},
|
||||
"versions": "each entry is a redis-py version whose signatures name the arg, with which ones. An empty array means no signature names it, so it only reaches Python as a litellm key"
|
||||
},
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_redis_types::{Auth, AwsIamAuth, AzureAdAuth, ProviderHandle, StaticAuth};
|
||||
|
||||
use super::{
|
||||
use crate::{
|
||||
RedisError,
|
||||
options::{self, AwsOptions, Options, flag, secret, text},
|
||||
};
|
||||
|
|
@ -9,7 +8,6 @@ use super::{
|
|||
pub(super) fn redis_auth(
|
||||
options: &Options,
|
||||
provider: Option<ProviderHandle>,
|
||||
environment: &dyn Lookup,
|
||||
) -> Result<Auth, RedisError> {
|
||||
match (provider, &options.credential_provider) {
|
||||
(Some(handle), _) => return Ok(Auth::Provider(handle)),
|
||||
|
|
@ -44,9 +42,7 @@ pub(super) fn redis_auth(
|
|||
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()),
|
||||
username: environment
|
||||
.get("REDIS_USERNAME")
|
||||
.filter(|username| !username.is_empty()),
|
||||
username: None,
|
||||
}))
|
||||
}
|
||||
(None, false, true) => aws_iam(&options.aws, uses_tls(options)?).map(Auth::AwsIam),
|
||||
|
|
@ -4,13 +4,13 @@ use litellm_redis_types::{
|
|||
};
|
||||
use litellm_secret_value::SecretValue;
|
||||
|
||||
use super::{
|
||||
Expected, RedisError,
|
||||
layers::Entry,
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
Entry, Expected, RedisError,
|
||||
options::{Options, as_text, flag, integer, invalid, secret, text},
|
||||
tls,
|
||||
};
|
||||
use crate::Value;
|
||||
|
||||
/// The target, first match wins like `get_redis_client`: cluster, url, Sentinel, then host. A
|
||||
/// url also returns the query parameters it ignores.
|
||||
|
|
@ -123,9 +123,7 @@ fn cluster_nodes(entry: Option<&Entry>) -> Result<Option<Vec<Node>>, RedisError>
|
|||
|
||||
fn sentinel_nodes(value: &Value) -> Option<Vec<Node>> {
|
||||
let nodes = nodes(value, |pair| match pair {
|
||||
Value::Sequence(pair) if pair.len() == 2 => {
|
||||
node_address(as_text(&pair[0]), as_text(&pair[1]))
|
||||
}
|
||||
Value::Array(pair) if pair.len() == 2 => node_address(as_text(&pair[0]), as_text(&pair[1])),
|
||||
_ => None,
|
||||
})?;
|
||||
(!nodes.is_empty()).then_some(nodes)
|
||||
|
|
@ -138,7 +136,7 @@ fn nodes(value: &Value, parse: impl Fn(&Value) -> Option<Node>) -> Option<Vec<No
|
|||
.iter()
|
||||
.map(parse)
|
||||
.collect(),
|
||||
Value::Sequence(nodes) => nodes.iter().map(parse).collect(),
|
||||
Value::Array(nodes) => nodes.iter().map(parse).collect(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@ pub enum RedisError {
|
|||
DbInCluster { setting: String },
|
||||
#[error("credential_provider is a host object, and the host passed no handle for it")]
|
||||
ProviderWithoutHandle,
|
||||
#[error("{setting} asks for OCSP revocation checks, which rustls cannot perform, so Redis is not connected without them")]
|
||||
#[error(
|
||||
"{setting} asks for OCSP revocation checks, which rustls cannot perform, so Redis is not connected without them"
|
||||
)]
|
||||
Unenforceable { setting: String },
|
||||
#[error("Redis setting {setting} is not plain data")]
|
||||
NotPlainData { setting: String },
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_redis_types::Ignored;
|
||||
|
||||
use super::layers::Entry;
|
||||
use crate::Value;
|
||||
use serde_json::Value;
|
||||
|
||||
// Every Redis setting litellm/_redis.py reads, with how Rust handles it and where else Python looks for it.
|
||||
// Warn rows are read by Python and dropped here with a warning naming the consequence, never an error.
|
||||
|
|
@ -10,17 +7,17 @@ use crate::Value;
|
|||
// environment marks the kwargs _get_redis_env_kwarg_mapping reads as REDIS_<KWARG>, and fallback lists the names
|
||||
// _get_redis_client_logic consults with get_secret when the merged kwargs lack the setting
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Handling {
|
||||
pub enum Handling {
|
||||
Read,
|
||||
Warn,
|
||||
Reject,
|
||||
}
|
||||
|
||||
pub(super) struct Setting {
|
||||
pub(super) kwarg: &'static str,
|
||||
pub(super) handling: Handling,
|
||||
pub(super) environment: bool,
|
||||
pub(super) fallback: &'static [&'static str],
|
||||
pub struct Setting {
|
||||
pub kwarg: &'static str,
|
||||
pub handling: Handling,
|
||||
pub environment: bool,
|
||||
pub fallback: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl Setting {
|
||||
|
|
@ -38,13 +35,13 @@ impl Setting {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn environment_name(&self) -> Option<String> {
|
||||
pub fn environment_name(&self) -> Option<String> {
|
||||
self.environment
|
||||
.then(|| format!("REDIS_{}", self.kwarg.to_ascii_uppercase()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const SETTINGS: &[Setting] = &[
|
||||
pub const SETTINGS: &[Setting] = &[
|
||||
Setting::new("aws_iam_auth", Handling::Read, true, &[]),
|
||||
Setting::new("aws_iam_cache_name", Handling::Read, true, &[]),
|
||||
Setting::new(
|
||||
|
|
@ -165,11 +162,11 @@ pub(super) const SETTINGS: &[Setting] = &[
|
|||
Setting::new("username", Handling::Read, true, &[]),
|
||||
];
|
||||
|
||||
pub(super) fn is_refused_when_set(kwarg: &str) -> bool {
|
||||
pub(crate) fn is_refused_when_set(kwarg: &str) -> bool {
|
||||
setting(kwarg).is_some_and(|setting| setting.handling == Handling::Reject)
|
||||
}
|
||||
|
||||
pub(super) fn ignored(setting: String, kwarg: &str) -> Ignored {
|
||||
pub(crate) fn ignored(setting: String, kwarg: &str) -> Ignored {
|
||||
Ignored {
|
||||
setting,
|
||||
consequence: consequence(kwarg),
|
||||
|
|
@ -178,7 +175,7 @@ pub(super) fn ignored(setting: String, kwarg: &str) -> Ignored {
|
|||
|
||||
// rustls only speaks TLS 1.2 and 1.3, so a minimum of 1.2 or lower is already met. redis-py takes an
|
||||
// ssl.TLSVersion, which reaches Rust as its name or its value
|
||||
pub(super) fn is_met_by_rustls(kwarg: &str, value: &Value) -> bool {
|
||||
pub(crate) fn is_met_by_rustls(kwarg: &str, value: &Value) -> bool {
|
||||
if kwarg != "ssl_min_version" {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -213,7 +210,7 @@ const HOST_OBJECTS: &[&str] = &[
|
|||
"ssl_ocsp_context",
|
||||
];
|
||||
|
||||
pub(super) fn is_host_object(kwarg: &str) -> bool {
|
||||
pub(crate) fn is_host_object(kwarg: &str) -> bool {
|
||||
HOST_OBJECTS.contains(&kwarg)
|
||||
}
|
||||
|
||||
|
|
@ -237,19 +234,6 @@ fn consequence(kwarg: &str) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn setting(kwarg: &str) -> Option<&'static Setting> {
|
||||
pub fn setting(kwarg: &str) -> Option<&'static Setting> {
|
||||
SETTINGS.iter().find(|setting| setting.kwarg == kwarg)
|
||||
}
|
||||
|
||||
pub(super) fn fallback(kwarg: &str, lookup: &dyn Lookup) -> Option<Entry> {
|
||||
let names = setting(kwarg).map_or(&[][..], |setting| setting.fallback);
|
||||
names.iter().find_map(|name| {
|
||||
lookup
|
||||
.get(name)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| Entry {
|
||||
value: Value::String(value),
|
||||
source: (*name).to_owned(),
|
||||
})
|
||||
})
|
||||
}
|
||||
79
litellm-rust/crates/redis-resolve/src/lib.rs
Normal file
79
litellm-rust/crates/redis-resolve/src/lib.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use std::{collections::BTreeMap, time::Duration};
|
||||
|
||||
use litellm_redis_types::{Auth, HealthCheck, ProviderHandle, Resolution, StaticAuth, Target};
|
||||
use serde_json::Value;
|
||||
|
||||
mod auth;
|
||||
mod endpoint;
|
||||
mod error;
|
||||
mod keys;
|
||||
mod options;
|
||||
mod tls;
|
||||
|
||||
pub use error::{Expected, RedisError};
|
||||
pub use keys::{Handling, SETTINGS, Setting, setting};
|
||||
pub use options::is_present;
|
||||
|
||||
use options::Options;
|
||||
|
||||
/// One merged redis-py kwarg and the name a warning or error reports it under.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
pub value: Value,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
pub type Kwargs = BTreeMap<String, Entry>;
|
||||
|
||||
/// Kwargs whose every entry is reported under its own kwarg name.
|
||||
pub fn kwargs(fields: impl IntoIterator<Item = (String, Value)>) -> Kwargs {
|
||||
fields
|
||||
.into_iter()
|
||||
.map(|(kwarg, value)| {
|
||||
let entry = Entry {
|
||||
value,
|
||||
source: kwarg.clone(),
|
||||
};
|
||||
(kwarg, entry)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What redis-py's constructors make of kwargs a host already merged the way `_get_redis_client_logic` does. A
|
||||
/// host passes its `credential_provider` object as `provider`.
|
||||
pub fn interpret(
|
||||
kwargs: &Kwargs,
|
||||
provider: Option<ProviderHandle>,
|
||||
) -> Result<Resolution, RedisError> {
|
||||
let (options, unmodeled) = Options::read(kwargs)?;
|
||||
let auth = auth::redis_auth(&options, provider)?;
|
||||
let (target, url_ignored) = 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 {
|
||||
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())?,
|
||||
health_check: health_check(options.health_check_interval.as_ref())?,
|
||||
passthrough: unmodeled.passthrough,
|
||||
host_objects: unmodeled.host_objects,
|
||||
ignored: unmodeled.ignored.into_iter().chain(url_ignored).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
// redis-py checks health only when the interval is truthy, so zero turns it off and a negative interval checks
|
||||
// before every command
|
||||
fn health_check(entry: Option<&Entry>) -> Result<HealthCheck, RedisError> {
|
||||
Ok(match options::integer::<i64>(entry)? {
|
||||
None => HealthCheck::Unset,
|
||||
Some(0) => HealthCheck::Disabled,
|
||||
Some(seconds) => HealthCheck::Every(Duration::from_secs(seconds.try_into().unwrap_or(0))),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,24 +1,16 @@
|
|||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
time::Duration,
|
||||
};
|
||||
use std::{collections::BTreeSet, time::Duration};
|
||||
|
||||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_python_compat::{BigInt, number};
|
||||
use litellm_redis_types::{Ignored, Passthrough};
|
||||
use litellm_secret_value::SecretValue;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
Expected, RedisError,
|
||||
use crate::{
|
||||
Entry, Expected, Kwargs, RedisError,
|
||||
keys::{self, Handling},
|
||||
layers::Entry,
|
||||
};
|
||||
use crate::Value;
|
||||
|
||||
pub(super) type Kwargs = BTreeMap<String, Entry>;
|
||||
|
||||
/// Every setting the resolver reads, after layering. A setting is `None` when it is unset, null or
|
||||
/// empty, and then its environment fallback, if Python has one, stands in for it.
|
||||
/// Every setting the resolver reads. A setting is `None` when it is unset, null or empty.
|
||||
pub(super) struct Options {
|
||||
pub(super) url: Option<Entry>,
|
||||
pub(super) host: Option<Entry>,
|
||||
|
|
@ -98,14 +90,8 @@ impl Unmodeled {
|
|||
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::<Result<_, _>>()?;
|
||||
.map(|(key, entry)| (key.clone(), entry.value.clone()))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
passthrough: Passthrough::new(passthrough),
|
||||
host_objects: objects.into_iter().map(|(key, _)| key.clone()).collect(),
|
||||
|
|
@ -122,17 +108,13 @@ impl Unmodeled {
|
|||
|
||||
impl Options {
|
||||
/// The typed settings and the ones the resolver leaves to the host.
|
||||
pub(super) fn read(
|
||||
kwargs: Kwargs,
|
||||
lookup: &dyn Lookup,
|
||||
) -> Result<(Self, Unmodeled), RedisError> {
|
||||
let unmodeled = Unmodeled::read(&kwargs)?;
|
||||
pub(super) fn read(kwargs: &Kwargs) -> Result<(Self, Unmodeled), RedisError> {
|
||||
let unmodeled = Unmodeled::read(kwargs)?;
|
||||
let get = |key: &str| {
|
||||
kwargs
|
||||
.get(key)
|
||||
.filter(|entry| is_present(&entry.value))
|
||||
.cloned()
|
||||
.or_else(|| keys::fallback(key, lookup))
|
||||
};
|
||||
let options = Self {
|
||||
url: get("url"),
|
||||
|
|
@ -206,13 +188,13 @@ fn handling(key: &str) -> Option<Handling> {
|
|||
}
|
||||
|
||||
// Python reads most of these with `x or fallback`, so an empty value counts as unset everywhere
|
||||
fn is_present(value: &Value) -> bool {
|
||||
pub fn is_present(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::String(text) => !text.is_empty(),
|
||||
Value::Sequence(items) => !items.is_empty(),
|
||||
Value::Mapping(entries) => !entries.is_empty(),
|
||||
Value::Bool(_) | Value::Number(_) | Value::Tagged(_) => true,
|
||||
Value::Array(items) => !items.is_empty(),
|
||||
Value::Object(entries) => !entries.is_empty(),
|
||||
Value::Bool(_) | Value::Number(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2,12 +2,12 @@ use litellm_python_compat::number;
|
|||
use litellm_redis_types::{Ignored, TlsOptions};
|
||||
use litellm_secret_value::SecretValue;
|
||||
|
||||
use super::{
|
||||
Expected, RedisError, keys,
|
||||
layers::Entry,
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
Entry, Expected, RedisError, keys,
|
||||
options::{self, TlsInput, flag, parsed, secret, text},
|
||||
};
|
||||
use crate::Value;
|
||||
|
||||
// redis-py's parse_url turns every query parameter into a client kwarg. Only these and, on a rediss:// url, the
|
||||
// TLS ones have a redis-rs equivalent, so any other parameter is ignored
|
||||
605
litellm-rust/crates/redis-resolve/tests/interpret.rs
Normal file
605
litellm-rust/crates/redis-resolve/tests/interpret.rs
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use litellm_redis_resolve::{Entry, Expected, Kwargs, RedisError, interpret, kwargs};
|
||||
use litellm_redis_types::{
|
||||
Auth, HealthCheck, Node, ProviderHandle, Resolution, StaticAuth, Target, TlsOptions,
|
||||
};
|
||||
use rstest::rstest;
|
||||
use serde_json::json;
|
||||
|
||||
const PROVIDER: ProviderHandle = ProviderHandle::new(7);
|
||||
|
||||
fn merged(fields: serde_json::Value, sources: &[(&str, &str)]) -> Kwargs {
|
||||
let fields = serde_json::from_value::<BTreeMap<String, serde_json::Value>>(fields).unwrap();
|
||||
kwargs(fields)
|
||||
.into_iter()
|
||||
.map(|(kwarg, entry)| {
|
||||
let source = sources
|
||||
.iter()
|
||||
.find(|(named, _)| *named == kwarg)
|
||||
.map_or(entry.source, |(_, source)| (*source).to_owned());
|
||||
(kwarg, Entry { source, ..entry })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// a host hands its credential_provider object over as a handle, so the cases do the same
|
||||
fn interpret_merged(
|
||||
fields: serde_json::Value,
|
||||
sources: &[(&str, &str)],
|
||||
) -> Result<Resolution, RedisError> {
|
||||
let has_provider = fields
|
||||
.get("credential_provider")
|
||||
.is_some_and(|provider| !provider.is_null());
|
||||
interpret(&merged(fields, sources), has_provider.then_some(PROVIDER))
|
||||
}
|
||||
|
||||
fn interpret_kwargs(fields: serde_json::Value) -> Result<Resolution, RedisError> {
|
||||
interpret_merged(fields, &[])
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_provider_handle_beats_every_cloud_auth_and_static_credentials() {
|
||||
let resolved = interpret_kwargs(json!({
|
||||
"host": "redis", "password": "pass", "credential_provider": "host-object",
|
||||
"gcp_service_account": "account", "aws_iam_auth": true, "azure_redis_ad_token": "true",
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.auth, Auth::Provider(PROVIDER));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_rediss_url_reads_ssl_password_from_its_query() {
|
||||
let resolved =
|
||||
interpret_kwargs(json!({"url": "rediss://cache:6380/0?ssl_password=query-key-secret"}))
|
||||
.unwrap();
|
||||
|
||||
let Target::Url(parsed) = resolved.target else {
|
||||
panic!("expected a url target, got {:?}", resolved.target);
|
||||
};
|
||||
let key_password = parsed.tls.and_then(|tls| tls.key_password);
|
||||
assert_eq!(expose(key_password.as_ref()), Some("query-key-secret"));
|
||||
assert!(resolved.ignored.is_empty(), "{:?}", resolved.ignored);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::read_on_a_plain_url(
|
||||
"redis://cache:6379/1?db=3&username=query-user&password=query-secret",
|
||||
Some(3),
|
||||
Some("query-user"),
|
||||
Some("query-secret")
|
||||
)]
|
||||
#[case::absent("rediss://cache:6380/1", None, None, None)]
|
||||
fn a_url_query_carries_db_and_credentials_like_parse_url(
|
||||
#[case] url: &str,
|
||||
#[case] db: Option<i64>,
|
||||
#[case] username: Option<&str>,
|
||||
#[case] password: Option<&str>,
|
||||
) {
|
||||
let resolved = interpret_kwargs(json!({"url": url})).unwrap();
|
||||
|
||||
let Target::Url(parsed) = resolved.target else {
|
||||
panic!("expected a url target, got {:?}", resolved.target);
|
||||
};
|
||||
assert_eq!(parsed.db, db);
|
||||
assert_eq!(expose(parsed.username.as_ref()), username);
|
||||
assert_eq!(expose(parsed.password.as_ref()), password);
|
||||
assert!(resolved.ignored.is_empty(), "{:?}", resolved.ignored);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_url_query_db_that_is_not_an_integer_fails_like_parse_url() {
|
||||
let error = interpret_kwargs(json!({"url": "redis://cache:6379?db=first"})).unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, RedisError::Invalid { setting, expected: Expected::Integer } if setting == "url ?db")
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::tls_1_2_is_already_met(json!("TLSv1_2"), false)]
|
||||
#[case::the_enum_name(json!("TLSVersion.TLSv1_2"), false)]
|
||||
#[case::the_enum_value(json!(771), false)]
|
||||
#[case::tls_1_3_is_not_enforced(json!("TLSv1_3"), true)]
|
||||
fn ssl_min_version_warns_only_when_rustls_cannot_meet_it(
|
||||
#[case] version: serde_json::Value,
|
||||
#[case] warns: bool,
|
||||
) {
|
||||
let resolved =
|
||||
interpret_kwargs(json!({"host": "redis", "ssl": true, "ssl_min_version": version}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
!resolved.ignored.is_empty(),
|
||||
warns,
|
||||
"{:?}",
|
||||
resolved.ignored
|
||||
);
|
||||
}
|
||||
|
||||
fn tls_view(tls: Option<&TlsOptions>) -> serde_json::Value {
|
||||
tls.map_or(serde_json::Value::Null, |tls| {
|
||||
serde_json::json!({
|
||||
"ca_certs": tls.ca_certs,
|
||||
"ca_data": tls.ca_data,
|
||||
"ca_path": tls.ca_path,
|
||||
"certfile": tls.certfile,
|
||||
"keyfile": tls.keyfile,
|
||||
"verify_certificate": tls.verify_certificate,
|
||||
"check_hostname": tls.check_hostname,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn nodes_view(nodes: &[Node]) -> Vec<String> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| format!("{}:{}", node.host, node.port))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expose(value: Option<&litellm_secret_value::SecretValue>) -> Option<&str> {
|
||||
value.map(litellm_secret_value::SecretValue::expose)
|
||||
}
|
||||
|
||||
fn view(resolved: &Resolution) -> serde_json::Value {
|
||||
let target = match &resolved.target {
|
||||
Target::Unix(socket) => serde_json::json!({
|
||||
"mode": "unix", "path": socket.path, "tls": null,
|
||||
}),
|
||||
Target::Standalone(host) => serde_json::json!({
|
||||
"mode": "host",
|
||||
"host": host.host,
|
||||
"port": host.port,
|
||||
"db": host.db,
|
||||
"tls": tls_view(host.tls.as_ref()),
|
||||
}),
|
||||
Target::Url(url) => serde_json::json!({
|
||||
"mode": "url",
|
||||
"url": url.url.expose(),
|
||||
"tls": tls_view(url.tls.as_ref()),
|
||||
}),
|
||||
Target::Cluster(cluster) => serde_json::json!({
|
||||
"mode": "cluster",
|
||||
"nodes": nodes_view(&cluster.nodes),
|
||||
"tls": tls_view(cluster.tls.as_ref()),
|
||||
}),
|
||||
Target::Sentinel(sentinel) => serde_json::json!({
|
||||
"mode": "sentinel",
|
||||
"nodes": nodes_view(&sentinel.monitors),
|
||||
"service_name": sentinel.service_name,
|
||||
"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 {
|
||||
Auth::Static(StaticAuth { username, password }) => serde_json::json!({
|
||||
"kind": "static",
|
||||
"username": expose(username.as_ref()),
|
||||
"password": expose(password.as_ref()),
|
||||
}),
|
||||
Auth::Provider(_) => serde_json::json!({"kind": "host"}),
|
||||
Auth::GcpIam { service_account } => {
|
||||
serde_json::json!({"kind": "gcp_iam", "service_account": service_account})
|
||||
}
|
||||
Auth::AzureAd(azure) => {
|
||||
serde_json::json!({"kind": "azure_ad", "username": azure.username})
|
||||
}
|
||||
Auth::AwsIam(aws) => serde_json::json!({
|
||||
"kind": "aws_iam",
|
||||
"user_name": aws.user_name,
|
||||
"cache_name": aws.cache_name,
|
||||
"region": aws.region,
|
||||
"serverless": aws.serverless,
|
||||
}),
|
||||
};
|
||||
let serde_json::Value::Object(target) = target else {
|
||||
unreachable!("every target view is an object")
|
||||
};
|
||||
let rest = serde_json::json!({
|
||||
"auth": auth,
|
||||
"socket_timeout": resolved.timeout.map_or(5.0, |timeout| timeout.as_secs_f64()),
|
||||
"socket_connect_timeout": resolved.connect_timeout.or(resolved.timeout).map_or(5.0, |timeout| timeout.as_secs_f64()),
|
||||
"health_check_interval": match (&resolved.target, resolved.health_check) {
|
||||
(_, HealthCheck::Every(interval)) => Some(interval.as_secs_f64()),
|
||||
(Target::Cluster(_), HealthCheck::Unset) => Some(25.0),
|
||||
(_, HealthCheck::Unset | HealthCheck::Disabled) => None,
|
||||
},
|
||||
});
|
||||
let serde_json::Value::Object(rest) = rest else {
|
||||
unreachable!("the view is an object")
|
||||
};
|
||||
serde_json::Value::Object(target.into_iter().chain(rest).collect())
|
||||
}
|
||||
|
||||
fn expected_view(fields: serde_json::Value) -> serde_json::Value {
|
||||
let serde_json::Value::Object(fields) = fields else {
|
||||
unreachable!("expected views are objects")
|
||||
};
|
||||
let defaults = json!({
|
||||
"auth": {"kind": "static", "username": null, "password": null},
|
||||
"tls": null,
|
||||
"socket_timeout": 5.0,
|
||||
"socket_connect_timeout": 5.0,
|
||||
"health_check_interval": null,
|
||||
});
|
||||
let serde_json::Value::Object(defaults) = defaults else {
|
||||
unreachable!("the defaults are an object")
|
||||
};
|
||||
serde_json::Value::Object(defaults.into_iter().chain(fields).collect())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::host_defaults(
|
||||
json!({"host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::empty_cluster_nodes_use_the_environment(
|
||||
json!({"startup_nodes": "[{\"host\": \"node-a\", \"port\": 7000}]"}),
|
||||
&[("startup_nodes", "REDIS_CLUSTER_NODES")],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::empty_sentinel_nodes_use_the_environment(
|
||||
json!({"sentinel_nodes": "[[\"sentinel-a\", 26379]]", "service_name": "primary"}),
|
||||
&[("sentinel_nodes", "REDIS_SENTINEL_NODES")],
|
||||
Ok(expected_view(json!({"mode": "sentinel", "nodes": ["sentinel-a:26379"], "service_name": "primary", "sentinel_password": null, "db": 0}))),
|
||||
)]
|
||||
#[case::empty_service_name_uses_the_environment(
|
||||
json!({"sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary"}),
|
||||
&[("service_name", "REDIS_SERVICE_NAME")],
|
||||
Ok(expected_view(json!({"mode": "sentinel", "nodes": ["sentinel-a:26379"], "service_name": "primary", "sentinel_password": null, "db": 0}))),
|
||||
)]
|
||||
#[case::plaintext_ignores_tls_requirements(
|
||||
json!({"host": "redis", "ssl_cert_reqs": "strict"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::an_ambiguous_flag_is_refused(
|
||||
json!({"host": "redis", "ssl": "on"}),
|
||||
&[],
|
||||
Err("invalid Redis setting ssl: expected a boolean"),
|
||||
)]
|
||||
#[case::empty_sentinel_nodes_without_the_environment_name_no_target(
|
||||
json!({"sentinel_nodes": [], "service_name": "primary"}),
|
||||
&[],
|
||||
Err("either host or url must be specified for redis"),
|
||||
)]
|
||||
#[case::ssl_ca_path_is_unsupported(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ca_path": "/ca"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": "/ca", "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::ssl_validate_ocsp_is_unsupported(
|
||||
json!({"host": "redis", "ssl": true, "ssl_validate_ocsp": true}),
|
||||
&[],
|
||||
Err("ssl_validate_ocsp asks for OCSP revocation checks, which rustls cannot perform, so Redis is not connected without them"),
|
||||
)]
|
||||
#[case::ssl_password_is_unsupported(
|
||||
json!({"host": "redis", "ssl": true, "ssl_certfile": "/client.pem", "ssl_password": "key-secret"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": "/client.pem", "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::host_with_every_basic_setting(
|
||||
json!({"db": 2, "host": "redis", "password": "pass", "port": 6380, "socket_timeout": 1.5, "ssl": true, "username": "user"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6380, "db": 2, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "static", "username": "user", "password": "pass"}, "socket_timeout": 1.5, "socket_connect_timeout": 1.5}))),
|
||||
)]
|
||||
#[case::environment_host_port_and_password(
|
||||
json!({"port": "6390", "ssl": true, "host": "environment", "password": "environment-secret"}),
|
||||
&[("host", "REDIS_HOST"), ("password", "REDIS_PASSWORD"), ("port", "REDIS_PORT"), ("ssl", "REDIS_SSL")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "environment", "port": 6390, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "static", "username": null, "password": "environment-secret"}}))),
|
||||
)]
|
||||
#[case::string_numbers_are_coerced(
|
||||
json!({"db": "3", "host": "redis", "port": "6380", "socket_timeout": "2.5"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6380, "db": 3, "socket_timeout": 2.5, "socket_connect_timeout": 2.5}))),
|
||||
)]
|
||||
#[case::padded_and_underscored_numbers_parse_like_python_int_and_float(
|
||||
json!({"host": "redis", "port": "6380\n", "db": " 2 ", "socket_timeout": "1_0.5\n"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"db": 2, "host": "redis", "mode": "host", "port": 6380, "socket_timeout": 10.5, "socket_connect_timeout": 10.5}))),
|
||||
)]
|
||||
#[case::padded_environment_numbers_parse_like_python_int(
|
||||
json!({"port": "6_380", "host": "redis", "db": "3\n"}),
|
||||
&[("db", "REDIS_DB"), ("host", "REDIS_HOST"), ("port", "REDIS_PORT")],
|
||||
Ok(expected_view(json!({"db": 3, "host": "redis", "mode": "host", "port": 6380}))),
|
||||
)]
|
||||
#[case::socket_timeout_from_the_environment(
|
||||
json!({"socket_timeout": "0.75", "host": "redis"}),
|
||||
&[("socket_timeout", "REDIS_SOCKET_TIMEOUT")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "socket_timeout": 0.75, "socket_connect_timeout": 0.75}))),
|
||||
)]
|
||||
#[case::environment_url(
|
||||
json!({"url": "redis://environment:6379/1"}),
|
||||
&[("url", "REDIS_URL")],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "redis://environment:6379/1"}))),
|
||||
)]
|
||||
#[case::environment_url_beats_the_environment_host(
|
||||
json!({"host": "ignored", "url": "redis://from-url:6379"}),
|
||||
&[("host", "REDIS_HOST"), ("url", "REDIS_URL")],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "redis://from-url:6379"}))),
|
||||
)]
|
||||
#[case::an_explicit_host_drops_the_environment_url(
|
||||
json!({"host": "configured"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "configured", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::a_url_drops_host_db_and_password_and_keeps_the_username(
|
||||
json!({"db": 4, "host": "dropped", "password": "dropped", "url": "rediss://cache:6380", "username": "user"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache:6380", "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "static", "username": "user", "password": null}}))),
|
||||
)]
|
||||
#[case::a_url_ignores_tls_settings_outside_the_url(
|
||||
json!({"ssl_ca_certs": "/ca.pem", "ssl_cert_reqs": "none", "ssl_check_hostname": true, "url": "rediss://cache:6380"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache:6380", "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::startup_nodes_beat_a_url(
|
||||
json!({"password": "cluster-secret", "startup_nodes": [{"host": "node-a", "port": 7000}], "url": "redis://single:6379"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "auth": {"kind": "static", "username": null, "password": "cluster-secret"}, "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::environment_cluster_nodes(
|
||||
json!({"startup_nodes": "[{\"host\": \"node-a\", \"port\": \"7000\"}, {\"host\": \"node-b\", \"port\": 7001}]"}),
|
||||
&[("startup_nodes", "REDIS_CLUSTER_NODES")],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000", "node-b:7001"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::cluster_tls_options(
|
||||
json!({"ssl": "true", "ssl_ca_certs": "/ca.pem", "ssl_check_hostname": true, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "tls": {"ca_certs": "/ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": true}, "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::sentinel_from_settings(
|
||||
json!({"password": "data-secret", "sentinel_nodes": [["sentinel-a", 26379], ["sentinel-b", 26380]], "sentinel_password": "sentinel-secret", "service_name": "primary"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "sentinel", "nodes": ["sentinel-a:26379", "sentinel-b:26380"], "service_name": "primary", "sentinel_password": "sentinel-secret", "db": 0, "auth": {"kind": "static", "username": null, "password": "data-secret"}}))),
|
||||
)]
|
||||
#[case::sentinel_from_the_environment(
|
||||
json!({"sentinel_nodes": "[[\"sentinel-a\", 26379]]", "sentinel_password": "sentinel-secret", "service_name": "primary"}),
|
||||
&[("sentinel_nodes", "REDIS_SENTINEL_NODES"), ("sentinel_password", "REDIS_SENTINEL_PASSWORD"), ("service_name", "REDIS_SERVICE_NAME")],
|
||||
Ok(expected_view(json!({"mode": "sentinel", "nodes": ["sentinel-a:26379"], "service_name": "primary", "sentinel_password": "sentinel-secret", "db": 0}))),
|
||||
)]
|
||||
#[case::a_host_breaks_sentinel(
|
||||
json!({"host": "ignored", "sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary"}),
|
||||
&[],
|
||||
Err("host cannot be combined with sentinel_nodes, which redis-py rejects at startup"),
|
||||
)]
|
||||
#[case::a_url_beats_sentinels(
|
||||
json!({"sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary", "url": "redis://single:6379"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "redis://single:6379"}))),
|
||||
)]
|
||||
#[case::an_explicit_credential_provider_beats_cloud_auth(
|
||||
json!({"aws_iam_auth": true, "credential_provider": true, "gcp_service_account": "account", "host": "redis", "password": "dropped"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "auth": {"kind": "host"}}))),
|
||||
)]
|
||||
#[case::gcp_beats_azure_and_aws(
|
||||
json!({"gcp_service_account": "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com", "aws_iam_auth": true, "azure_redis_ad_token": "True", "host": "redis", "password": "dropped"}),
|
||||
&[("gcp_service_account", "REDIS_GCP_SERVICE_ACCOUNT")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "auth": {"kind": "gcp_iam", "service_account": "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com"}}))),
|
||||
)]
|
||||
#[case::gcp_ca_certs_replace_the_configured_ones_under_ssl(
|
||||
json!({"gcp_service_account": "account", "gcp_ssl_ca_certs": "/gcp-ca.pem", "host": "redis", "ssl": true, "ssl_ca_certs": "/configured-ca.pem"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": "/gcp-ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "gcp_iam", "service_account": "account"}}))),
|
||||
)]
|
||||
#[case::azure_beats_aws_and_takes_the_environment_username(
|
||||
json!({"aws_iam_auth": true, "azure_client_id": "client", "azure_client_secret": "secret", "azure_redis_ad_token": true, "azure_tenant_id": "tenant", "host": "redis", "username": "dropped"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "auth": {"kind": "azure_ad", "username": null}}))),
|
||||
)]
|
||||
#[case::azure_without_a_username_sends_only_the_token(
|
||||
json!({"azure_client_id": "client", "azure_client_secret": "secret", "azure_redis_ad_token": true, "azure_tenant_id": "tenant", "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "auth": {"kind": "azure_ad", "username": null}}))),
|
||||
)]
|
||||
#[case::azure_needs_the_literal_true(
|
||||
json!({"azure_client_id": "client", "azure_client_secret": "secret", "azure_redis_ad_token": "1", "azure_tenant_id": "tenant", "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::aws_iam_over_tls_with_the_default_region(
|
||||
json!({"aws_iam_auth": "yes", "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "password": "dropped", "ssl": true, "aws_iam_region": "us-east-1"}),
|
||||
&[("aws_iam_region", "AWS_DEFAULT_REGION")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "cache-1", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "aws_iam", "user_name": "iam-user", "cache_name": "cache-1", "region": "us-east-1", "serverless": false}}))),
|
||||
)]
|
||||
#[case::aws_region_beats_the_default_region(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "ssl": true, "aws_iam_region": "eu-central-1"}),
|
||||
&[("aws_iam_region", "AWS_REGION")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "cache-1", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "aws_iam", "user_name": "iam-user", "cache_name": "cache-1", "region": "eu-central-1", "serverless": false}}))),
|
||||
)]
|
||||
#[case::aws_iam_serverless_over_a_rediss_url_drops_its_userinfo(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_region": "eu-west-1", "aws_iam_serverless": true, "aws_iam_user_name": "iam-user", "url": "rediss://user:pass@cache-1:6379"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache-1:6379", "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}, "auth": {"kind": "aws_iam", "user_name": "iam-user", "cache_name": "cache-1", "region": "eu-west-1", "serverless": true}}))),
|
||||
)]
|
||||
#[case::aws_iam_without_tls_fails(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_region": "us-east-1", "aws_iam_user_name": "iam-user", "host": "cache-1"}),
|
||||
&[],
|
||||
Err("AWS ElastiCache IAM Redis authentication requires TLS"),
|
||||
)]
|
||||
#[case::aws_iam_without_a_region_fails(
|
||||
json!({"aws_iam_auth": true, "aws_iam_cache_name": "cache-1", "aws_iam_user_name": "iam-user", "host": "cache-1", "ssl": true}),
|
||||
&[],
|
||||
Err("AWS ElastiCache IAM Redis authentication requires: aws_iam_region"),
|
||||
)]
|
||||
#[case::tls_options_from_settings(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ca_certs": "/ca.pem", "ssl_cert_reqs": "none", "ssl_check_hostname": true}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": "/ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": false, "check_hostname": true}}))),
|
||||
)]
|
||||
#[case::tls_options_from_the_environment(
|
||||
json!({"ssl_ca_certs": "/env-ca.pem", "ssl": true, "ssl_cert_reqs": "optional", "host": "redis"}),
|
||||
&[("ssl", "REDIS_SSL"), ("ssl_ca_certs", "REDIS_SSL_CA_CERTS"), ("ssl_cert_reqs", "REDIS_SSL_CERT_REQS")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": "/env-ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::connect_timeout_is_separate_from_the_socket_timeout(
|
||||
json!({"host": "redis", "socket_connect_timeout": 0.25, "socket_timeout": 2.5}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "socket_timeout": 2.5, "socket_connect_timeout": 0.25}))),
|
||||
)]
|
||||
#[case::environment_references_resolve(
|
||||
json!({"host": "referenced", "password": "referenced-secret"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "referenced", "port": 6379, "db": 0, "auth": {"kind": "static", "username": null, "password": "referenced-secret"}}))),
|
||||
)]
|
||||
#[case::no_target_fails(
|
||||
json!({"port": 6379}),
|
||||
&[],
|
||||
Err("either host or url must be specified for redis"),
|
||||
)]
|
||||
#[case::client_certificates(
|
||||
json!({"host": "redis", "ssl": true, "ssl_certfile": "/client.pem", "ssl_keyfile": "/client.key"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": "/client.pem", "keyfile": "/client.key", "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::a_rediss_url_takes_tls_options_from_its_query(
|
||||
json!({"url": "rediss://cache:6380/0?ssl_cert_reqs=none&ssl_ca_certs=/ca.pem&ssl_check_hostname=yes"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache:6380/0?ssl_cert_reqs=none&ssl_ca_certs=/ca.pem&ssl_check_hostname=yes", "tls": {"ca_certs": "/ca.pem", "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": false, "check_hostname": true}}))),
|
||||
)]
|
||||
#[case::a_url_query_setting_without_a_redis_rs_equivalent(
|
||||
json!({"url": "redis://cache:6379/0?socket_timeout=2"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "redis://cache:6379/0?socket_timeout=2"}))),
|
||||
)]
|
||||
#[case::tls_settings_outside_a_url_never_fail_it(
|
||||
json!({"db": "99999999999999999999", "ssl_cert_reqs": "strict", "url": "rediss://cache:6380"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "url", "url": "rediss://cache:6380", "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::db_is_not_read_for_a_cluster(
|
||||
json!({"db": 99_999_999_999_i64, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::cluster_db_true(
|
||||
json!({"db": true, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::cluster_db_false(
|
||||
json!({"db": false, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::cluster_db_fraction(
|
||||
json!({"db": 0.5, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::cluster_db_float_zero(
|
||||
json!({"db": 0.0, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::cluster_db_nonempty_list(
|
||||
json!({"db": [1], "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::cluster_db_empty_list(
|
||||
json!({"db": [], "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"], "health_check_interval": 25.0}))),
|
||||
)]
|
||||
#[case::ssl_ciphers(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ciphers": "HIGH"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::retry_on_timeout(
|
||||
json!({"host": "redis", "retry_on_timeout": true}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::malformed_port(
|
||||
json!({"host": "redis", "port": "abc"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::malformed_db(
|
||||
json!({"db": "first", "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::socket_timeout_not_a_number(
|
||||
json!({"host": "redis", "socket_timeout": "slow"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::malformed_port_from_the_environment(
|
||||
json!({"port": "abc", "host": "redis"}),
|
||||
&[("host", "REDIS_HOST"), ("port", "REDIS_PORT")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::protocol_3(
|
||||
json!({"host": "redis", "protocol": 3}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::retry_on_error_from_the_environment(
|
||||
json!({"retry_on_error": true, "host": "redis"}),
|
||||
&[("retry_on_error", "REDIS_RETRY_ON_ERROR")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::unix_socket_path(
|
||||
json!({"host": "redis", "unix_socket_path": "/tmp/redis.sock"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "unix", "path": "/tmp/redis.sock"}))),
|
||||
)]
|
||||
#[case::unix_socket_path_keeps_tls_settings_out_of_the_target(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ca_certs": "/ca.pem", "unix_socket_path": "/tmp/redis.sock"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "unix", "path": "/tmp/redis.sock"}))),
|
||||
)]
|
||||
#[case::ssl_ca_data(
|
||||
json!({"host": "redis", "ssl": true, "ssl_ca_data": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----", "ca_path": null, "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::ssl_ca_path_from_the_environment(
|
||||
json!({"ssl": true, "ssl_ca_path": "/etc/ssl/private-cas", "host": "redis"}),
|
||||
&[("ssl", "REDIS_SSL"), ("ssl_ca_path", "REDIS_SSL_CA_PATH")],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "tls": {"ca_certs": null, "ca_data": null, "ca_path": "/etc/ssl/private-cas", "certfile": null, "keyfile": null, "verify_certificate": true, "check_hostname": false}}))),
|
||||
)]
|
||||
#[case::health_check_interval_is_whole_seconds(
|
||||
json!({"health_check_interval": "7", "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0, "health_check_interval": 7.0}))),
|
||||
)]
|
||||
#[case::health_check_interval_zero_is_off(
|
||||
json!({"health_check_interval": 0, "host": "redis"}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "host", "host": "redis", "port": 6379, "db": 0}))),
|
||||
)]
|
||||
#[case::health_check_interval_zero_keeps_a_cluster_off(
|
||||
json!({"health_check_interval": 0, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Ok(expected_view(json!({"mode": "cluster", "nodes": ["node-a:7000"]}))),
|
||||
)]
|
||||
#[case::a_cluster_rejects_a_nonzero_db(
|
||||
json!({"db": 3, "startup_nodes": [{"host": "node-a", "port": 7000}]}),
|
||||
&[],
|
||||
Err("db must be 0 in cluster mode"),
|
||||
)]
|
||||
#[case::unix_socket_path_without_a_host_fails_like_python(
|
||||
json!({"unix_socket_path": "/tmp/redis.sock"}),
|
||||
&[],
|
||||
Err("either host or url must be specified for redis"),
|
||||
)]
|
||||
fn resolves_like_python_redis(
|
||||
#[case] merged: serde_json::Value,
|
||||
#[case] sources: &[(&str, &str)],
|
||||
#[case] expected: Result<serde_json::Value, &str>,
|
||||
) {
|
||||
let outcome = interpret_merged(merged, sources)
|
||||
.map(|resolved| view(&resolved))
|
||||
.map_err(|error| error.to_string());
|
||||
|
||||
assert_eq!(outcome, expected.map_err(str::to_owned));
|
||||
}
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
- `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`
|
||||
- `Resolution` and the target, node, TLS and auth types inside it: what `redis-resolve` interprets merged kwargs into, `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 `redis-resolve` never pulls in redis-rs or tokio. Anything that needs redis-rs, like building a `ConnectionInfo` or reading TLS files, lives in `redis-client`
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import json
|
|||
# s/o [@Frank Colson](https://www.linkedin.com/in/frank-colson-422b9b183/) for this redis implementation
|
||||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from functools import reduce
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
|
@ -300,9 +301,7 @@ def _uses_tls(redis_kwargs: Mapping[str, object]) -> bool:
|
|||
def _build_elasticache_iam_provider(redis_kwargs: Mapping[str, object]) -> ElastiCacheIAMCredentialProvider:
|
||||
user_name: Final = redis_kwargs.get("aws_iam_user_name")
|
||||
cache_name: Final = redis_kwargs.get("aws_iam_cache_name")
|
||||
region: Final = (
|
||||
redis_kwargs.get("aws_iam_region") or get_secret_str("AWS_REGION") or get_secret_str("AWS_DEFAULT_REGION")
|
||||
)
|
||||
region: Final = redis_kwargs.get("aws_iam_region")
|
||||
required_settings: Final = (
|
||||
("aws_iam_user_name", user_name),
|
||||
("aws_iam_cache_name", cache_name),
|
||||
|
|
@ -510,36 +509,80 @@ def _url_without_userinfo(url: str) -> str:
|
|||
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
def _setting_text(redis_kwargs: Mapping[str, object], setting: str) -> str | None:
|
||||
value: Final = redis_kwargs.get(setting)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _or_secrets(value: object, read: Callable[[str], object], *names: str) -> object:
|
||||
return reduce(lambda found, name: found or read(name), names, value)
|
||||
|
||||
|
||||
def _merge_redis_kwargs(
|
||||
env_overrides: Mapping[str, object],
|
||||
) -> dict[str, object]: # mutable-ok: _get_redis_client_logic swaps settings for client objects in place
|
||||
overrides: Final = {
|
||||
k: get_secret(v.replace("os.environ/", "")) if isinstance(v, str) and v.startswith("os.environ/") else v
|
||||
for k, v in env_overrides.items()
|
||||
}
|
||||
# An explicitly configured connection target outranks REDIS_URL from the
|
||||
# environment. Without this, the url branch strips the caller's
|
||||
# host/port/password and silently connects to whatever REDIS_URL names.
|
||||
caller_named_a_target: Final = any(
|
||||
overrides.get(key) is not None for key in ("host", "startup_nodes", "sentinel_nodes")
|
||||
)
|
||||
drops_environment_url: Final = caller_named_a_target and overrides.get("url") is None
|
||||
layered: Final = {
|
||||
**{k: v for k, v in _redis_kwargs_from_environment().items() if not (k == "url" and drops_environment_url)},
|
||||
**overrides,
|
||||
}
|
||||
|
||||
def fallback(setting: str, read: Callable[[str], object], *names: str) -> tuple[str, object]:
|
||||
return setting, _or_secrets(layered.get(setting), read, *names)
|
||||
|
||||
targets: Final = (
|
||||
fallback("startup_nodes", get_secret, "REDIS_CLUSTER_NODES"),
|
||||
fallback("sentinel_nodes", get_secret, "REDIS_SENTINEL_NODES"),
|
||||
fallback("sentinel_password", get_secret_str, "REDIS_SENTINEL_PASSWORD"),
|
||||
fallback("service_name", get_secret, "REDIS_SERVICE_NAME"),
|
||||
)
|
||||
if layered.get("credential_provider") is not None:
|
||||
return {**layered, **{k: v for k, v in targets if v is not None}}
|
||||
|
||||
identities: Final = dict(
|
||||
(
|
||||
fallback("gcp_service_account", get_secret_str, "REDIS_GCP_SERVICE_ACCOUNT"),
|
||||
fallback("gcp_ssl_ca_certs", get_secret_str, "REDIS_GCP_SSL_CA_CERTS"),
|
||||
fallback("azure_redis_ad_token", get_secret, "REDIS_AZURE_AD_TOKEN"),
|
||||
)
|
||||
)
|
||||
gcp_enabled: Final = identities["gcp_service_account"] is not None
|
||||
azure_enabled: Final = str(identities["azure_redis_ad_token"]).lower() == "true"
|
||||
azure: Final = (
|
||||
(
|
||||
fallback("azure_client_id", get_secret_str, "AZURE_CLIENT_ID"),
|
||||
fallback("azure_tenant_id", get_secret_str, "AZURE_TENANT_ID"),
|
||||
fallback("azure_client_secret", get_secret_str, "AZURE_CLIENT_SECRET"),
|
||||
)
|
||||
if azure_enabled and not gcp_enabled
|
||||
else ()
|
||||
)
|
||||
aws: Final = (
|
||||
(fallback("aws_iam_region", get_secret_str, "AWS_REGION", "AWS_DEFAULT_REGION"),)
|
||||
if _coerces_to_true(layered.get("aws_iam_auth")) and not gcp_enabled and not azure_enabled
|
||||
else ()
|
||||
)
|
||||
filled: Final = (*targets, *identities.items(), *azure, *aws)
|
||||
return {**layered, **{k: v for k, v in filled if v is not None}}
|
||||
|
||||
|
||||
def _get_redis_client_logic(**env_overrides):
|
||||
"""
|
||||
Common functionality across sync + async redis client implementations
|
||||
"""
|
||||
### check if "os.environ/<key-name>" passed in
|
||||
for k, v in env_overrides.items():
|
||||
if isinstance(v, str) and v.startswith("os.environ/"):
|
||||
v = v.replace("os.environ/", "")
|
||||
value = get_secret(v)
|
||||
env_overrides[k] = value
|
||||
redis_kwargs: Final = _merge_redis_kwargs(env_overrides)
|
||||
|
||||
environment_kwargs: Final = _redis_kwargs_from_environment()
|
||||
|
||||
# An explicitly configured connection target outranks REDIS_URL from the
|
||||
# environment. Without this, the url branch below strips the caller's
|
||||
# host/port/password and silently connects to whatever REDIS_URL names.
|
||||
caller_named_a_target: Final = any(
|
||||
env_overrides.get(key) is not None for key in ("host", "startup_nodes", "sentinel_nodes")
|
||||
)
|
||||
if caller_named_a_target and env_overrides.get("url") is None:
|
||||
environment_kwargs.pop("url", None)
|
||||
|
||||
redis_kwargs: Final = {
|
||||
**environment_kwargs,
|
||||
**env_overrides,
|
||||
}
|
||||
|
||||
_startup_nodes: Final[str | list | None] = redis_kwargs.get("startup_nodes", None) or get_secret(
|
||||
"REDIS_CLUSTER_NODES"
|
||||
)
|
||||
_startup_nodes: Final = redis_kwargs.get("startup_nodes") or None
|
||||
|
||||
# If startup_nodes resolved to None (not set by kwarg or env), remove the key
|
||||
# entirely so callers can rely on key presence as a reliable cluster-mode signal.
|
||||
|
|
@ -548,31 +591,14 @@ def _get_redis_client_logic(**env_overrides):
|
|||
elif _startup_nodes is None:
|
||||
redis_kwargs.pop("startup_nodes", None)
|
||||
|
||||
_sentinel_nodes: Final[str | list | None] = redis_kwargs.get("sentinel_nodes", None) or get_secret(
|
||||
"REDIS_SENTINEL_NODES"
|
||||
)
|
||||
|
||||
if _sentinel_nodes is not None and isinstance(_sentinel_nodes, str):
|
||||
_sentinel_nodes: Final = redis_kwargs.get("sentinel_nodes")
|
||||
if isinstance(_sentinel_nodes, str):
|
||||
redis_kwargs["sentinel_nodes"] = json.loads(_sentinel_nodes)
|
||||
|
||||
_sentinel_password: Final[str | None] = redis_kwargs.get("sentinel_password", None) or get_secret_str(
|
||||
"REDIS_SENTINEL_PASSWORD"
|
||||
)
|
||||
|
||||
if _sentinel_password is not None:
|
||||
redis_kwargs["sentinel_password"] = _sentinel_password
|
||||
|
||||
_service_name: Final[str | None] = redis_kwargs.get("service_name", None) or get_secret("REDIS_SERVICE_NAME")
|
||||
|
||||
if _service_name is not None:
|
||||
redis_kwargs["service_name"] = _service_name
|
||||
|
||||
if redis_kwargs.get("credential_provider") is None:
|
||||
# Handle GCP IAM authentication
|
||||
_gcp_service_account: Final = redis_kwargs.get("gcp_service_account") or get_secret_str(
|
||||
"REDIS_GCP_SERVICE_ACCOUNT"
|
||||
)
|
||||
_gcp_ssl_ca_certs: Final = redis_kwargs.get("gcp_ssl_ca_certs") or get_secret_str("REDIS_GCP_SSL_CA_CERTS")
|
||||
_gcp_service_account: Final = _setting_text(redis_kwargs, "gcp_service_account")
|
||||
_gcp_ssl_ca_certs: Final = _setting_text(redis_kwargs, "gcp_ssl_ca_certs")
|
||||
|
||||
if _gcp_service_account is not None:
|
||||
verbose_logger.debug("Setting up GCP IAM authentication for Redis with service account.")
|
||||
|
|
@ -587,7 +613,7 @@ def _get_redis_client_logic(**env_overrides):
|
|||
redis_kwargs["ssl_ca_certs"] = _gcp_ssl_ca_certs
|
||||
|
||||
# Handle Azure AD authentication (after GCP IAM block)
|
||||
_azure_redis_ad_token: Final = redis_kwargs.get("azure_redis_ad_token") or get_secret("REDIS_AZURE_AD_TOKEN")
|
||||
_azure_redis_ad_token: Final = redis_kwargs.get("azure_redis_ad_token") or None
|
||||
|
||||
_azure_ad_enabled: Final = _azure_redis_ad_token is not None and str(_azure_redis_ad_token).lower() == "true"
|
||||
_aws_iam_enabled: Final = _coerces_to_true(redis_kwargs.get("aws_iam_auth"))
|
||||
|
|
@ -599,11 +625,9 @@ def _get_redis_client_logic(**env_overrides):
|
|||
)
|
||||
|
||||
if _azure_ad_enabled and _gcp_service_account is None:
|
||||
_azure_client_id: Final = redis_kwargs.get("azure_client_id") or get_secret_str("AZURE_CLIENT_ID")
|
||||
_azure_tenant_id: Final = redis_kwargs.get("azure_tenant_id") or get_secret_str("AZURE_TENANT_ID")
|
||||
_azure_client_secret: Final = redis_kwargs.get("azure_client_secret") or get_secret_str(
|
||||
"AZURE_CLIENT_SECRET"
|
||||
)
|
||||
_azure_client_id: Final = _setting_text(redis_kwargs, "azure_client_id")
|
||||
_azure_tenant_id: Final = _setting_text(redis_kwargs, "azure_tenant_id")
|
||||
_azure_client_secret: Final = _setting_text(redis_kwargs, "azure_client_secret")
|
||||
|
||||
verbose_logger.debug("Setting up Azure AD authentication for Redis.")
|
||||
redis_kwargs["redis_connect_func"] = create_azure_ad_redis_connect_func(
|
||||
|
|
@ -649,8 +673,9 @@ def _get_redis_client_logic(**env_overrides):
|
|||
redis_kwargs.pop("redis_connect_func", None)
|
||||
redis_kwargs.pop("username", None)
|
||||
redis_kwargs.pop("password", None)
|
||||
if redis_kwargs.get("url") is not None:
|
||||
redis_kwargs["url"] = _url_without_userinfo(redis_kwargs["url"])
|
||||
_url: Final = redis_kwargs.get("url")
|
||||
if isinstance(_url, str):
|
||||
redis_kwargs["url"] = _url_without_userinfo(_url)
|
||||
|
||||
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
|
||||
# Only strip host/port/db/password when not routing to a cluster.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from litellm._redis import (
|
|||
_get_redis_env_kwarg_mapping,
|
||||
_get_redis_kwargs,
|
||||
_get_redis_url_kwargs,
|
||||
_merge_redis_kwargs,
|
||||
_pretty_print_redis_config,
|
||||
_uses_tls,
|
||||
get_redis_async_client,
|
||||
|
|
@ -2057,26 +2058,29 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
|
||||
@pytest.mark.skipif(
|
||||
redis.VERSION != (5, 3, 1),
|
||||
reason="the Rust resolver in litellm-rust/crates/config pins redis-py 5.3.1, per its AGENTS.md",
|
||||
reason="the Rust interpreter in litellm-rust/crates/redis-resolve pins redis-py 5.3.1, per its AGENTS.md",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "environment", "expected"),
|
||||
("kwargs", "environment", "merged", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
{"host": "redis"},
|
||||
{},
|
||||
{"host": "redis"},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="host_defaults",
|
||||
),
|
||||
pytest.param(
|
||||
{"startup_nodes": []},
|
||||
{"REDIS_CLUSTER_NODES": '[{"host": "node-a", "port": 7000}]'},
|
||||
{"startup_nodes": '[{"host": "node-a", "port": 7000}]'},
|
||||
_expected_view({"health_check_interval": 25.0, "mode": "cluster", "nodes": ["node-a:7000"]}),
|
||||
id="empty_cluster_nodes_use_the_environment",
|
||||
),
|
||||
pytest.param(
|
||||
{"sentinel_nodes": [], "service_name": "primary"},
|
||||
{"REDIS_SENTINEL_NODES": '[["sentinel-a", 26379]]'},
|
||||
{"sentinel_nodes": '[["sentinel-a", 26379]]', "service_name": "primary"},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2091,6 +2095,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"sentinel_nodes": [["sentinel-a", 26379]], "service_name": ""},
|
||||
{"REDIS_SERVICE_NAME": "primary"},
|
||||
{"sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary"},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2105,18 +2110,21 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis", "ssl_cert_reqs": "strict"},
|
||||
{},
|
||||
{"host": "redis", "ssl_cert_reqs": "strict"},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="plaintext_ignores_tls_requirements",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis", "ssl": "on"},
|
||||
{},
|
||||
{"host": "redis", "ssl": "on"},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="an_ambiguous_flag_is_refused",
|
||||
),
|
||||
pytest.param(
|
||||
{"sentinel_nodes": [], "service_name": "primary"},
|
||||
{},
|
||||
{"sentinel_nodes": [], "service_name": "primary"},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Both 'sentinel_nodes' and 'service_name' are required for Redis Sentinel.",
|
||||
|
|
@ -2128,6 +2136,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis", "ssl": True, "ssl_ca_path": "/ca"},
|
||||
{},
|
||||
{"host": "redis", "ssl": True, "ssl_ca_path": "/ca"},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Redis.__init__() got an unexpected keyword argument 'ssl_ca_path'",
|
||||
|
|
@ -2139,6 +2148,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis", "ssl": True, "ssl_certfile": "/client.pem", "ssl_password": "key-secret"},
|
||||
{},
|
||||
{"host": "redis", "ssl": True, "ssl_certfile": "/client.pem", "ssl_password": "key-secret"},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Redis.__init__() got an unexpected keyword argument 'ssl_password'",
|
||||
|
|
@ -2150,6 +2160,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis", "ssl": True, "ssl_validate_ocsp": True},
|
||||
{},
|
||||
{"host": "redis", "ssl": True, "ssl_validate_ocsp": True},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Redis.__init__() got an unexpected keyword argument 'ssl_validate_ocsp'",
|
||||
|
|
@ -2169,6 +2180,15 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"username": "user",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"db": 2,
|
||||
"host": "redis",
|
||||
"password": "pass",
|
||||
"port": 6380,
|
||||
"socket_timeout": 1.5,
|
||||
"ssl": True,
|
||||
"username": "user",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {"kind": "static", "password": "pass", "username": "user"},
|
||||
|
|
@ -2199,6 +2219,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"REDIS_PORT": "6390",
|
||||
"REDIS_SSL": "True",
|
||||
},
|
||||
{"port": "6390", "ssl": True, "host": "environment", "password": "environment-secret"},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {"kind": "static", "password": "environment-secret", "username": None},
|
||||
|
|
@ -2222,6 +2243,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"db": "3", "host": "redis", "port": "6380", "socket_timeout": "2.5"},
|
||||
{},
|
||||
{"db": "3", "host": "redis", "port": "6380", "socket_timeout": "2.5"},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 3,
|
||||
|
|
@ -2237,6 +2259,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis", "port": "6380\n", "db": " 2 ", "socket_timeout": "1_0.5\n"},
|
||||
{},
|
||||
{"host": "redis", "port": "6380\n", "db": " 2 ", "socket_timeout": "1_0.5\n"},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 2,
|
||||
|
|
@ -2252,12 +2275,14 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{},
|
||||
{"REDIS_HOST": "redis", "REDIS_PORT": "6_380", "REDIS_DB": "3\n"},
|
||||
{"port": "6_380", "host": "redis", "db": "3\n"},
|
||||
_expected_view({"db": 3, "host": "redis", "mode": "host", "port": 6380}),
|
||||
id="padded_environment_numbers_parse_like_python_int",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis"},
|
||||
{"REDIS_SOCKET_TIMEOUT": "0.75"},
|
||||
{"socket_timeout": "0.75", "host": "redis"},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2273,24 +2298,28 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{},
|
||||
{"REDIS_URL": "redis://environment:6379/1"},
|
||||
{"url": "redis://environment:6379/1"},
|
||||
_expected_view({"mode": "url", "url": "redis://environment:6379/1"}),
|
||||
id="environment_url",
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
{"REDIS_HOST": "ignored", "REDIS_URL": "redis://from-url:6379"},
|
||||
{"host": "ignored", "url": "redis://from-url:6379"},
|
||||
_expected_view({"mode": "url", "url": "redis://from-url:6379"}),
|
||||
id="environment_url_beats_the_environment_host",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "configured"},
|
||||
{"REDIS_URL": "redis://environment:6379"},
|
||||
{"host": "configured"},
|
||||
_expected_view({"db": 0, "host": "configured", "mode": "host", "port": 6379}),
|
||||
id="an_explicit_host_drops_the_environment_url",
|
||||
),
|
||||
pytest.param(
|
||||
{"db": 4, "host": "dropped", "password": "dropped", "url": "rediss://cache:6380", "username": "user"},
|
||||
{},
|
||||
{"db": 4, "host": "dropped", "password": "dropped", "url": "rediss://cache:6380", "username": "user"},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {"kind": "static", "password": None, "username": "user"},
|
||||
|
|
@ -2317,6 +2346,12 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"url": "rediss://cache:6380",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"ssl_ca_certs": "/ca.pem",
|
||||
"ssl_cert_reqs": "none",
|
||||
"ssl_check_hostname": True,
|
||||
"url": "rediss://cache:6380",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"mode": "url",
|
||||
|
|
@ -2341,6 +2376,11 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"url": "redis://single:6379",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"password": "cluster-secret",
|
||||
"startup_nodes": [{"host": "node-a", "port": 7000}],
|
||||
"url": "redis://single:6379",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {"kind": "static", "password": "cluster-secret", "username": None},
|
||||
|
|
@ -2354,6 +2394,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{},
|
||||
{"REDIS_CLUSTER_NODES": '[{"host": "node-a", "port": "7000"}, {"host": "node-b", "port": 7001}]'},
|
||||
{"startup_nodes": '[{"host": "node-a", "port": "7000"}, {"host": "node-b", "port": 7001}]'},
|
||||
_expected_view({"health_check_interval": 25.0, "mode": "cluster", "nodes": ["node-a:7000", "node-b:7001"]}),
|
||||
id="environment_cluster_nodes",
|
||||
),
|
||||
|
|
@ -2365,6 +2406,12 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"startup_nodes": [{"host": "node-a", "port": 7000}],
|
||||
},
|
||||
{},
|
||||
{
|
||||
"ssl": "true",
|
||||
"ssl_ca_certs": "/ca.pem",
|
||||
"ssl_check_hostname": True,
|
||||
"startup_nodes": [{"host": "node-a", "port": 7000}],
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"health_check_interval": 25.0,
|
||||
|
|
@ -2391,6 +2438,12 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"service_name": "primary",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"password": "data-secret",
|
||||
"sentinel_nodes": [["sentinel-a", 26379], ["sentinel-b", 26380]],
|
||||
"sentinel_password": "sentinel-secret",
|
||||
"service_name": "primary",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {"kind": "static", "password": "data-secret", "username": None},
|
||||
|
|
@ -2410,6 +2463,11 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"REDIS_SENTINEL_PASSWORD": "sentinel-secret",
|
||||
"REDIS_SERVICE_NAME": "primary",
|
||||
},
|
||||
{
|
||||
"sentinel_nodes": '[["sentinel-a", 26379]]',
|
||||
"sentinel_password": "sentinel-secret",
|
||||
"service_name": "primary",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2424,6 +2482,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "ignored", "sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary"},
|
||||
{},
|
||||
{"host": "ignored", "sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary"},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "redis.asyncio.client.Redis() got multiple values for keyword argument 'host'",
|
||||
|
|
@ -2435,6 +2494,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary", "url": "redis://single:6379"},
|
||||
{},
|
||||
{"sentinel_nodes": [["sentinel-a", 26379]], "service_name": "primary", "url": "redis://single:6379"},
|
||||
_expected_view({"mode": "url", "url": "redis://single:6379"}),
|
||||
id="a_url_beats_sentinels",
|
||||
),
|
||||
|
|
@ -2447,12 +2507,26 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"password": "dropped",
|
||||
},
|
||||
{"REDIS_AZURE_AD_TOKEN": "true"},
|
||||
{
|
||||
"aws_iam_auth": True,
|
||||
"credential_provider": True,
|
||||
"gcp_service_account": "account",
|
||||
"host": "redis",
|
||||
"password": "dropped",
|
||||
},
|
||||
_expected_view({"auth": {"kind": "host"}, "db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="an_explicit_credential_provider_beats_cloud_auth",
|
||||
),
|
||||
pytest.param(
|
||||
{"aws_iam_auth": True, "azure_redis_ad_token": "True", "host": "redis", "password": "dropped"},
|
||||
{"REDIS_GCP_SERVICE_ACCOUNT": "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com"},
|
||||
{
|
||||
"gcp_service_account": "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com",
|
||||
"aws_iam_auth": True,
|
||||
"azure_redis_ad_token": "True",
|
||||
"host": "redis",
|
||||
"password": "dropped",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {
|
||||
|
|
@ -2476,6 +2550,13 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"ssl_ca_certs": "/configured-ca.pem",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"gcp_service_account": "account",
|
||||
"gcp_ssl_ca_certs": "/gcp-ca.pem",
|
||||
"host": "redis",
|
||||
"ssl": True,
|
||||
"ssl_ca_certs": "/configured-ca.pem",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {"kind": "gcp_iam", "service_account": "account"},
|
||||
|
|
@ -2507,6 +2588,15 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"username": "dropped",
|
||||
},
|
||||
{"REDIS_USERNAME": "environment-user"},
|
||||
{
|
||||
"username": "dropped",
|
||||
"aws_iam_auth": True,
|
||||
"azure_client_id": "client",
|
||||
"azure_client_secret": "secret",
|
||||
"azure_redis_ad_token": True,
|
||||
"azure_tenant_id": "tenant",
|
||||
"host": "redis",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {"kind": "azure_ad", "username": "environment-user"},
|
||||
|
|
@ -2527,6 +2617,13 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"host": "redis",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"azure_client_id": "client",
|
||||
"azure_client_secret": "secret",
|
||||
"azure_redis_ad_token": True,
|
||||
"azure_tenant_id": "tenant",
|
||||
"host": "redis",
|
||||
},
|
||||
_expected_view(
|
||||
{"auth": {"kind": "azure_ad", "username": None}, "db": 0, "host": "redis", "mode": "host", "port": 6379}
|
||||
),
|
||||
|
|
@ -2541,6 +2638,13 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"host": "redis",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"azure_client_id": "client",
|
||||
"azure_client_secret": "secret",
|
||||
"azure_redis_ad_token": "1",
|
||||
"azure_tenant_id": "tenant",
|
||||
"host": "redis",
|
||||
},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="azure_needs_the_literal_true",
|
||||
),
|
||||
|
|
@ -2554,6 +2658,15 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"ssl": True,
|
||||
},
|
||||
{"AWS_DEFAULT_REGION": "us-east-1"},
|
||||
{
|
||||
"aws_iam_auth": "yes",
|
||||
"aws_iam_cache_name": "cache-1",
|
||||
"aws_iam_user_name": "iam-user",
|
||||
"host": "cache-1",
|
||||
"password": "dropped",
|
||||
"ssl": True,
|
||||
"aws_iam_region": "us-east-1",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {
|
||||
|
|
@ -2589,6 +2702,14 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"ssl": True,
|
||||
},
|
||||
{"AWS_DEFAULT_REGION": "us-east-1", "AWS_REGION": "eu-central-1"},
|
||||
{
|
||||
"aws_iam_auth": True,
|
||||
"aws_iam_cache_name": "cache-1",
|
||||
"aws_iam_user_name": "iam-user",
|
||||
"host": "cache-1",
|
||||
"ssl": True,
|
||||
"aws_iam_region": "eu-central-1",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {
|
||||
|
|
@ -2625,6 +2746,14 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"url": "rediss://user:pass@cache-1:6379",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"aws_iam_auth": True,
|
||||
"aws_iam_cache_name": "cache-1",
|
||||
"aws_iam_region": "eu-west-1",
|
||||
"aws_iam_serverless": True,
|
||||
"aws_iam_user_name": "iam-user",
|
||||
"url": "rediss://user:pass@cache-1:6379",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {
|
||||
|
|
@ -2658,6 +2787,13 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"host": "cache-1",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"aws_iam_auth": True,
|
||||
"aws_iam_cache_name": "cache-1",
|
||||
"aws_iam_region": "us-east-1",
|
||||
"aws_iam_user_name": "iam-user",
|
||||
"host": "cache-1",
|
||||
},
|
||||
{"rejected": {"message": "AWS ElastiCache IAM Redis authentication requires TLS", "type": "ValueError"}},
|
||||
id="aws_iam_without_tls_fails",
|
||||
),
|
||||
|
|
@ -2670,6 +2806,13 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"ssl": True,
|
||||
},
|
||||
{},
|
||||
{
|
||||
"aws_iam_auth": True,
|
||||
"aws_iam_cache_name": "cache-1",
|
||||
"aws_iam_user_name": "iam-user",
|
||||
"host": "cache-1",
|
||||
"ssl": True,
|
||||
},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "AWS ElastiCache IAM Redis authentication requires: aws_iam_region",
|
||||
|
|
@ -2687,6 +2830,13 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"ssl_check_hostname": True,
|
||||
},
|
||||
{},
|
||||
{
|
||||
"host": "redis",
|
||||
"ssl": True,
|
||||
"ssl_ca_certs": "/ca.pem",
|
||||
"ssl_cert_reqs": "none",
|
||||
"ssl_check_hostname": True,
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2709,6 +2859,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis"},
|
||||
{"REDIS_SSL": "true", "REDIS_SSL_CA_CERTS": "/env-ca.pem", "REDIS_SSL_CERT_REQS": "optional"},
|
||||
{"ssl_ca_certs": "/env-ca.pem", "ssl": True, "ssl_cert_reqs": "optional", "host": "redis"},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2731,6 +2882,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis", "socket_connect_timeout": 0.25, "socket_timeout": 2.5},
|
||||
{},
|
||||
{"host": "redis", "socket_connect_timeout": 0.25, "socket_timeout": 2.5},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2746,6 +2898,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "os.environ/COORD_HOST", "password": "os.environ/COORD_PASSWORD"},
|
||||
{"COORD_HOST": "referenced", "COORD_PASSWORD": "referenced-secret"},
|
||||
{"host": "referenced", "password": "referenced-secret"},
|
||||
_expected_view(
|
||||
{
|
||||
"auth": {"kind": "static", "password": "referenced-secret", "username": None},
|
||||
|
|
@ -2760,12 +2913,14 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"port": 6379},
|
||||
{},
|
||||
{"port": 6379},
|
||||
{"rejected": {"message": "Either 'host' or 'url' must be specified for redis.", "type": "ValueError"}},
|
||||
id="no_target_fails",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis", "ssl": True, "ssl_certfile": "/client.pem", "ssl_keyfile": "/client.key"},
|
||||
{},
|
||||
{"host": "redis", "ssl": True, "ssl_certfile": "/client.pem", "ssl_keyfile": "/client.key"},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2788,6 +2943,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"url": "rediss://cache:6380/0?ssl_cert_reqs=none&ssl_ca_certs=/ca.pem&ssl_check_hostname=yes"},
|
||||
{},
|
||||
{"url": "rediss://cache:6380/0?ssl_cert_reqs=none&ssl_ca_certs=/ca.pem&ssl_check_hostname=yes"},
|
||||
_expected_view(
|
||||
{
|
||||
"mode": "url",
|
||||
|
|
@ -2808,6 +2964,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"url": "redis://cache:6379/0?socket_timeout=2"},
|
||||
{},
|
||||
{"url": "redis://cache:6379/0?socket_timeout=2"},
|
||||
_expected_view(
|
||||
{
|
||||
"mode": "url",
|
||||
|
|
@ -2821,6 +2978,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"db": "99999999999999999999", "ssl_cert_reqs": "strict", "url": "rediss://cache:6380"},
|
||||
{},
|
||||
{"db": "99999999999999999999", "ssl_cert_reqs": "strict", "url": "rediss://cache:6380"},
|
||||
_expected_view(
|
||||
{
|
||||
"mode": "url",
|
||||
|
|
@ -2841,6 +2999,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"db": 99999999999, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"db": 99999999999, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Argument 'db' must be 0 or None in cluster mode",
|
||||
|
|
@ -2852,6 +3011,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"db": True, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"db": True, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Argument 'db' must be 0 or None in cluster mode",
|
||||
|
|
@ -2863,12 +3023,14 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"db": False, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"db": False, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
_expected_view({"health_check_interval": 25.0, "mode": "cluster", "nodes": ["node-a:7000"]}),
|
||||
id="cluster_db_false",
|
||||
),
|
||||
pytest.param(
|
||||
{"db": 0.5, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"db": 0.5, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Argument 'db' must be 0 or None in cluster mode",
|
||||
|
|
@ -2880,12 +3042,14 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"db": 0.0, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"db": 0.0, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
_expected_view({"health_check_interval": 25.0, "mode": "cluster", "nodes": ["node-a:7000"]}),
|
||||
id="cluster_db_float_zero",
|
||||
),
|
||||
pytest.param(
|
||||
{"db": [1], "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"db": [1], "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Argument 'db' must be 0 or None in cluster mode",
|
||||
|
|
@ -2897,12 +3061,14 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"db": [], "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"db": [], "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
_expected_view({"health_check_interval": 25.0, "mode": "cluster", "nodes": ["node-a:7000"]}),
|
||||
id="cluster_db_empty_list",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis", "ssl": True, "ssl_ciphers": "HIGH"},
|
||||
{},
|
||||
{"host": "redis", "ssl": True, "ssl_ciphers": "HIGH"},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -2925,54 +3091,63 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis", "retry_on_timeout": True},
|
||||
{},
|
||||
{"host": "redis", "retry_on_timeout": True},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="retry_on_timeout",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis", "port": "abc"},
|
||||
{},
|
||||
{"host": "redis", "port": "abc"},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="malformed_port",
|
||||
),
|
||||
pytest.param(
|
||||
{"db": "first", "host": "redis"},
|
||||
{},
|
||||
{"db": "first", "host": "redis"},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="malformed_db",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis", "socket_timeout": "slow"},
|
||||
{},
|
||||
{"host": "redis", "socket_timeout": "slow"},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="socket_timeout_not_a_number",
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
{"REDIS_HOST": "redis", "REDIS_PORT": "abc"},
|
||||
{"port": "abc", "host": "redis"},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="malformed_port_from_the_environment",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis", "protocol": 3},
|
||||
{},
|
||||
{"host": "redis", "protocol": 3},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="protocol_3",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis"},
|
||||
{"REDIS_RETRY_ON_ERROR": "true"},
|
||||
{"retry_on_error": True, "host": "redis"},
|
||||
{"rejected": {"message": "'bool' object is not iterable", "type": "TypeError"}},
|
||||
id="retry_on_error_from_the_environment",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis", "unix_socket_path": "/tmp/redis.sock"},
|
||||
{},
|
||||
{"host": "redis", "unix_socket_path": "/tmp/redis.sock"},
|
||||
_expected_view({"mode": "unix", "path": "/tmp/redis.sock"}),
|
||||
id="unix_socket_path",
|
||||
),
|
||||
pytest.param(
|
||||
{"host": "redis", "ssl": True, "ssl_ca_certs": "/ca.pem", "unix_socket_path": "/tmp/redis.sock"},
|
||||
{},
|
||||
{"host": "redis", "ssl": True, "ssl_ca_certs": "/ca.pem", "unix_socket_path": "/tmp/redis.sock"},
|
||||
_expected_view({"mode": "unix", "path": "/tmp/redis.sock"}),
|
||||
id="unix_socket_path_keeps_tls_settings_out_of_the_target",
|
||||
),
|
||||
|
|
@ -2983,6 +3158,11 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
"ssl_ca_data": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
|
||||
},
|
||||
{},
|
||||
{
|
||||
"host": "redis",
|
||||
"ssl": True,
|
||||
"ssl_ca_data": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
|
||||
},
|
||||
_expected_view(
|
||||
{
|
||||
"db": 0,
|
||||
|
|
@ -3005,6 +3185,7 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"host": "redis"},
|
||||
{"REDIS_SSL": "true", "REDIS_SSL_CA_PATH": "/etc/ssl/private-cas"},
|
||||
{"ssl": True, "ssl_ca_path": "/etc/ssl/private-cas", "host": "redis"},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Redis.__init__() got an unexpected keyword argument 'ssl_ca_path'",
|
||||
|
|
@ -3016,24 +3197,28 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"health_check_interval": "7", "host": "redis"},
|
||||
{},
|
||||
{"health_check_interval": "7", "host": "redis"},
|
||||
_expected_view({"db": 0, "health_check_interval": 7.0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="health_check_interval_is_whole_seconds",
|
||||
),
|
||||
pytest.param(
|
||||
{"health_check_interval": 0, "host": "redis"},
|
||||
{},
|
||||
{"health_check_interval": 0, "host": "redis"},
|
||||
_expected_view({"db": 0, "host": "redis", "mode": "host", "port": 6379}),
|
||||
id="health_check_interval_zero_is_off",
|
||||
),
|
||||
pytest.param(
|
||||
{"health_check_interval": 0, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"health_check_interval": 0, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
_expected_view({"mode": "cluster", "nodes": ["node-a:7000"]}),
|
||||
id="health_check_interval_zero_keeps_a_cluster_off",
|
||||
),
|
||||
pytest.param(
|
||||
{"db": 3, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{},
|
||||
{"db": 3, "startup_nodes": [{"host": "node-a", "port": 7000}]},
|
||||
{
|
||||
"rejected": {
|
||||
"message": "Argument 'db' must be 0 or None in cluster mode",
|
||||
|
|
@ -3045,17 +3230,19 @@ def _expected_view(fields: dict[str, object]) -> dict[str, object]:
|
|||
pytest.param(
|
||||
{"unix_socket_path": "/tmp/redis.sock"},
|
||||
{},
|
||||
{"unix_socket_path": "/tmp/redis.sock"},
|
||||
{"rejected": {"message": "Either 'host' or 'url' must be specified for redis.", "type": "ValueError"}},
|
||||
id="unix_socket_path_without_a_host_fails_like_python",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_redis_settings_resolve_like_the_rust_resolver_expects(monkeypatch, kwargs, environment, expected):
|
||||
def test_redis_settings_resolve_like_the_rust_resolver_expects(monkeypatch, kwargs, environment, merged, expected):
|
||||
for name in [name for name in os.environ if name.startswith(("REDIS_", "AZURE_", "AWS_"))]:
|
||||
monkeypatch.delenv(name)
|
||||
for name, value in environment.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
assert _merge_redis_kwargs(kwargs) == merged
|
||||
try:
|
||||
outcome = _resolution_view(kwargs)
|
||||
except (ValueError, TypeError, RedisClusterException) as error:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue