Skip to main content

alien_core/resources/
redis.rs

1use crate::error::{ErrorData, Result};
2use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
3use crate::ResourceType;
4use alien_error::AlienError;
5use bon::Builder;
6use serde::{Deserialize, Serialize};
7use std::any::Any;
8use std::fmt::Debug;
9
10/// What a Redis server does when it reaches its memory limit.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13pub enum RedisEvictionPolicy {
14    /// Reject writes when memory is full.
15    #[serde(rename = "noeviction")]
16    NoEviction,
17    /// Evict the least recently used key, whether or not it has a TTL.
18    #[serde(rename = "allkeys-lru")]
19    AllKeysLru,
20    /// Evict the least frequently used key, whether or not it has a TTL.
21    #[serde(rename = "allkeys-lfu")]
22    AllKeysLfu,
23    /// Evict a random key, whether or not it has a TTL.
24    #[serde(rename = "allkeys-random")]
25    AllKeysRandom,
26    /// Evict the least recently used key among keys with a TTL.
27    #[default]
28    #[serde(rename = "volatile-lru")]
29    VolatileLru,
30    /// Evict the least frequently used key among keys with a TTL.
31    #[serde(rename = "volatile-lfu")]
32    VolatileLfu,
33    /// Evict a random key among keys with a TTL.
34    #[serde(rename = "volatile-random")]
35    VolatileRandom,
36    /// Evict the key with the shortest remaining TTL.
37    #[serde(rename = "volatile-ttl")]
38    VolatileTtl,
39}
40
41impl RedisEvictionPolicy {
42    /// The value of the `maxmemory-policy` setting on every platform.
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            Self::NoEviction => "noeviction",
46            Self::AllKeysLru => "allkeys-lru",
47            Self::AllKeysLfu => "allkeys-lfu",
48            Self::AllKeysRandom => "allkeys-random",
49            Self::VolatileLru => "volatile-lru",
50            Self::VolatileLfu => "volatile-lfu",
51            Self::VolatileRandom => "volatile-random",
52            Self::VolatileTtl => "volatile-ttl",
53        }
54    }
55}
56
57/// A managed, Redis-compatible in-memory store on a private network. The target
58/// platform decides the backend (AWS ElastiCache for Valkey, GCP Memorystore for
59/// Valkey, Azure Managed Redis, or the official Valkey image in Docker on Local). It is
60/// never publicly reachable, and it is a single endpoint (not cluster mode), so
61/// plain clients and multi-key scripts work.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
63#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65#[builder(start_fn = new)]
66pub struct Redis {
67    #[builder(start_fn)]
68    pub id: String,
69    /// Memory available for data (e.g. "512Mi", "4Gi"). None = the smallest
70    /// production tier. The smallest tier that fits is used; a request larger
71    /// than the biggest single-shard tier on the platform is rejected.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub memory: Option<String>,
74    /// What happens at the memory limit. Default `volatile-lru`: keys without
75    /// a TTL are never evicted.
76    #[builder(default)]
77    #[serde(default)]
78    pub eviction_policy: RedisEvictionPolicy,
79    /// Keep data across a server restart or maintenance. Default false (a
80    /// cache). On AWS, GCP and Azure this needs `high_availability`, because the
81    /// clouds only persist data from a replicated deployment. Local always
82    /// persists with an append-only file when this is on.
83    #[builder(default)]
84    #[serde(default)]
85    pub persistence: bool,
86    /// One replica in another zone with automatic failover. Default false.
87    #[builder(default)]
88    #[serde(default)]
89    pub high_availability: bool,
90    /// Customer-managed key for encryption at rest. Cannot be changed after
91    /// creation.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub encryption_key: Option<ResourceRef>,
94}
95
96/// ElastiCache node types in ascending order with their published node memory in GiB.
97/// One table for the controller, the CloudFormation emitter and the Terraform emitter.
98pub const ELASTICACHE_NODE_TYPES: &[(&str, f64)] = &[
99    ("cache.t4g.small", 1.37),
100    ("cache.t4g.medium", 3.09),
101    ("cache.m7g.large", 6.38),
102    ("cache.m7g.xlarge", 12.93),
103    ("cache.r7g.xlarge", 26.32),
104    ("cache.r7g.2xlarge", 52.82),
105    ("cache.r7g.4xlarge", 105.81),
106    ("cache.r7g.8xlarge", 209.55),
107    ("cache.r7g.16xlarge", 419.09),
108];
109
110/// Memorystore for Valkey node types in ascending order with their default usable memory in
111/// GB (decimal), from the published node specification. The CUSTOM_* types are left out: they
112/// exist only for cluster-disabled instances and have not been exercised against a real project.
113pub const MEMORYSTORE_NODE_TYPES: &[(&str, f64)] = &[
114    ("SHARED_CORE_NANO", 1.12),
115    ("STANDARD_SMALL", 5.2),
116    ("HIGHMEM_MEDIUM", 10.4),
117    ("STANDARD_LARGE", 20.8),
118    ("HIGHMEM_XLARGE", 46.4),
119    ("HIGHMEM_2XLARGE", 88.0),
120];
121
122/// Azure Managed Redis Balanced SKUs in ascending order with their published memory in GB.
123/// B0 is left out: the pricing page says B0 and B1 may lack some features, and B0 is half the
124/// size of the smallest production tier. `NoCluster` caps a database at 25 GB, so B20 is the
125/// largest. Sizes are the published figures; the live check of `maxmemory` is recorded in the
126/// design notes.
127pub const AZURE_MANAGED_REDIS_SKUS: &[(&str, f64)] = &[
128    ("Balanced_B1", 1.0),
129    ("Balanced_B3", 3.0),
130    ("Balanced_B5", 6.0),
131    ("Balanced_B10", 12.0),
132    ("Balanced_B20", 24.0),
133];
134
135impl Redis {
136    /// The resource type identifier for Redis.
137    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("redis");
138
139    /// Requested memory in bytes, or None for the default tier. Uses the same
140    /// parser as every other sized resource, so all controllers and emitters
141    /// read a quantity the same way. A bare number is GiB (as for Postgres).
142    pub fn requested_memory_bytes(&self) -> Result<Option<u64>> {
143        self.memory
144            .as_deref()
145            .map(|value| {
146                let quantity = value.trim();
147                if let Ok(gib) = quantity.parse::<f64>() {
148                    if gib.is_finite() && gib > 0.0 {
149                        return Ok((gib * 1024_f64.powi(3)).ceil() as u64);
150                    }
151                    return Err(self.invalid_memory(value));
152                }
153                // Accept the GiB/GB spellings as aliases of Gi/G.
154                let quantity = quantity.strip_suffix('B').unwrap_or(quantity);
155                crate::instance_catalog::parse_memory_bytes(quantity)
156                    .ok()
157                    .filter(|bytes| *bytes > 0)
158                    .ok_or_else(|| self.invalid_memory(value))
159            })
160            .transpose()
161    }
162
163    /// The smallest ElastiCache node type whose usable data memory fits the request, and
164    /// that usable memory in bytes. Usable memory is 75% of the node's memory because
165    /// ElastiCache reserves 25% by default (`reserved-memory-percent`). With no request the
166    /// default is 1 GiB. Requests above the largest single-shard node are rejected, never capped.
167    pub fn elasticache_node_type(&self) -> Result<(&'static str, u64)> {
168        let wanted = self.requested_memory_bytes()?.unwrap_or(1024_u64.pow(3));
169        ELASTICACHE_NODE_TYPES
170            .iter()
171            .map(|(name, gib)| (*name, (gib * 0.75 * 1024_f64.powi(3)) as u64))
172            .find(|(_, usable)| *usable >= wanted)
173            .ok_or_else(|| {
174                AlienError::new(ErrorData::OperationNotSupported {
175                    operation: "Redis memory sizing".to_string(),
176                    reason: format!(
177                        "{} bytes exceeds the largest single-shard ElastiCache node ({})",
178                        wanted,
179                        ELASTICACHE_NODE_TYPES[ELASTICACHE_NODE_TYPES.len() - 1].0
180                    ),
181                })
182            })
183    }
184
185    /// The smallest Memorystore node type whose default usable memory fits the request, and
186    /// that usable memory in bytes. With no request the default is 1 GiB. Requests above the
187    /// largest node are rejected, never capped.
188    pub fn memorystore_node_type(&self) -> Result<(&'static str, u64)> {
189        let wanted = self.requested_memory_bytes()?.unwrap_or(1024_u64.pow(3));
190        MEMORYSTORE_NODE_TYPES
191            .iter()
192            .map(|(name, gb)| (*name, (gb * 1e9) as u64))
193            .find(|(_, usable)| *usable >= wanted)
194            .ok_or_else(|| {
195                AlienError::new(ErrorData::OperationNotSupported {
196                    operation: "Redis memory sizing".to_string(),
197                    reason: format!(
198                        "{} bytes exceeds the largest Memorystore node ({})",
199                        wanted,
200                        MEMORYSTORE_NODE_TYPES[MEMORYSTORE_NODE_TYPES.len() - 1].0
201                    ),
202                })
203            })
204    }
205
206    /// The smallest Azure Managed Redis SKU whose memory fits the request, and that memory in
207    /// bytes. With no request the default is 1 GiB. Requests above the largest `NoCluster`
208    /// SKU are rejected, never capped.
209    pub fn azure_managed_redis_sku(&self) -> Result<(&'static str, u64)> {
210        let wanted = self.requested_memory_bytes()?.unwrap_or(1024_u64.pow(3));
211        AZURE_MANAGED_REDIS_SKUS
212            .iter()
213            .map(|(name, gb)| (*name, (gb * 1024_f64.powi(3)) as u64))
214            .find(|(_, usable)| *usable >= wanted)
215            .ok_or_else(|| {
216                AlienError::new(ErrorData::OperationNotSupported {
217                    operation: "Redis memory sizing".to_string(),
218                    reason: format!(
219                        "{} bytes exceeds the largest single-endpoint Azure Managed Redis SKU ({})",
220                        wanted,
221                        AZURE_MANAGED_REDIS_SKUS[AZURE_MANAGED_REDIS_SKUS.len() - 1].0
222                    ),
223                })
224            })
225    }
226
227    fn invalid_memory(&self, value: &str) -> AlienError<ErrorData> {
228        AlienError::new(ErrorData::OperationNotSupported {
229            operation: "Redis memory sizing".to_string(),
230            reason: format!(
231                "'{value}' must be a positive quantity with a unit, such as 512Mi or 4Gi"
232            ),
233        })
234    }
235
236    /// Returns the Redis resource's unique identifier.
237    pub fn id(&self) -> &str {
238        &self.id
239    }
240}
241
242/// Outputs generated by a successfully provisioned Redis server.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
245#[serde(rename_all = "camelCase")]
246pub struct RedisOutputs {
247    /// Host the server listens on; never publicly reachable.
248    pub endpoint: String,
249    /// Listening port.
250    pub port: u16,
251}
252
253impl ResourceOutputsDefinition for RedisOutputs {
254    fn get_resource_type(&self) -> ResourceType {
255        Redis::RESOURCE_TYPE.clone()
256    }
257
258    fn as_any(&self) -> &dyn Any {
259        self
260    }
261
262    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
263        Box::new(self.clone())
264    }
265
266    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
267        other.as_any().downcast_ref::<RedisOutputs>() == Some(self)
268    }
269
270    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
271        serde_json::to_value(self)
272    }
273}
274
275impl ResourceDefinition for Redis {
276    fn get_resource_type(&self) -> ResourceType {
277        Self::RESOURCE_TYPE
278    }
279
280    fn id(&self) -> &str {
281        &self.id
282    }
283
284    fn replace_after_failed_create_is_safe(&self) -> bool {
285        // Replacing it deletes the data it holds.
286        false
287    }
288
289    fn get_dependencies(&self) -> Vec<ResourceRef> {
290        // The network dependency is added by infrastructure preflights on cloud platforms and
291        // is irrelevant on Local; cloud controllers reach the network via
292        // require_dependency() at runtime.
293        self.encryption_key.iter().cloned().collect()
294    }
295
296    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
297        let new_redis = new_config.as_any().downcast_ref::<Redis>().ok_or_else(|| {
298            AlienError::new(ErrorData::UnexpectedResourceType {
299                resource_id: self.id.clone(),
300                expected: Self::RESOURCE_TYPE,
301                actual: new_config.get_resource_type(),
302            })
303        })?;
304
305        if self.id != new_redis.id {
306            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
307                resource_id: self.id.clone(),
308                reason: "the 'id' field is immutable".to_string(),
309            }));
310        }
311
312        if self.encryption_key != new_redis.encryption_key {
313            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
314                resource_id: self.id.clone(),
315                reason: "the 'encryptionKey' field is immutable once the resource exists"
316                    .to_string(),
317            }));
318        }
319
320        // A malformed size must fail here instead of at the cloud API.
321        new_redis.requested_memory_bytes().map_err(|e| {
322            AlienError::new(ErrorData::InvalidResourceUpdate {
323                resource_id: self.id.clone(),
324                reason: e.to_string(),
325            })
326        })?;
327
328        Ok(())
329    }
330
331    fn as_any(&self) -> &dyn Any {
332        self
333    }
334
335    fn as_any_mut(&mut self) -> &mut dyn Any {
336        self
337    }
338
339    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
340        Box::new(self.clone())
341    }
342
343    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
344        other.as_any().downcast_ref::<Redis>() == Some(self)
345    }
346
347    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
348        serde_json::to_value(self)
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn builder_applies_defaults() {
358        let redis = Redis::new("cache".to_string()).build();
359        assert_eq!(redis.eviction_policy, RedisEvictionPolicy::VolatileLru);
360        assert!(!redis.persistence);
361        assert!(!redis.high_availability);
362        assert_eq!(redis.memory, None);
363    }
364
365    #[test]
366    fn json_defaults_match_builder_defaults() {
367        let redis: Redis = serde_json::from_str(r#"{"id":"cache"}"#).unwrap();
368        assert_eq!(redis, Redis::new("cache".to_string()).build());
369    }
370
371    #[test]
372    fn eviction_policy_serializes_to_the_server_setting() {
373        for policy in [
374            RedisEvictionPolicy::NoEviction,
375            RedisEvictionPolicy::AllKeysLru,
376            RedisEvictionPolicy::AllKeysLfu,
377            RedisEvictionPolicy::AllKeysRandom,
378            RedisEvictionPolicy::VolatileLru,
379            RedisEvictionPolicy::VolatileLfu,
380            RedisEvictionPolicy::VolatileRandom,
381            RedisEvictionPolicy::VolatileTtl,
382        ] {
383            assert_eq!(
384                serde_json::to_value(policy).unwrap(),
385                serde_json::Value::String(policy.as_str().to_string())
386            );
387        }
388        assert!(serde_json::from_str::<RedisEvictionPolicy>(r#""fifo""#).is_err());
389    }
390
391    #[test]
392    fn memory_units_agree() {
393        for (quantity, bytes) in [
394            ("1", 1u64 << 30),
395            ("1.5", 3 << 29),
396            ("512Mi", 512 << 20),
397            ("4Gi", 4 << 30),
398            ("4GiB", 4 << 30),
399            ("1GB", 1_000_000_000),
400        ] {
401            let redis = Redis::new("c".into()).memory(quantity.into()).build();
402            assert_eq!(
403                redis.requested_memory_bytes().unwrap(),
404                Some(bytes),
405                "{quantity}"
406            );
407        }
408        assert_eq!(
409            Redis::new("c".into())
410                .build()
411                .requested_memory_bytes()
412                .unwrap(),
413            None
414        );
415    }
416
417    #[test]
418    fn memory_rejects_invalid_values() {
419        for quantity in [
420            "", "0", "0Gi", "-1Gi", "NaNGi", "infGi", "1e100Gi", "8unknown",
421        ] {
422            let redis = Redis::new("c".into()).memory(quantity.into()).build();
423            assert!(redis.requested_memory_bytes().is_err(), "{quantity}");
424        }
425    }
426
427    #[test]
428    fn update_rejects_id_and_key_changes_and_bad_memory() {
429        let original = Redis::new("c".to_string()).build();
430        let renamed = Redis::new("d".to_string()).build();
431        assert!(original
432            .validate_update(&renamed)
433            .unwrap_err()
434            .to_string()
435            .contains("'id' field is immutable"));
436
437        let keyed = Redis::new("c".to_string())
438            .encryption_key(ResourceRef::new(crate::Key::RESOURCE_TYPE, "k".to_string()))
439            .build();
440        assert!(original
441            .validate_update(&keyed)
442            .unwrap_err()
443            .to_string()
444            .contains("'encryptionKey' field is immutable"));
445
446        let bad = Redis::new("c".to_string()).memory("lots".into()).build();
447        assert!(original.validate_update(&bad).is_err());
448        let bigger = Redis::new("c".to_string()).memory("8Gi".into()).build();
449        assert!(original.validate_update(&bigger).is_ok());
450    }
451
452    #[test]
453    fn outputs_roundtrip() {
454        let outputs = RedisOutputs {
455            endpoint: "127.0.0.1".to_string(),
456            port: 6379,
457        };
458        let json = serde_json::to_string(&outputs).unwrap();
459        assert_eq!(
460            outputs,
461            serde_json::from_str::<RedisOutputs>(&json).unwrap()
462        );
463    }
464
465    #[test]
466    fn elasticache_node_type_picks_smallest_fit_and_rejects_oversize() {
467        let redis = |m: Option<&str>| match m {
468            Some(m) => Redis::new("r".into()).memory(m.into()).build(),
469            None => Redis::new("r".into()).build(),
470        };
471        assert_eq!(
472            redis(None).elasticache_node_type().unwrap().0,
473            "cache.t4g.small"
474        );
475        // 1.37 GiB node -> 1.0275 GiB usable: 1 GiB fits, 1.1 GiB moves up.
476        assert_eq!(
477            redis(Some("1Gi")).elasticache_node_type().unwrap().0,
478            "cache.t4g.small"
479        );
480        assert_eq!(
481            redis(Some("1100Mi")).elasticache_node_type().unwrap().0,
482            "cache.t4g.medium"
483        );
484        assert_eq!(
485            redis(Some("300Gi")).elasticache_node_type().unwrap().0,
486            "cache.r7g.16xlarge"
487        );
488        assert!(redis(Some("400Gi")).elasticache_node_type().is_err());
489    }
490
491    #[test]
492    fn memorystore_node_type_picks_smallest_fit_and_rejects_oversize() {
493        let redis = |m: Option<&str>| match m {
494            Some(m) => Redis::new("r".into()).memory(m.into()).build(),
495            None => Redis::new("r".into()).build(),
496        };
497        assert_eq!(
498            redis(None).memorystore_node_type().unwrap().0,
499            "SHARED_CORE_NANO"
500        );
501        // 1.12 GB usable on the nano node: 1 GiB (1.07 GB) fits, 1.1 GiB (1.18 GB) does not.
502        assert_eq!(
503            redis(Some("1100Mi")).memorystore_node_type().unwrap().0,
504            "STANDARD_SMALL"
505        );
506        assert_eq!(
507            redis(Some("4Gi")).memorystore_node_type().unwrap().0,
508            "STANDARD_SMALL"
509        );
510        assert_eq!(
511            redis(Some("80Gi")).memorystore_node_type().unwrap().0,
512            "HIGHMEM_2XLARGE"
513        );
514        assert!(redis(Some("100Gi")).memorystore_node_type().is_err());
515    }
516
517    #[test]
518    fn azure_sku_picks_smallest_fit_and_rejects_above_the_nocluster_cap() {
519        let redis = |m: Option<&str>| match m {
520            Some(m) => Redis::new("r".into()).memory(m.into()).build(),
521            None => Redis::new("r".into()).build(),
522        };
523        assert_eq!(
524            redis(None).azure_managed_redis_sku().unwrap().0,
525            "Balanced_B1"
526        );
527        assert_eq!(
528            redis(Some("4Gi")).azure_managed_redis_sku().unwrap().0,
529            "Balanced_B5"
530        );
531        assert_eq!(
532            redis(Some("24Gi")).azure_managed_redis_sku().unwrap().0,
533            "Balanced_B20"
534        );
535        assert!(redis(Some("25Gi")).azure_managed_redis_sku().is_err());
536    }
537}