alien_core/resources/
postgres.rs1use 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, PartialEq, Eq, Serialize, Deserialize, Builder)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16#[builder(start_fn = new)]
17pub struct Postgres {
18 #[builder(start_fn)]
19 pub id: String,
20 #[builder(default = default_version())]
22 #[serde(default = "default_version")]
23 #[cfg_attr(feature = "openapi", schema(default = default_version))]
24 pub version: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub cpu: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub memory: Option<String>,
31 #[builder(default = default_storage())]
33 #[serde(default = "default_storage")]
34 #[cfg_attr(feature = "openapi", schema(default = default_storage))]
35 pub storage: String,
36 #[builder(default)]
38 #[serde(default)]
39 pub high_availability: bool,
40 #[serde(skip_serializing_if = "Option::is_none")]
44 pub backend: Option<PostgresBackend>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(tag = "type", rename_all = "camelCase")]
52pub enum PostgresBackend {
53 AuroraServerlessV2,
54}
55
56impl Postgres {
57 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("postgres");
59
60 pub fn id(&self) -> &str {
62 &self.id
63 }
64}
65
66fn default_version() -> String {
67 "17".to_string()
68}
69
70fn default_storage() -> String {
71 "20Gi".to_string()
72}
73
74pub const POSTGRES_DATABASE_NAME: &str = "alien";
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85#[serde(rename_all = "camelCase")]
86pub struct PostgresOutputs {
87 pub endpoint: String,
89 pub database: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub port: Option<u16>,
95}
96
97impl ResourceOutputsDefinition for PostgresOutputs {
98 fn get_resource_type(&self) -> ResourceType {
99 Postgres::RESOURCE_TYPE.clone()
100 }
101
102 fn as_any(&self) -> &dyn Any {
103 self
104 }
105
106 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
107 Box::new(self.clone())
108 }
109
110 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
111 other.as_any().downcast_ref::<PostgresOutputs>() == Some(self)
112 }
113
114 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
115 serde_json::to_value(self)
116 }
117}
118
119impl ResourceDefinition for Postgres {
120 fn get_resource_type(&self) -> ResourceType {
121 Self::RESOURCE_TYPE
122 }
123
124 fn id(&self) -> &str {
125 &self.id
126 }
127
128 fn replace_after_failed_create_is_safe(&self) -> bool {
129 false
131 }
132
133 fn get_dependencies(&self) -> Vec<ResourceRef> {
134 Vec::new()
138 }
139
140 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
141 let new_pg = new_config
142 .as_any()
143 .downcast_ref::<Postgres>()
144 .ok_or_else(|| {
145 AlienError::new(ErrorData::UnexpectedResourceType {
146 resource_id: self.id.clone(),
147 expected: Self::RESOURCE_TYPE,
148 actual: new_config.get_resource_type(),
149 })
150 })?;
151
152 if self.id != new_pg.id {
153 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
154 resource_id: self.id.clone(),
155 reason: "the 'id' field is immutable".to_string(),
156 }));
157 }
158
159 if self.backend != new_pg.backend {
163 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
164 resource_id: self.id.clone(),
165 reason: "the 'backend' field is immutable once the resource exists".to_string(),
166 }));
167 }
168
169 let old_storage =
174 crate::instance_catalog::parse_memory_bytes(&self.storage).map_err(|e| {
175 AlienError::new(ErrorData::InvalidResourceUpdate {
176 resource_id: self.id.clone(),
177 reason: format!(
178 "current storage '{}' is not a valid size: {e}",
179 self.storage
180 ),
181 })
182 })?;
183 let new_storage =
184 crate::instance_catalog::parse_memory_bytes(&new_pg.storage).map_err(|e| {
185 AlienError::new(ErrorData::InvalidResourceUpdate {
186 resource_id: self.id.clone(),
187 reason: format!(
188 "requested storage '{}' is not a valid size: {e}",
189 new_pg.storage
190 ),
191 })
192 })?;
193 if new_storage < old_storage {
194 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
195 resource_id: self.id.clone(),
196 reason: format!(
197 "storage cannot shrink (from '{}' to '{}')",
198 self.storage, new_pg.storage
199 ),
200 }));
201 }
202
203 let old_version = self.version.parse::<u32>().map_err(|_| {
204 AlienError::new(ErrorData::InvalidResourceUpdate {
205 resource_id: self.id.clone(),
206 reason: format!(
207 "current engine version '{}' is not a numeric major",
208 self.version
209 ),
210 })
211 })?;
212 let new_version = new_pg.version.parse::<u32>().map_err(|_| {
213 AlienError::new(ErrorData::InvalidResourceUpdate {
214 resource_id: self.id.clone(),
215 reason: format!(
216 "requested engine version '{}' is not a numeric major",
217 new_pg.version
218 ),
219 })
220 })?;
221 if new_version < old_version {
222 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
223 resource_id: self.id.clone(),
224 reason: format!(
225 "engine version cannot downgrade (from '{}' to '{}')",
226 self.version, new_pg.version
227 ),
228 }));
229 }
230
231 Ok(())
232 }
233
234 fn as_any(&self) -> &dyn Any {
235 self
236 }
237
238 fn as_any_mut(&mut self) -> &mut dyn Any {
239 self
240 }
241
242 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
243 Box::new(self.clone())
244 }
245
246 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
247 other.as_any().downcast_ref::<Postgres>() == Some(self)
248 }
249
250 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
251 serde_json::to_value(self)
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn builder_applies_defaults() {
261 let pg = Postgres::new("db".to_string()).build();
262 assert_eq!(pg.id, "db");
263 assert_eq!(pg.version, "17");
264 assert_eq!(pg.storage, "20Gi");
265 assert!(!pg.high_availability);
266 assert_eq!(pg.cpu, None);
267 assert_eq!(pg.backend, None);
268 }
269
270 #[test]
271 fn resource_type_is_postgres() {
272 assert_eq!(Postgres::RESOURCE_TYPE.as_ref(), "postgres");
273 }
274
275 #[test]
276 fn validate_update_rejects_id_change() {
277 let original = Postgres::new("db".to_string()).build();
278 let renamed = Postgres::new("other".to_string()).build();
279 let err = original
280 .validate_update(&renamed)
281 .expect_err("changing the id must be rejected");
282 assert!(err.to_string().contains("'id' field is immutable"));
283 }
284
285 #[test]
286 fn validate_update_rejects_backend_change_including_none_to_some() {
287 let default_backend = Postgres::new("db".to_string()).build();
290 let explicit = Postgres::new("db".to_string())
291 .backend(PostgresBackend::AuroraServerlessV2)
292 .build();
293 let err = default_backend
294 .validate_update(&explicit)
295 .expect_err("None -> Some(backend) must be rejected");
296 assert!(err.to_string().contains("'backend' field is immutable"));
297 assert!(explicit.validate_update(&explicit).is_ok());
299 }
300
301 #[test]
302 fn validate_update_rejects_storage_shrink_and_allows_growth() {
303 let original = Postgres::new("db".to_string())
304 .storage("100Gi".to_string())
305 .build();
306 let shrunk = Postgres::new("db".to_string())
307 .storage("20Gi".to_string())
308 .build();
309 let grown = Postgres::new("db".to_string())
310 .storage("200Gi".to_string())
311 .build();
312
313 let err = original
314 .validate_update(&shrunk)
315 .expect_err("shrinking storage must be rejected");
316 assert!(err.to_string().contains("storage cannot shrink"));
317 assert!(original.validate_update(&grown).is_ok());
318 }
319
320 #[test]
321 fn validate_update_rejects_version_downgrade_and_allows_upgrade() {
322 let original = Postgres::new("db".to_string())
323 .version("16".to_string())
324 .build();
325 let downgrade = Postgres::new("db".to_string())
326 .version("15".to_string())
327 .build();
328 let upgrade = Postgres::new("db".to_string())
329 .version("17".to_string())
330 .build();
331
332 let err = original
333 .validate_update(&downgrade)
334 .expect_err("version downgrade must be rejected");
335 assert!(err.to_string().contains("engine version cannot downgrade"));
336 assert!(original.validate_update(&upgrade).is_ok());
337 }
338
339 #[test]
340 fn serializes_with_camel_case_and_backend() {
341 let pg = Postgres::new("db".to_string())
342 .backend(PostgresBackend::AuroraServerlessV2)
343 .high_availability(true)
344 .build();
345 let json = serde_json::to_value(&pg).unwrap();
346 assert_eq!(json["highAvailability"], true);
347 assert_eq!(json["backend"]["type"], "auroraServerlessV2");
348
349 let roundtrip: Postgres = serde_json::from_value(json).unwrap();
350 assert_eq!(pg, roundtrip);
351 }
352
353 #[test]
354 fn outputs_roundtrip() {
355 let outputs = PostgresOutputs {
356 endpoint: "127.0.0.1".to_string(),
357 database: "db".to_string(),
358 port: Some(5432),
359 };
360 let json = serde_json::to_string(&outputs).unwrap();
361 let deserialized: PostgresOutputs = serde_json::from_str(&json).unwrap();
362 assert_eq!(outputs, deserialized);
363 }
364}