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 validate_aurora_availability(&self) -> Result<()> {
66 if self.high_availability {
67 tracing::warn!(resource_id = %self.id, "Aurora highAvailability does not yet provision a reader for compute failover; this deployment has one compute instance. Aurora storage remains replicated across Availability Zones.");
68 }
69 Ok(())
70 }
71
72 pub fn aurora_parameter_group_family(&self) -> Result<String> {
75 let major = self
76 .version
77 .split('.')
78 .next()
79 .filter(|major| !major.is_empty() && major.bytes().all(|byte| byte.is_ascii_digit()))
80 .ok_or_else(|| {
81 AlienError::new(ErrorData::OperationNotSupported {
82 operation: "Aurora parameter group family".to_string(),
83 reason: format!("invalid Postgres engine version '{}'", self.version),
84 })
85 })?;
86 Ok(format!("aurora-postgresql{major}"))
87 }
88
89 pub fn requested_cpu(&self) -> Result<Option<f64>> {
91 self.cpu
92 .as_deref()
93 .map(|value| {
94 crate::instance_catalog::parse_cpu(value)
95 .ok()
96 .filter(|cpu| cpu.is_finite() && *cpu > 0.0)
97 .ok_or_else(|| self.invalid_size("cpu", value))
98 })
99 .transpose()
100 }
101
102 pub fn requested_memory_gib(&self) -> Result<Option<f64>> {
105 self.memory
106 .as_deref()
107 .map(|value| {
108 if let Ok(gib) = value.trim().parse::<f64>() {
109 return if gib.is_finite() && gib > 0.0 {
110 Ok(gib)
111 } else {
112 Err(self.invalid_size("memory", value))
113 };
114 }
115 self.size_gib("memory", value)
116 })
117 .transpose()
118 }
119
120 pub fn requested_storage_gib(&self) -> Result<u64> {
122 Ok(self.size_gib("storage", &self.storage)?.ceil() as u64)
123 }
124
125 fn size_gib(&self, field: &str, value: &str) -> Result<f64> {
126 let quantity = value.trim();
127 let quantity = quantity.strip_suffix('B').unwrap_or(quantity);
129 crate::instance_catalog::parse_memory_bytes(quantity)
130 .ok()
131 .filter(|bytes| *bytes > 0)
132 .map(|bytes| bytes as f64 / 1024_f64.powi(3))
133 .ok_or_else(|| self.invalid_size(field, value))
134 }
135
136 fn invalid_size(&self, field: &str, value: &str) -> alien_error::AlienError<ErrorData> {
137 AlienError::new(ErrorData::OperationNotSupported {
138 operation: format!("Postgres {field} sizing"),
139 reason: format!(
140 "'{value}' must be a positive finite resource quantity{}",
141 if field == "memory" {
142 " in GiB or with a unit, such as 512Mi or 4Gi"
143 } else {
144 ""
145 }
146 ),
147 })
148 }
149
150 pub fn aurora_max_capacity_acu(&self) -> Result<f64> {
154 let Some(gib) = self.requested_memory_gib()? else {
155 return Ok(2.0);
156 };
157 let capacity = (gib.ceil() / 2.0).max(1.0);
158 if capacity > 256.0 {
159 return Err(AlienError::new(ErrorData::OperationNotSupported {
160 operation: "Aurora memory sizing".to_string(),
161 reason: "request exceeds the supported maximum of 512 GiB (256 ACU)".to_string(),
162 }));
163 }
164 Ok(capacity)
165 }
166
167 pub fn id(&self) -> &str {
169 &self.id
170 }
171}
172
173fn default_version() -> String {
174 "17".to_string()
175}
176
177fn default_storage() -> String {
178 "20Gi".to_string()
179}
180
181pub const POSTGRES_DATABASE_NAME: &str = "app";
188
189pub const POSTGRES_ADMIN_USERNAME: &str = "app_admin";
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
195#[serde(rename_all = "camelCase")]
196pub struct PostgresOutputs {
197 pub endpoint: String,
199 pub database: String,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub port: Option<u16>,
205}
206
207impl ResourceOutputsDefinition for PostgresOutputs {
208 fn get_resource_type(&self) -> ResourceType {
209 Postgres::RESOURCE_TYPE.clone()
210 }
211
212 fn as_any(&self) -> &dyn Any {
213 self
214 }
215
216 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
217 Box::new(self.clone())
218 }
219
220 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
221 other.as_any().downcast_ref::<PostgresOutputs>() == Some(self)
222 }
223
224 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
225 serde_json::to_value(self)
226 }
227}
228
229impl ResourceDefinition for Postgres {
230 fn get_resource_type(&self) -> ResourceType {
231 Self::RESOURCE_TYPE
232 }
233
234 fn id(&self) -> &str {
235 &self.id
236 }
237
238 fn replace_after_failed_create_is_safe(&self) -> bool {
239 false
241 }
242
243 fn get_dependencies(&self) -> Vec<ResourceRef> {
244 self.encryption_key.iter().cloned().collect()
248 }
249
250 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
251 let new_pg = new_config
252 .as_any()
253 .downcast_ref::<Postgres>()
254 .ok_or_else(|| {
255 AlienError::new(ErrorData::UnexpectedResourceType {
256 resource_id: self.id.clone(),
257 expected: Self::RESOURCE_TYPE,
258 actual: new_config.get_resource_type(),
259 })
260 })?;
261
262 if self.id != new_pg.id {
263 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
264 resource_id: self.id.clone(),
265 reason: "the 'id' field is immutable".to_string(),
266 }));
267 }
268
269 if self.encryption_key != new_pg.encryption_key {
270 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
271 resource_id: self.id.clone(),
272 reason: "the 'encryptionKey' field is immutable once the resource exists"
273 .to_string(),
274 }));
275 }
276
277 if self.backend != new_pg.backend {
281 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
282 resource_id: self.id.clone(),
283 reason: "the 'backend' field is immutable once the resource exists".to_string(),
284 }));
285 }
286
287 let old_storage =
292 crate::instance_catalog::parse_memory_bytes(&self.storage).map_err(|e| {
293 AlienError::new(ErrorData::InvalidResourceUpdate {
294 resource_id: self.id.clone(),
295 reason: format!(
296 "current storage '{}' is not a valid size: {e}",
297 self.storage
298 ),
299 })
300 })?;
301 let new_storage =
302 crate::instance_catalog::parse_memory_bytes(&new_pg.storage).map_err(|e| {
303 AlienError::new(ErrorData::InvalidResourceUpdate {
304 resource_id: self.id.clone(),
305 reason: format!(
306 "requested storage '{}' is not a valid size: {e}",
307 new_pg.storage
308 ),
309 })
310 })?;
311 if new_storage < old_storage {
312 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
313 resource_id: self.id.clone(),
314 reason: format!(
315 "storage cannot shrink (from '{}' to '{}')",
316 self.storage, new_pg.storage
317 ),
318 }));
319 }
320
321 let old_version = self.version.parse::<u32>().map_err(|_| {
322 AlienError::new(ErrorData::InvalidResourceUpdate {
323 resource_id: self.id.clone(),
324 reason: format!(
325 "current engine version '{}' is not a numeric major",
326 self.version
327 ),
328 })
329 })?;
330 let new_version = new_pg.version.parse::<u32>().map_err(|_| {
331 AlienError::new(ErrorData::InvalidResourceUpdate {
332 resource_id: self.id.clone(),
333 reason: format!(
334 "requested engine version '{}' is not a numeric major",
335 new_pg.version
336 ),
337 })
338 })?;
339 if new_version < old_version {
340 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
341 resource_id: self.id.clone(),
342 reason: format!(
343 "engine version cannot downgrade (from '{}' to '{}')",
344 self.version, new_pg.version
345 ),
346 }));
347 }
348
349 Ok(())
350 }
351
352 fn as_any(&self) -> &dyn Any {
353 self
354 }
355
356 fn as_any_mut(&mut self) -> &mut dyn Any {
357 self
358 }
359
360 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
361 Box::new(self.clone())
362 }
363
364 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
365 other.as_any().downcast_ref::<Postgres>() == Some(self)
366 }
367
368 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
369 serde_json::to_value(self)
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn aurora_family_uses_the_major_of_pinned_minor_versions() {
379 for (version, family) in [
380 ("15", "aurora-postgresql15"),
381 ("16.8", "aurora-postgresql16"),
382 ("17.7", "aurora-postgresql17"),
383 ] {
384 let pg = Postgres::new("db".to_string())
385 .version(version.to_string())
386 .build();
387 assert_eq!(pg.aurora_parameter_group_family().unwrap(), family);
388 }
389 for version in ["", ".17", "latest", "17x"] {
390 let pg = Postgres::new("db".to_string())
391 .version(version.to_string())
392 .build();
393 assert!(pg.aurora_parameter_group_family().is_err());
394 }
395 }
396
397 #[test]
398 fn builder_applies_defaults() {
399 let pg = Postgres::new("db".to_string()).build();
400 assert_eq!(pg.id, "db");
401 assert_eq!(pg.version, "17");
402 assert_eq!(pg.storage, "20Gi");
403 assert!(!pg.high_availability);
404 assert_eq!(pg.cpu, None);
405 assert_eq!(pg.backend, None);
406 }
407
408 #[test]
409 fn resource_type_is_postgres() {
410 assert_eq!(Postgres::RESOURCE_TYPE.as_ref(), "postgres");
411 }
412
413 #[test]
414 fn validate_update_rejects_id_change() {
415 let original = Postgres::new("db".to_string()).build();
416 let renamed = Postgres::new("other".to_string()).build();
417 let err = original
418 .validate_update(&renamed)
419 .expect_err("changing the id must be rejected");
420 assert!(err.to_string().contains("'id' field is immutable"));
421 }
422
423 #[test]
424 fn validate_update_rejects_backend_change_including_none_to_some() {
425 let default_backend = Postgres::new("db".to_string()).build();
428 let explicit = Postgres::new("db".to_string())
429 .backend(PostgresBackend::AuroraServerlessV2)
430 .build();
431 let err = default_backend
432 .validate_update(&explicit)
433 .expect_err("None -> Some(backend) must be rejected");
434 assert!(err.to_string().contains("'backend' field is immutable"));
435 assert!(explicit.validate_update(&explicit).is_ok());
437 }
438
439 #[test]
440 fn validate_update_rejects_storage_shrink_and_allows_growth() {
441 let original = Postgres::new("db".to_string())
442 .storage("100Gi".to_string())
443 .build();
444 let shrunk = Postgres::new("db".to_string())
445 .storage("20Gi".to_string())
446 .build();
447 let grown = Postgres::new("db".to_string())
448 .storage("200Gi".to_string())
449 .build();
450
451 let err = original
452 .validate_update(&shrunk)
453 .expect_err("shrinking storage must be rejected");
454 assert!(err.to_string().contains("storage cannot shrink"));
455 assert!(original.validate_update(&grown).is_ok());
456 }
457
458 #[test]
459 fn validate_update_rejects_version_downgrade_and_allows_upgrade() {
460 let original = Postgres::new("db".to_string())
461 .version("16".to_string())
462 .build();
463 let downgrade = Postgres::new("db".to_string())
464 .version("15".to_string())
465 .build();
466 let upgrade = Postgres::new("db".to_string())
467 .version("17".to_string())
468 .build();
469
470 let err = original
471 .validate_update(&downgrade)
472 .expect_err("version downgrade must be rejected");
473 assert!(err.to_string().contains("engine version cannot downgrade"));
474 assert!(original.validate_update(&upgrade).is_ok());
475 }
476
477 #[test]
478 fn serializes_with_camel_case_and_backend() {
479 let pg = Postgres::new("db".to_string())
480 .backend(PostgresBackend::AuroraServerlessV2)
481 .high_availability(true)
482 .build();
483 let json = serde_json::to_value(&pg).unwrap();
484 assert_eq!(json["highAvailability"], true);
485 assert_eq!(json["backend"]["type"], "auroraServerlessV2");
486
487 let roundtrip: Postgres = serde_json::from_value(json).unwrap();
488 assert_eq!(pg, roundtrip);
489 }
490
491 #[test]
492 fn outputs_roundtrip() {
493 let outputs = PostgresOutputs {
494 endpoint: "127.0.0.1".to_string(),
495 database: "db".to_string(),
496 port: Some(5432),
497 };
498 let json = serde_json::to_string(&outputs).unwrap();
499 let deserialized: PostgresOutputs = serde_json::from_str(&json).unwrap();
500 assert_eq!(outputs, deserialized);
501 }
502}
503
504#[cfg(test)]
505mod sizing_tests {
506 use super::*;
507
508 #[test]
509 fn memory_units_and_aurora_half_steps_agree() {
510 for (quantity, gib, acu) in [
511 ("8", 8.0, 4.0),
512 ("1.5", 1.5, 1.0),
513 ("512Mi", 0.5, 1.0),
514 ("1Gi", 1.0, 1.0),
515 ("1.5Gi", 1.5, 1.0),
516 ("3Gi", 3.0, 1.5),
517 ("3.9Gi", 3.9, 2.0),
518 ("8Gi", 8.0, 4.0),
519 ("8GiB", 8.0, 4.0),
520 ("512Gi", 512.0, 256.0),
521 ("1GB", 1_000_000_000.0 / 1_073_741_824.0, 1.0),
522 ] {
523 let pg = Postgres::new("db".into()).memory(quantity.into()).build();
524 assert!(
525 (pg.requested_memory_gib().unwrap().unwrap() - gib).abs() < 1e-8,
526 "{quantity}"
527 );
528 assert_eq!(pg.aurora_max_capacity_acu().unwrap(), acu, "{quantity}");
529 }
530 assert_eq!(
531 Postgres::new("db".into())
532 .build()
533 .aurora_max_capacity_acu()
534 .unwrap(),
535 2.0
536 );
537 }
538
539 #[test]
540 fn sizing_rejects_invalid_nonpositive_nonfinite_and_overflow() {
541 for quantity in [
542 "", "0", "0Gi", "-1Gi", "NaNGi", "infGi", "1e100Gi", "8unknown",
543 ] {
544 let pg = Postgres::new("db".into()).memory(quantity.into()).build();
545 assert!(pg.requested_memory_gib().is_err(), "{quantity}");
546 assert!(pg.aurora_max_capacity_acu().is_err(), "{quantity}");
547 }
548 assert!(Postgres::new("db".into())
549 .memory("513Gi".into())
550 .build()
551 .aurora_max_capacity_acu()
552 .is_err());
553 for cpu in ["0", "-1", "NaN", "inf", "invalid"] {
554 assert!(
555 Postgres::new("db".into())
556 .cpu(cpu.into())
557 .build()
558 .requested_cpu()
559 .is_err(),
560 "{cpu}"
561 );
562 }
563 assert_eq!(
564 Postgres::new("db".into())
565 .cpu("500m".into())
566 .build()
567 .requested_cpu()
568 .unwrap(),
569 Some(0.5)
570 );
571 assert_eq!(
572 Postgres::new("db".into())
573 .storage("1536Mi".into())
574 .build()
575 .requested_storage_gib()
576 .unwrap(),
577 2
578 );
579 }
580}