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