use crate::error::{ErrorData, Result};
use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef};
use crate::ResourceType;
use alien_error::AlienError;
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::fmt::Debug;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum RedisEvictionPolicy {
#[serde(rename = "noeviction")]
NoEviction,
#[serde(rename = "allkeys-lru")]
AllKeysLru,
#[serde(rename = "allkeys-lfu")]
AllKeysLfu,
#[serde(rename = "allkeys-random")]
AllKeysRandom,
#[default]
#[serde(rename = "volatile-lru")]
VolatileLru,
#[serde(rename = "volatile-lfu")]
VolatileLfu,
#[serde(rename = "volatile-random")]
VolatileRandom,
#[serde(rename = "volatile-ttl")]
VolatileTtl,
}
impl RedisEvictionPolicy {
pub fn as_str(&self) -> &'static str {
match self {
Self::NoEviction => "noeviction",
Self::AllKeysLru => "allkeys-lru",
Self::AllKeysLfu => "allkeys-lfu",
Self::AllKeysRandom => "allkeys-random",
Self::VolatileLru => "volatile-lru",
Self::VolatileLfu => "volatile-lfu",
Self::VolatileRandom => "volatile-random",
Self::VolatileTtl => "volatile-ttl",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[builder(start_fn = new)]
pub struct Redis {
#[builder(start_fn)]
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
#[builder(default)]
#[serde(default)]
pub eviction_policy: RedisEvictionPolicy,
#[builder(default)]
#[serde(default)]
pub persistence: bool,
#[builder(default)]
#[serde(default)]
pub high_availability: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption_key: Option<ResourceRef>,
}
pub const ELASTICACHE_NODE_TYPES: &[(&str, f64)] = &[
("cache.t4g.small", 1.37),
("cache.t4g.medium", 3.09),
("cache.m7g.large", 6.38),
("cache.m7g.xlarge", 12.93),
("cache.r7g.xlarge", 26.32),
("cache.r7g.2xlarge", 52.82),
("cache.r7g.4xlarge", 105.81),
("cache.r7g.8xlarge", 209.55),
("cache.r7g.16xlarge", 419.09),
];
pub const MEMORYSTORE_NODE_TYPES: &[(&str, f64)] = &[
("SHARED_CORE_NANO", 1.12),
("STANDARD_SMALL", 5.2),
("HIGHMEM_MEDIUM", 10.4),
("STANDARD_LARGE", 20.8),
("HIGHMEM_XLARGE", 46.4),
("HIGHMEM_2XLARGE", 88.0),
];
pub const AZURE_MANAGED_REDIS_SKUS: &[(&str, f64)] = &[
("Balanced_B1", 1.0),
("Balanced_B3", 3.0),
("Balanced_B5", 6.0),
("Balanced_B10", 12.0),
("Balanced_B20", 24.0),
];
impl Redis {
pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("redis");
pub fn requested_memory_bytes(&self) -> Result<Option<u64>> {
self.memory
.as_deref()
.map(|value| {
let quantity = value.trim();
if let Ok(gib) = quantity.parse::<f64>() {
if gib.is_finite() && gib > 0.0 {
return Ok((gib * 1024_f64.powi(3)).ceil() as u64);
}
return Err(self.invalid_memory(value));
}
let quantity = quantity.strip_suffix('B').unwrap_or(quantity);
crate::instance_catalog::parse_memory_bytes(quantity)
.ok()
.filter(|bytes| *bytes > 0)
.ok_or_else(|| self.invalid_memory(value))
})
.transpose()
}
pub fn elasticache_node_type(&self) -> Result<(&'static str, u64)> {
let wanted = self.requested_memory_bytes()?.unwrap_or(1024_u64.pow(3));
ELASTICACHE_NODE_TYPES
.iter()
.map(|(name, gib)| (*name, (gib * 0.75 * 1024_f64.powi(3)) as u64))
.find(|(_, usable)| *usable >= wanted)
.ok_or_else(|| {
AlienError::new(ErrorData::OperationNotSupported {
operation: "Redis memory sizing".to_string(),
reason: format!(
"{} bytes exceeds the largest single-shard ElastiCache node ({})",
wanted,
ELASTICACHE_NODE_TYPES[ELASTICACHE_NODE_TYPES.len() - 1].0
),
})
})
}
pub fn memorystore_node_type(&self) -> Result<(&'static str, u64)> {
let wanted = self.requested_memory_bytes()?.unwrap_or(1024_u64.pow(3));
MEMORYSTORE_NODE_TYPES
.iter()
.map(|(name, gb)| (*name, (gb * 1e9) as u64))
.find(|(_, usable)| *usable >= wanted)
.ok_or_else(|| {
AlienError::new(ErrorData::OperationNotSupported {
operation: "Redis memory sizing".to_string(),
reason: format!(
"{} bytes exceeds the largest Memorystore node ({})",
wanted,
MEMORYSTORE_NODE_TYPES[MEMORYSTORE_NODE_TYPES.len() - 1].0
),
})
})
}
pub fn azure_managed_redis_sku(&self) -> Result<(&'static str, u64)> {
let wanted = self.requested_memory_bytes()?.unwrap_or(1024_u64.pow(3));
AZURE_MANAGED_REDIS_SKUS
.iter()
.map(|(name, gb)| (*name, (gb * 1024_f64.powi(3)) as u64))
.find(|(_, usable)| *usable >= wanted)
.ok_or_else(|| {
AlienError::new(ErrorData::OperationNotSupported {
operation: "Redis memory sizing".to_string(),
reason: format!(
"{} bytes exceeds the largest single-endpoint Azure Managed Redis SKU ({})",
wanted,
AZURE_MANAGED_REDIS_SKUS[AZURE_MANAGED_REDIS_SKUS.len() - 1].0
),
})
})
}
fn invalid_memory(&self, value: &str) -> AlienError<ErrorData> {
AlienError::new(ErrorData::OperationNotSupported {
operation: "Redis memory sizing".to_string(),
reason: format!(
"'{value}' must be a positive quantity with a unit, such as 512Mi or 4Gi"
),
})
}
pub fn id(&self) -> &str {
&self.id
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct RedisOutputs {
pub endpoint: String,
pub port: u16,
}
impl ResourceOutputsDefinition for RedisOutputs {
fn get_resource_type(&self) -> ResourceType {
Redis::RESOURCE_TYPE.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
Box::new(self.clone())
}
fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
other.as_any().downcast_ref::<RedisOutputs>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
impl ResourceDefinition for Redis {
fn get_resource_type(&self) -> ResourceType {
Self::RESOURCE_TYPE
}
fn id(&self) -> &str {
&self.id
}
fn replace_after_failed_create_is_safe(&self) -> bool {
false
}
fn get_dependencies(&self) -> Vec<ResourceRef> {
self.encryption_key.iter().cloned().collect()
}
fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
let new_redis = new_config.as_any().downcast_ref::<Redis>().ok_or_else(|| {
AlienError::new(ErrorData::UnexpectedResourceType {
resource_id: self.id.clone(),
expected: Self::RESOURCE_TYPE,
actual: new_config.get_resource_type(),
})
})?;
if self.id != new_redis.id {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'id' field is immutable".to_string(),
}));
}
if self.encryption_key != new_redis.encryption_key {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'encryptionKey' field is immutable once the resource exists"
.to_string(),
}));
}
new_redis.requested_memory_bytes().map_err(|e| {
AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: e.to_string(),
})
})?;
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceDefinition> {
Box::new(self.clone())
}
fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
other.as_any().downcast_ref::<Redis>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_applies_defaults() {
let redis = Redis::new("cache".to_string()).build();
assert_eq!(redis.eviction_policy, RedisEvictionPolicy::VolatileLru);
assert!(!redis.persistence);
assert!(!redis.high_availability);
assert_eq!(redis.memory, None);
}
#[test]
fn json_defaults_match_builder_defaults() {
let redis: Redis = serde_json::from_str(r#"{"id":"cache"}"#).unwrap();
assert_eq!(redis, Redis::new("cache".to_string()).build());
}
#[test]
fn eviction_policy_serializes_to_the_server_setting() {
for policy in [
RedisEvictionPolicy::NoEviction,
RedisEvictionPolicy::AllKeysLru,
RedisEvictionPolicy::AllKeysLfu,
RedisEvictionPolicy::AllKeysRandom,
RedisEvictionPolicy::VolatileLru,
RedisEvictionPolicy::VolatileLfu,
RedisEvictionPolicy::VolatileRandom,
RedisEvictionPolicy::VolatileTtl,
] {
assert_eq!(
serde_json::to_value(policy).unwrap(),
serde_json::Value::String(policy.as_str().to_string())
);
}
assert!(serde_json::from_str::<RedisEvictionPolicy>(r#""fifo""#).is_err());
}
#[test]
fn memory_units_agree() {
for (quantity, bytes) in [
("1", 1u64 << 30),
("1.5", 3 << 29),
("512Mi", 512 << 20),
("4Gi", 4 << 30),
("4GiB", 4 << 30),
("1GB", 1_000_000_000),
] {
let redis = Redis::new("c".into()).memory(quantity.into()).build();
assert_eq!(
redis.requested_memory_bytes().unwrap(),
Some(bytes),
"{quantity}"
);
}
assert_eq!(
Redis::new("c".into())
.build()
.requested_memory_bytes()
.unwrap(),
None
);
}
#[test]
fn memory_rejects_invalid_values() {
for quantity in [
"", "0", "0Gi", "-1Gi", "NaNGi", "infGi", "1e100Gi", "8unknown",
] {
let redis = Redis::new("c".into()).memory(quantity.into()).build();
assert!(redis.requested_memory_bytes().is_err(), "{quantity}");
}
}
#[test]
fn update_rejects_id_and_key_changes_and_bad_memory() {
let original = Redis::new("c".to_string()).build();
let renamed = Redis::new("d".to_string()).build();
assert!(original
.validate_update(&renamed)
.unwrap_err()
.to_string()
.contains("'id' field is immutable"));
let keyed = Redis::new("c".to_string())
.encryption_key(ResourceRef::new(crate::Key::RESOURCE_TYPE, "k".to_string()))
.build();
assert!(original
.validate_update(&keyed)
.unwrap_err()
.to_string()
.contains("'encryptionKey' field is immutable"));
let bad = Redis::new("c".to_string()).memory("lots".into()).build();
assert!(original.validate_update(&bad).is_err());
let bigger = Redis::new("c".to_string()).memory("8Gi".into()).build();
assert!(original.validate_update(&bigger).is_ok());
}
#[test]
fn outputs_roundtrip() {
let outputs = RedisOutputs {
endpoint: "127.0.0.1".to_string(),
port: 6379,
};
let json = serde_json::to_string(&outputs).unwrap();
assert_eq!(
outputs,
serde_json::from_str::<RedisOutputs>(&json).unwrap()
);
}
#[test]
fn elasticache_node_type_picks_smallest_fit_and_rejects_oversize() {
let redis = |m: Option<&str>| match m {
Some(m) => Redis::new("r".into()).memory(m.into()).build(),
None => Redis::new("r".into()).build(),
};
assert_eq!(
redis(None).elasticache_node_type().unwrap().0,
"cache.t4g.small"
);
assert_eq!(
redis(Some("1Gi")).elasticache_node_type().unwrap().0,
"cache.t4g.small"
);
assert_eq!(
redis(Some("1100Mi")).elasticache_node_type().unwrap().0,
"cache.t4g.medium"
);
assert_eq!(
redis(Some("300Gi")).elasticache_node_type().unwrap().0,
"cache.r7g.16xlarge"
);
assert!(redis(Some("400Gi")).elasticache_node_type().is_err());
}
#[test]
fn memorystore_node_type_picks_smallest_fit_and_rejects_oversize() {
let redis = |m: Option<&str>| match m {
Some(m) => Redis::new("r".into()).memory(m.into()).build(),
None => Redis::new("r".into()).build(),
};
assert_eq!(
redis(None).memorystore_node_type().unwrap().0,
"SHARED_CORE_NANO"
);
assert_eq!(
redis(Some("1100Mi")).memorystore_node_type().unwrap().0,
"STANDARD_SMALL"
);
assert_eq!(
redis(Some("4Gi")).memorystore_node_type().unwrap().0,
"STANDARD_SMALL"
);
assert_eq!(
redis(Some("80Gi")).memorystore_node_type().unwrap().0,
"HIGHMEM_2XLARGE"
);
assert!(redis(Some("100Gi")).memorystore_node_type().is_err());
}
#[test]
fn azure_sku_picks_smallest_fit_and_rejects_above_the_nocluster_cap() {
let redis = |m: Option<&str>| match m {
Some(m) => Redis::new("r".into()).memory(m.into()).build(),
None => Redis::new("r".into()).build(),
};
assert_eq!(
redis(None).azure_managed_redis_sku().unwrap().0,
"Balanced_B1"
);
assert_eq!(
redis(Some("4Gi")).azure_managed_redis_sku().unwrap().0,
"Balanced_B5"
);
assert_eq!(
redis(Some("24Gi")).azure_managed_redis_sku().unwrap().0,
"Balanced_B20"
);
assert!(redis(Some("25Gi")).azure_managed_redis_sku().is_err());
}
}