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 get_dependencies(&self) -> Vec<ResourceRef> {
129 Vec::new()
133 }
134
135 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
136 let new_pg = new_config
137 .as_any()
138 .downcast_ref::<Postgres>()
139 .ok_or_else(|| {
140 AlienError::new(ErrorData::UnexpectedResourceType {
141 resource_id: self.id.clone(),
142 expected: Self::RESOURCE_TYPE,
143 actual: new_config.get_resource_type(),
144 })
145 })?;
146
147 if self.id != new_pg.id {
148 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
149 resource_id: self.id.clone(),
150 reason: "the 'id' field is immutable".to_string(),
151 }));
152 }
153
154 if self.backend != new_pg.backend {
158 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
159 resource_id: self.id.clone(),
160 reason: "the 'backend' field is immutable once the resource exists".to_string(),
161 }));
162 }
163
164 let old_storage =
169 crate::instance_catalog::parse_memory_bytes(&self.storage).map_err(|e| {
170 AlienError::new(ErrorData::InvalidResourceUpdate {
171 resource_id: self.id.clone(),
172 reason: format!(
173 "current storage '{}' is not a valid size: {e}",
174 self.storage
175 ),
176 })
177 })?;
178 let new_storage =
179 crate::instance_catalog::parse_memory_bytes(&new_pg.storage).map_err(|e| {
180 AlienError::new(ErrorData::InvalidResourceUpdate {
181 resource_id: self.id.clone(),
182 reason: format!(
183 "requested storage '{}' is not a valid size: {e}",
184 new_pg.storage
185 ),
186 })
187 })?;
188 if new_storage < old_storage {
189 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
190 resource_id: self.id.clone(),
191 reason: format!(
192 "storage cannot shrink (from '{}' to '{}')",
193 self.storage, new_pg.storage
194 ),
195 }));
196 }
197
198 let old_version = self.version.parse::<u32>().map_err(|_| {
199 AlienError::new(ErrorData::InvalidResourceUpdate {
200 resource_id: self.id.clone(),
201 reason: format!(
202 "current engine version '{}' is not a numeric major",
203 self.version
204 ),
205 })
206 })?;
207 let new_version = new_pg.version.parse::<u32>().map_err(|_| {
208 AlienError::new(ErrorData::InvalidResourceUpdate {
209 resource_id: self.id.clone(),
210 reason: format!(
211 "requested engine version '{}' is not a numeric major",
212 new_pg.version
213 ),
214 })
215 })?;
216 if new_version < old_version {
217 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
218 resource_id: self.id.clone(),
219 reason: format!(
220 "engine version cannot downgrade (from '{}' to '{}')",
221 self.version, new_pg.version
222 ),
223 }));
224 }
225
226 Ok(())
227 }
228
229 fn as_any(&self) -> &dyn Any {
230 self
231 }
232
233 fn as_any_mut(&mut self) -> &mut dyn Any {
234 self
235 }
236
237 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
238 Box::new(self.clone())
239 }
240
241 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
242 other.as_any().downcast_ref::<Postgres>() == Some(self)
243 }
244
245 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
246 serde_json::to_value(self)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn builder_applies_defaults() {
256 let pg = Postgres::new("db".to_string()).build();
257 assert_eq!(pg.id, "db");
258 assert_eq!(pg.version, "17");
259 assert_eq!(pg.storage, "20Gi");
260 assert!(!pg.high_availability);
261 assert_eq!(pg.cpu, None);
262 assert_eq!(pg.backend, None);
263 }
264
265 #[test]
266 fn resource_type_is_postgres() {
267 assert_eq!(Postgres::RESOURCE_TYPE.as_ref(), "postgres");
268 }
269
270 #[test]
271 fn validate_update_rejects_id_change() {
272 let original = Postgres::new("db".to_string()).build();
273 let renamed = Postgres::new("other".to_string()).build();
274 let err = original
275 .validate_update(&renamed)
276 .expect_err("changing the id must be rejected");
277 assert!(err.to_string().contains("'id' field is immutable"));
278 }
279
280 #[test]
281 fn validate_update_rejects_backend_change_including_none_to_some() {
282 let default_backend = Postgres::new("db".to_string()).build();
285 let explicit = Postgres::new("db".to_string())
286 .backend(PostgresBackend::AuroraServerlessV2)
287 .build();
288 let err = default_backend
289 .validate_update(&explicit)
290 .expect_err("None -> Some(backend) must be rejected");
291 assert!(err.to_string().contains("'backend' field is immutable"));
292 assert!(explicit.validate_update(&explicit).is_ok());
294 }
295
296 #[test]
297 fn validate_update_rejects_storage_shrink_and_allows_growth() {
298 let original = Postgres::new("db".to_string())
299 .storage("100Gi".to_string())
300 .build();
301 let shrunk = Postgres::new("db".to_string())
302 .storage("20Gi".to_string())
303 .build();
304 let grown = Postgres::new("db".to_string())
305 .storage("200Gi".to_string())
306 .build();
307
308 let err = original
309 .validate_update(&shrunk)
310 .expect_err("shrinking storage must be rejected");
311 assert!(err.to_string().contains("storage cannot shrink"));
312 assert!(original.validate_update(&grown).is_ok());
313 }
314
315 #[test]
316 fn validate_update_rejects_version_downgrade_and_allows_upgrade() {
317 let original = Postgres::new("db".to_string())
318 .version("16".to_string())
319 .build();
320 let downgrade = Postgres::new("db".to_string())
321 .version("15".to_string())
322 .build();
323 let upgrade = Postgres::new("db".to_string())
324 .version("17".to_string())
325 .build();
326
327 let err = original
328 .validate_update(&downgrade)
329 .expect_err("version downgrade must be rejected");
330 assert!(err.to_string().contains("engine version cannot downgrade"));
331 assert!(original.validate_update(&upgrade).is_ok());
332 }
333
334 #[test]
335 fn serializes_with_camel_case_and_backend() {
336 let pg = Postgres::new("db".to_string())
337 .backend(PostgresBackend::AuroraServerlessV2)
338 .high_availability(true)
339 .build();
340 let json = serde_json::to_value(&pg).unwrap();
341 assert_eq!(json["highAvailability"], true);
342 assert_eq!(json["backend"]["type"], "auroraServerlessV2");
343
344 let roundtrip: Postgres = serde_json::from_value(json).unwrap();
345 assert_eq!(pg, roundtrip);
346 }
347
348 #[test]
349 fn outputs_roundtrip() {
350 let outputs = PostgresOutputs {
351 endpoint: "127.0.0.1".to_string(),
352 database: "db".to_string(),
353 port: Some(5432),
354 };
355 let json = serde_json::to_string(&outputs).unwrap();
356 let deserialized: PostgresOutputs = serde_json::from_str(&json).unwrap();
357 assert_eq!(outputs, deserialized);
358 }
359}