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