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, 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 aurora_parameter_group_family(&self) -> Result<String> {
67 let major = self
68 .version
69 .split('.')
70 .next()
71 .filter(|major| !major.is_empty() && major.bytes().all(|byte| byte.is_ascii_digit()))
72 .ok_or_else(|| {
73 AlienError::new(ErrorData::OperationNotSupported {
74 operation: "Aurora parameter group family".to_string(),
75 reason: format!("invalid Postgres engine version '{}'", self.version),
76 })
77 })?;
78 Ok(format!("aurora-postgresql{major}"))
79 }
80
81 pub fn id(&self) -> &str {
83 &self.id
84 }
85}
86
87fn default_version() -> String {
88 "17".to_string()
89}
90
91fn default_storage() -> String {
92 "20Gi".to_string()
93}
94
95pub const POSTGRES_DATABASE_NAME: &str = "app";
102
103pub const POSTGRES_ADMIN_USERNAME: &str = "app_admin";
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
109#[serde(rename_all = "camelCase")]
110pub struct PostgresOutputs {
111 pub endpoint: String,
113 pub database: String,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub port: Option<u16>,
119}
120
121impl ResourceOutputsDefinition for PostgresOutputs {
122 fn get_resource_type(&self) -> ResourceType {
123 Postgres::RESOURCE_TYPE.clone()
124 }
125
126 fn as_any(&self) -> &dyn Any {
127 self
128 }
129
130 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
131 Box::new(self.clone())
132 }
133
134 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
135 other.as_any().downcast_ref::<PostgresOutputs>() == Some(self)
136 }
137
138 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
139 serde_json::to_value(self)
140 }
141}
142
143impl ResourceDefinition for Postgres {
144 fn get_resource_type(&self) -> ResourceType {
145 Self::RESOURCE_TYPE
146 }
147
148 fn id(&self) -> &str {
149 &self.id
150 }
151
152 fn replace_after_failed_create_is_safe(&self) -> bool {
153 false
155 }
156
157 fn get_dependencies(&self) -> Vec<ResourceRef> {
158 self.encryption_key.iter().cloned().collect()
162 }
163
164 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
165 let new_pg = new_config
166 .as_any()
167 .downcast_ref::<Postgres>()
168 .ok_or_else(|| {
169 AlienError::new(ErrorData::UnexpectedResourceType {
170 resource_id: self.id.clone(),
171 expected: Self::RESOURCE_TYPE,
172 actual: new_config.get_resource_type(),
173 })
174 })?;
175
176 if self.id != new_pg.id {
177 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
178 resource_id: self.id.clone(),
179 reason: "the 'id' field is immutable".to_string(),
180 }));
181 }
182
183 if self.encryption_key != new_pg.encryption_key {
184 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
185 resource_id: self.id.clone(),
186 reason: "the 'encryptionKey' field is immutable once the resource exists"
187 .to_string(),
188 }));
189 }
190
191 if self.backend != new_pg.backend {
195 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
196 resource_id: self.id.clone(),
197 reason: "the 'backend' field is immutable once the resource exists".to_string(),
198 }));
199 }
200
201 let old_storage =
206 crate::instance_catalog::parse_memory_bytes(&self.storage).map_err(|e| {
207 AlienError::new(ErrorData::InvalidResourceUpdate {
208 resource_id: self.id.clone(),
209 reason: format!(
210 "current storage '{}' is not a valid size: {e}",
211 self.storage
212 ),
213 })
214 })?;
215 let new_storage =
216 crate::instance_catalog::parse_memory_bytes(&new_pg.storage).map_err(|e| {
217 AlienError::new(ErrorData::InvalidResourceUpdate {
218 resource_id: self.id.clone(),
219 reason: format!(
220 "requested storage '{}' is not a valid size: {e}",
221 new_pg.storage
222 ),
223 })
224 })?;
225 if new_storage < old_storage {
226 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
227 resource_id: self.id.clone(),
228 reason: format!(
229 "storage cannot shrink (from '{}' to '{}')",
230 self.storage, new_pg.storage
231 ),
232 }));
233 }
234
235 let old_version = self.version.parse::<u32>().map_err(|_| {
236 AlienError::new(ErrorData::InvalidResourceUpdate {
237 resource_id: self.id.clone(),
238 reason: format!(
239 "current engine version '{}' is not a numeric major",
240 self.version
241 ),
242 })
243 })?;
244 let new_version = new_pg.version.parse::<u32>().map_err(|_| {
245 AlienError::new(ErrorData::InvalidResourceUpdate {
246 resource_id: self.id.clone(),
247 reason: format!(
248 "requested engine version '{}' is not a numeric major",
249 new_pg.version
250 ),
251 })
252 })?;
253 if new_version < old_version {
254 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
255 resource_id: self.id.clone(),
256 reason: format!(
257 "engine version cannot downgrade (from '{}' to '{}')",
258 self.version, new_pg.version
259 ),
260 }));
261 }
262
263 Ok(())
264 }
265
266 fn as_any(&self) -> &dyn Any {
267 self
268 }
269
270 fn as_any_mut(&mut self) -> &mut dyn Any {
271 self
272 }
273
274 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
275 Box::new(self.clone())
276 }
277
278 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
279 other.as_any().downcast_ref::<Postgres>() == Some(self)
280 }
281
282 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
283 serde_json::to_value(self)
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn aurora_family_uses_the_major_of_pinned_minor_versions() {
293 for (version, family) in [
294 ("15", "aurora-postgresql15"),
295 ("16.8", "aurora-postgresql16"),
296 ("17.7", "aurora-postgresql17"),
297 ] {
298 let pg = Postgres::new("db".to_string())
299 .version(version.to_string())
300 .build();
301 assert_eq!(pg.aurora_parameter_group_family().unwrap(), family);
302 }
303 for version in ["", ".17", "latest", "17x"] {
304 let pg = Postgres::new("db".to_string())
305 .version(version.to_string())
306 .build();
307 assert!(pg.aurora_parameter_group_family().is_err());
308 }
309 }
310
311 #[test]
312 fn builder_applies_defaults() {
313 let pg = Postgres::new("db".to_string()).build();
314 assert_eq!(pg.id, "db");
315 assert_eq!(pg.version, "17");
316 assert_eq!(pg.storage, "20Gi");
317 assert!(!pg.high_availability);
318 assert_eq!(pg.cpu, None);
319 assert_eq!(pg.backend, None);
320 }
321
322 #[test]
323 fn resource_type_is_postgres() {
324 assert_eq!(Postgres::RESOURCE_TYPE.as_ref(), "postgres");
325 }
326
327 #[test]
328 fn validate_update_rejects_id_change() {
329 let original = Postgres::new("db".to_string()).build();
330 let renamed = Postgres::new("other".to_string()).build();
331 let err = original
332 .validate_update(&renamed)
333 .expect_err("changing the id must be rejected");
334 assert!(err.to_string().contains("'id' field is immutable"));
335 }
336
337 #[test]
338 fn validate_update_rejects_backend_change_including_none_to_some() {
339 let default_backend = Postgres::new("db".to_string()).build();
342 let explicit = Postgres::new("db".to_string())
343 .backend(PostgresBackend::AuroraServerlessV2)
344 .build();
345 let err = default_backend
346 .validate_update(&explicit)
347 .expect_err("None -> Some(backend) must be rejected");
348 assert!(err.to_string().contains("'backend' field is immutable"));
349 assert!(explicit.validate_update(&explicit).is_ok());
351 }
352
353 #[test]
354 fn validate_update_rejects_storage_shrink_and_allows_growth() {
355 let original = Postgres::new("db".to_string())
356 .storage("100Gi".to_string())
357 .build();
358 let shrunk = Postgres::new("db".to_string())
359 .storage("20Gi".to_string())
360 .build();
361 let grown = Postgres::new("db".to_string())
362 .storage("200Gi".to_string())
363 .build();
364
365 let err = original
366 .validate_update(&shrunk)
367 .expect_err("shrinking storage must be rejected");
368 assert!(err.to_string().contains("storage cannot shrink"));
369 assert!(original.validate_update(&grown).is_ok());
370 }
371
372 #[test]
373 fn validate_update_rejects_version_downgrade_and_allows_upgrade() {
374 let original = Postgres::new("db".to_string())
375 .version("16".to_string())
376 .build();
377 let downgrade = Postgres::new("db".to_string())
378 .version("15".to_string())
379 .build();
380 let upgrade = Postgres::new("db".to_string())
381 .version("17".to_string())
382 .build();
383
384 let err = original
385 .validate_update(&downgrade)
386 .expect_err("version downgrade must be rejected");
387 assert!(err.to_string().contains("engine version cannot downgrade"));
388 assert!(original.validate_update(&upgrade).is_ok());
389 }
390
391 #[test]
392 fn serializes_with_camel_case_and_backend() {
393 let pg = Postgres::new("db".to_string())
394 .backend(PostgresBackend::AuroraServerlessV2)
395 .high_availability(true)
396 .build();
397 let json = serde_json::to_value(&pg).unwrap();
398 assert_eq!(json["highAvailability"], true);
399 assert_eq!(json["backend"]["type"], "auroraServerlessV2");
400
401 let roundtrip: Postgres = serde_json::from_value(json).unwrap();
402 assert_eq!(pg, roundtrip);
403 }
404
405 #[test]
406 fn outputs_roundtrip() {
407 let outputs = PostgresOutputs {
408 endpoint: "127.0.0.1".to_string(),
409 database: "db".to_string(),
410 port: Some(5432),
411 };
412 let json = serde_json::to_string(&outputs).unwrap();
413 let deserialized: PostgresOutputs = serde_json::from_str(&json).unwrap();
414 assert_eq!(outputs, deserialized);
415 }
416}