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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13pub enum RedisEvictionPolicy {
14 #[serde(rename = "noeviction")]
16 NoEviction,
17 #[serde(rename = "allkeys-lru")]
19 AllKeysLru,
20 #[serde(rename = "allkeys-lfu")]
22 AllKeysLfu,
23 #[serde(rename = "allkeys-random")]
25 AllKeysRandom,
26 #[default]
28 #[serde(rename = "volatile-lru")]
29 VolatileLru,
30 #[serde(rename = "volatile-lfu")]
32 VolatileLfu,
33 #[serde(rename = "volatile-random")]
35 VolatileRandom,
36 #[serde(rename = "volatile-ttl")]
38 VolatileTtl,
39}
40
41impl RedisEvictionPolicy {
42 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#[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 #[serde(skip_serializing_if = "Option::is_none")]
73 pub memory: Option<String>,
74 #[builder(default)]
77 #[serde(default)]
78 pub eviction_policy: RedisEvictionPolicy,
79 #[builder(default)]
84 #[serde(default)]
85 pub persistence: bool,
86 #[builder(default)]
88 #[serde(default)]
89 pub high_availability: bool,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub encryption_key: Option<ResourceRef>,
94}
95
96pub 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
110pub 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
122pub 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 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("redis");
138
139 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 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 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 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 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 pub fn id(&self) -> &str {
238 &self.id
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
245#[serde(rename_all = "camelCase")]
246pub struct RedisOutputs {
247 pub endpoint: String,
249 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 false
287 }
288
289 fn get_dependencies(&self) -> Vec<ResourceRef> {
290 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 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 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 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}