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
192pub const AURORA_CLUSTER_PARAMETERS: [(&str, &str); 3] = [
198 ("rds.force_ssl", "1"),
199 ("shared_preload_libraries", "pg_stat_statements,pgaudit"),
200 ("pgaudit.log", "ddl,role"),
201];
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
206#[serde(rename_all = "camelCase")]
207pub struct PostgresOutputs {
208 pub endpoint: String,
210 pub database: String,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub port: Option<u16>,
216}
217
218impl ResourceOutputsDefinition for PostgresOutputs {
219 fn get_resource_type(&self) -> ResourceType {
220 Postgres::RESOURCE_TYPE.clone()
221 }
222
223 fn as_any(&self) -> &dyn Any {
224 self
225 }
226
227 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
228 Box::new(self.clone())
229 }
230
231 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
232 other.as_any().downcast_ref::<PostgresOutputs>() == Some(self)
233 }
234
235 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
236 serde_json::to_value(self)
237 }
238}
239
240impl ResourceDefinition for Postgres {
241 fn get_resource_type(&self) -> ResourceType {
242 Self::RESOURCE_TYPE
243 }
244
245 fn id(&self) -> &str {
246 &self.id
247 }
248
249 fn replace_after_failed_create_is_safe(&self) -> bool {
250 false
252 }
253
254 fn get_dependencies(&self) -> Vec<ResourceRef> {
255 self.encryption_key.iter().cloned().collect()
259 }
260
261 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
262 let new_pg = new_config
263 .as_any()
264 .downcast_ref::<Postgres>()
265 .ok_or_else(|| {
266 AlienError::new(ErrorData::UnexpectedResourceType {
267 resource_id: self.id.clone(),
268 expected: Self::RESOURCE_TYPE,
269 actual: new_config.get_resource_type(),
270 })
271 })?;
272
273 if self.id != new_pg.id {
274 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
275 resource_id: self.id.clone(),
276 reason: "the 'id' field is immutable".to_string(),
277 }));
278 }
279
280 if self.encryption_key != new_pg.encryption_key {
281 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
282 resource_id: self.id.clone(),
283 reason: "the 'encryptionKey' field is immutable once the resource exists"
284 .to_string(),
285 }));
286 }
287
288 if self.backend != new_pg.backend {
292 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
293 resource_id: self.id.clone(),
294 reason: "the 'backend' field is immutable once the resource exists".to_string(),
295 }));
296 }
297
298 let old_storage =
303 crate::instance_catalog::parse_memory_bytes(&self.storage).map_err(|e| {
304 AlienError::new(ErrorData::InvalidResourceUpdate {
305 resource_id: self.id.clone(),
306 reason: format!(
307 "current storage '{}' is not a valid size: {e}",
308 self.storage
309 ),
310 })
311 })?;
312 let new_storage =
313 crate::instance_catalog::parse_memory_bytes(&new_pg.storage).map_err(|e| {
314 AlienError::new(ErrorData::InvalidResourceUpdate {
315 resource_id: self.id.clone(),
316 reason: format!(
317 "requested storage '{}' is not a valid size: {e}",
318 new_pg.storage
319 ),
320 })
321 })?;
322 if new_storage < old_storage {
323 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
324 resource_id: self.id.clone(),
325 reason: format!(
326 "storage cannot shrink (from '{}' to '{}')",
327 self.storage, new_pg.storage
328 ),
329 }));
330 }
331
332 let old_version = self.version.parse::<u32>().map_err(|_| {
333 AlienError::new(ErrorData::InvalidResourceUpdate {
334 resource_id: self.id.clone(),
335 reason: format!(
336 "current engine version '{}' is not a numeric major",
337 self.version
338 ),
339 })
340 })?;
341 let new_version = new_pg.version.parse::<u32>().map_err(|_| {
342 AlienError::new(ErrorData::InvalidResourceUpdate {
343 resource_id: self.id.clone(),
344 reason: format!(
345 "requested engine version '{}' is not a numeric major",
346 new_pg.version
347 ),
348 })
349 })?;
350 if new_version < old_version {
351 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
352 resource_id: self.id.clone(),
353 reason: format!(
354 "engine version cannot downgrade (from '{}' to '{}')",
355 self.version, new_pg.version
356 ),
357 }));
358 }
359
360 Ok(())
361 }
362
363 fn as_any(&self) -> &dyn Any {
364 self
365 }
366
367 fn as_any_mut(&mut self) -> &mut dyn Any {
368 self
369 }
370
371 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
372 Box::new(self.clone())
373 }
374
375 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
376 other.as_any().downcast_ref::<Postgres>() == Some(self)
377 }
378
379 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
380 serde_json::to_value(self)
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn aurora_family_uses_the_major_of_pinned_minor_versions() {
390 for (version, family) in [
391 ("15", "aurora-postgresql15"),
392 ("16.8", "aurora-postgresql16"),
393 ("17.7", "aurora-postgresql17"),
394 ] {
395 let pg = Postgres::new("db".to_string())
396 .version(version.to_string())
397 .build();
398 assert_eq!(pg.aurora_parameter_group_family().unwrap(), family);
399 }
400 for version in ["", ".17", "latest", "17x"] {
401 let pg = Postgres::new("db".to_string())
402 .version(version.to_string())
403 .build();
404 assert!(pg.aurora_parameter_group_family().is_err());
405 }
406 }
407
408 #[test]
409 fn builder_applies_defaults() {
410 let pg = Postgres::new("db".to_string()).build();
411 assert_eq!(pg.id, "db");
412 assert_eq!(pg.version, "17");
413 assert_eq!(pg.storage, "20Gi");
414 assert!(!pg.high_availability);
415 assert_eq!(pg.cpu, None);
416 assert_eq!(pg.backend, None);
417 }
418
419 #[test]
420 fn resource_type_is_postgres() {
421 assert_eq!(Postgres::RESOURCE_TYPE.as_ref(), "postgres");
422 }
423
424 #[test]
425 fn validate_update_rejects_id_change() {
426 let original = Postgres::new("db".to_string()).build();
427 let renamed = Postgres::new("other".to_string()).build();
428 let err = original
429 .validate_update(&renamed)
430 .expect_err("changing the id must be rejected");
431 assert!(err.to_string().contains("'id' field is immutable"));
432 }
433
434 #[test]
435 fn validate_update_rejects_backend_change_including_none_to_some() {
436 let default_backend = Postgres::new("db".to_string()).build();
439 let explicit = Postgres::new("db".to_string())
440 .backend(PostgresBackend::AuroraServerlessV2)
441 .build();
442 let err = default_backend
443 .validate_update(&explicit)
444 .expect_err("None -> Some(backend) must be rejected");
445 assert!(err.to_string().contains("'backend' field is immutable"));
446 assert!(explicit.validate_update(&explicit).is_ok());
448 }
449
450 #[test]
451 fn validate_update_rejects_storage_shrink_and_allows_growth() {
452 let original = Postgres::new("db".to_string())
453 .storage("100Gi".to_string())
454 .build();
455 let shrunk = Postgres::new("db".to_string())
456 .storage("20Gi".to_string())
457 .build();
458 let grown = Postgres::new("db".to_string())
459 .storage("200Gi".to_string())
460 .build();
461
462 let err = original
463 .validate_update(&shrunk)
464 .expect_err("shrinking storage must be rejected");
465 assert!(err.to_string().contains("storage cannot shrink"));
466 assert!(original.validate_update(&grown).is_ok());
467 }
468
469 #[test]
470 fn validate_update_rejects_version_downgrade_and_allows_upgrade() {
471 let original = Postgres::new("db".to_string())
472 .version("16".to_string())
473 .build();
474 let downgrade = Postgres::new("db".to_string())
475 .version("15".to_string())
476 .build();
477 let upgrade = Postgres::new("db".to_string())
478 .version("17".to_string())
479 .build();
480
481 let err = original
482 .validate_update(&downgrade)
483 .expect_err("version downgrade must be rejected");
484 assert!(err.to_string().contains("engine version cannot downgrade"));
485 assert!(original.validate_update(&upgrade).is_ok());
486 }
487
488 #[test]
489 fn serializes_with_camel_case_and_backend() {
490 let pg = Postgres::new("db".to_string())
491 .backend(PostgresBackend::AuroraServerlessV2)
492 .high_availability(true)
493 .build();
494 let json = serde_json::to_value(&pg).unwrap();
495 assert_eq!(json["highAvailability"], true);
496 assert_eq!(json["backend"]["type"], "auroraServerlessV2");
497
498 let roundtrip: Postgres = serde_json::from_value(json).unwrap();
499 assert_eq!(pg, roundtrip);
500 }
501
502 #[test]
503 fn outputs_roundtrip() {
504 let outputs = PostgresOutputs {
505 endpoint: "127.0.0.1".to_string(),
506 database: "db".to_string(),
507 port: Some(5432),
508 };
509 let json = serde_json::to_string(&outputs).unwrap();
510 let deserialized: PostgresOutputs = serde_json::from_str(&json).unwrap();
511 assert_eq!(outputs, deserialized);
512 }
513}
514
515#[cfg(test)]
516mod sizing_tests {
517 use super::*;
518
519 #[test]
520 fn memory_units_and_aurora_half_steps_agree() {
521 for (quantity, gib, acu) in [
522 ("8", 8.0, 4.0),
523 ("1.5", 1.5, 1.0),
524 ("512Mi", 0.5, 1.0),
525 ("1Gi", 1.0, 1.0),
526 ("1.5Gi", 1.5, 1.0),
527 ("3Gi", 3.0, 1.5),
528 ("3.9Gi", 3.9, 2.0),
529 ("8Gi", 8.0, 4.0),
530 ("8GiB", 8.0, 4.0),
531 ("512Gi", 512.0, 256.0),
532 ("1GB", 1_000_000_000.0 / 1_073_741_824.0, 1.0),
533 ] {
534 let pg = Postgres::new("db".into()).memory(quantity.into()).build();
535 assert!(
536 (pg.requested_memory_gib().unwrap().unwrap() - gib).abs() < 1e-8,
537 "{quantity}"
538 );
539 assert_eq!(pg.aurora_max_capacity_acu().unwrap(), acu, "{quantity}");
540 }
541 assert_eq!(
542 Postgres::new("db".into())
543 .build()
544 .aurora_max_capacity_acu()
545 .unwrap(),
546 2.0
547 );
548 }
549
550 #[test]
551 fn sizing_rejects_invalid_nonpositive_nonfinite_and_overflow() {
552 for quantity in [
553 "", "0", "0Gi", "-1Gi", "NaNGi", "infGi", "1e100Gi", "8unknown",
554 ] {
555 let pg = Postgres::new("db".into()).memory(quantity.into()).build();
556 assert!(pg.requested_memory_gib().is_err(), "{quantity}");
557 assert!(pg.aurora_max_capacity_acu().is_err(), "{quantity}");
558 }
559 assert!(Postgres::new("db".into())
560 .memory("513Gi".into())
561 .build()
562 .aurora_max_capacity_acu()
563 .is_err());
564 for cpu in ["0", "-1", "NaN", "inf", "invalid"] {
565 assert!(
566 Postgres::new("db".into())
567 .cpu(cpu.into())
568 .build()
569 .requested_cpu()
570 .is_err(),
571 "{cpu}"
572 );
573 }
574 assert_eq!(
575 Postgres::new("db".into())
576 .cpu("500m".into())
577 .build()
578 .requested_cpu()
579 .unwrap(),
580 Some(0.5)
581 );
582 assert_eq!(
583 Postgres::new("db".into())
584 .storage("1536Mi".into())
585 .build()
586 .requested_storage_gib()
587 .unwrap(),
588 2
589 );
590 }
591}