Skip to main content

alien_core/bindings/
redis.rs

1//! Redis binding definitions across platforms.
2//!
3//! The binding carries only connection details. Cloud variants keep the password
4//! out of state by pointing at the cloud secret store (or, on Azure, at the
5//! database whose access key is read at load time); Local and External carry the
6//! password as a `BindingValue`.
7
8use super::BindingValue;
9use serde::{Deserialize, Serialize};
10
11/// Connection details for a Redis-compatible server, one variant per backend.
12///
13/// Service tags are unique across all binding enums (serde dispatches on
14/// `service` alone), so the key-value store's `redis` tag is not reused.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
17#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
18#[serde(tag = "service")]
19pub enum RedisBinding {
20    /// AWS ElastiCache for Valkey (primary endpoint + AUTH token secret ARN).
21    #[serde(rename = "elasticache")]
22    ElastiCache(ElastiCacheRedisBinding),
23    /// GCP Memorystore for Valkey (PSC address + CA + secret name).
24    #[serde(rename = "memorystore")]
25    Memorystore(MemorystoreRedisBinding),
26    /// Azure Managed Redis (private endpoint host + database resource ID).
27    #[serde(rename = "azure-managed-redis")]
28    AzureManagedRedis(AzureManagedRedisBinding),
29    /// Operator-provided server (Kubernetes, on-prem, or cloud override).
30    #[serde(rename = "external")]
31    External(ExternalRedisBinding),
32    /// Local Valkey container.
33    #[serde(rename = "local-redis")]
34    Local(LocalRedisBinding),
35}
36
37/// AWS ElastiCache binding.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
40#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
41#[serde(rename_all = "camelCase")]
42pub struct ElastiCacheRedisBinding {
43    pub host: BindingValue<String>,
44    pub port: BindingValue<u16>,
45    pub username: BindingValue<String>,
46    /// Secrets Manager ARN of the AUTH token; resolved at load time.
47    pub password_secret_arn: BindingValue<String>,
48}
49
50/// GCP Memorystore binding.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
54#[serde(rename_all = "camelCase")]
55pub struct MemorystoreRedisBinding {
56    pub host: BindingValue<String>,
57    pub port: BindingValue<u16>,
58    pub username: BindingValue<String>,
59    /// Instance CA certificates (PEM), including every root accepted during a rotation.
60    pub server_ca_certificates: BindingValue<Vec<String>>,
61    /// Full instance name `projects/<project ID>/locations/<region>/instances/<id>`. The SDK
62    /// reads the default user's token from Memorystore when it loads the binding.
63    pub instance_name: BindingValue<String>,
64}
65
66/// Azure Managed Redis binding.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
69#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
70#[serde(rename_all = "camelCase")]
71pub struct AzureManagedRedisBinding {
72    pub host: BindingValue<String>,
73    pub port: BindingValue<u16>,
74    pub username: BindingValue<String>,
75    /// ARM resource ID of the database; its access key is read with `listKeys`
76    /// at load time.
77    pub database_resource_id: BindingValue<String>,
78}
79
80/// Operator-provided server binding.
81// No derived `Debug`: the inline password would print in cleartext.
82#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
84#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
85#[serde(rename_all = "camelCase")]
86pub struct ExternalRedisBinding {
87    pub host: BindingValue<String>,
88    pub port: BindingValue<u16>,
89    pub username: BindingValue<String>,
90    /// Password literal or a `secretKeyRef` to a Kubernetes Secret. Kubernetes workloads get the
91    /// password from the Secret as an environment variable, which the SDK inserts after it
92    /// parses the binding, so any character is safe.
93    #[serde(deserialize_with = "deserialize_external_password")]
94    #[cfg_attr(feature = "openapi", schema(value_type = ExternalPasswordInput, inline))]
95    #[cfg_attr(feature = "jsonschema", schemars(with = "ExternalPasswordInput"))]
96    pub password: BindingValue<String>,
97    /// Connect over TLS. Defaults to true.
98    #[serde(default = "default_true")]
99    pub tls: bool,
100}
101
102// A password string or a `secretKeyRef`; other shapes (numbers, arrays, `secretRef`) are refused
103// instead of falling through BindingValue's expression handling.
104#[derive(Deserialize)]
105#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
106#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
107#[serde(untagged)]
108enum ExternalPasswordInput {
109    Literal(String),
110    Reference(ExternalPasswordSecretKeyRef),
111}
112
113#[derive(Deserialize)]
114#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
115#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
116#[serde(rename_all = "camelCase", deny_unknown_fields)]
117struct ExternalPasswordSecretKeyRef {
118    secret_key_ref: super::SecretReference,
119}
120
121fn deserialize_external_password<'de, D>(deserializer: D) -> Result<BindingValue<String>, D::Error>
122where
123    D: serde::Deserializer<'de>,
124{
125    let value = serde_json::Value::deserialize(deserializer)?;
126    match serde_json::from_value::<ExternalPasswordInput>(value).map_err(|_| {
127        serde::de::Error::custom(
128            "expected a password string or {\"secretKeyRef\": {\"name\": ..., \"key\": ...}}",
129        )
130    })? {
131        ExternalPasswordInput::Literal(value) => Ok(BindingValue::Value(value)),
132        ExternalPasswordInput::Reference(reference) => {
133            Ok(BindingValue::Expression(serde_json::json!({
134                "secretKeyRef": {"name": reference.secret_key_ref.name, "key": reference.secret_key_ref.key}
135            })))
136        }
137    }
138}
139
140/// Local Valkey binding.
141// No derived `Debug`: the inline password would print in cleartext.
142#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
144#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
145#[serde(rename_all = "camelCase")]
146pub struct LocalRedisBinding {
147    pub host: BindingValue<String>,
148    pub port: BindingValue<u16>,
149    pub username: BindingValue<String>,
150    pub password: String,
151}
152
153fn default_true() -> bool {
154    true
155}
156
157impl std::fmt::Debug for ExternalRedisBinding {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("ExternalRedisBinding")
160            .field("host", &self.host)
161            .field("port", &self.port)
162            .field("username", &self.username)
163            .field("password", &"<redacted>")
164            .field("tls", &self.tls)
165            .finish()
166    }
167}
168
169impl std::fmt::Debug for LocalRedisBinding {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("LocalRedisBinding")
172            .field("host", &self.host)
173            .field("port", &self.port)
174            .field("username", &self.username)
175            .field("password", &"<redacted>")
176            .finish()
177    }
178}
179
180impl RedisBinding {
181    /// Creates a Local Valkey binding.
182    pub fn local(
183        host: impl Into<BindingValue<String>>,
184        port: u16,
185        username: impl Into<BindingValue<String>>,
186        password: impl Into<String>,
187    ) -> Self {
188        Self::Local(LocalRedisBinding {
189            host: host.into(),
190            port: BindingValue::value(port),
191            username: username.into(),
192            password: password.into(),
193        })
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn tags_are_distinct_from_the_key_value_store() {
203        let local = RedisBinding::local("127.0.0.1", 6379, "default", "secret");
204        let json = serde_json::to_value(&local).unwrap();
205        assert_eq!(json["service"], "local-redis");
206        assert_eq!(serde_json::from_value::<RedisBinding>(json).unwrap(), local);
207
208        // A key-value `redis` binding must not parse as a Redis resource binding.
209        let kv = serde_json::json!({"service": "redis", "host": "h", "port": 1});
210        assert!(serde_json::from_value::<RedisBinding>(kv).is_err());
211    }
212
213    #[test]
214    fn external_binding_defaults_to_tls_and_keeps_secret_refs() {
215        let input = serde_json::json!({
216            "service": "external", "host": "cache.internal", "port": 6379,
217            "username": "default",
218            "password": {"secretKeyRef": {"name": "redis-credentials", "key": "password"}}
219        });
220        let binding: RedisBinding = serde_json::from_value(input.clone()).unwrap();
221        let RedisBinding::External(external) = &binding else {
222            panic!("expected external binding");
223        };
224        assert!(external.tls);
225        assert!(!format!("{binding:?}").contains("redis-credentials"));
226        assert_eq!(
227            serde_json::to_value(&binding).unwrap()["password"],
228            input["password"]
229        );
230    }
231
232    #[test]
233    fn external_password_accepts_a_string_or_secret_key_ref_only() {
234        let binding = |password: serde_json::Value| {
235            serde_json::from_value::<RedisBinding>(serde_json::json!({
236                "service": "external", "host": "h", "port": 6379,
237                "username": "default", "password": password
238            }))
239        };
240        assert!(binding(serde_json::json!("p\"w\\d")).is_ok());
241        assert!(binding(serde_json::json!({"secretKeyRef": {"name": "n", "key": "k"}})).is_ok());
242        // `secretRef` would be spliced into the binding JSON as raw text.
243        assert!(binding(serde_json::json!({"secretRef": {"name": "n", "key": "k"}})).is_err());
244        assert!(binding(serde_json::json!(5)).is_err());
245        assert!(binding(serde_json::json!({"secretKeyRef": {"name": "n"}})).is_err());
246    }
247
248    #[test]
249    fn debug_output_never_prints_passwords() {
250        let local = RedisBinding::local("127.0.0.1", 6379, "default", "hunter2");
251        assert!(!format!("{local:?}").contains("hunter2"));
252    }
253}