Skip to main content

alien_core/resources/
postgres.rs

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/// A managed PostgreSQL database. The target platform decides the backend
11/// (AWS Aurora Serverless v2, GCP Cloud SQL, Azure Flexible Server, or an
12/// embedded native process on Local); the database is never publicly reachable.
13#[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    /// Major engine version: "15" | "16" | "17". Default "17".
21    #[builder(default = default_version())]
22    #[serde(default = "default_version")]
23    #[cfg_attr(feature = "openapi", schema(default = default_version))]
24    pub version: String,
25    /// Requested vCPUs (e.g. "0.5", "2"). None = smallest available tier.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub cpu: Option<String>,
28    /// Requested memory (e.g. "1Gi", "8Gi"). None = smallest available tier.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub memory: Option<String>,
31    /// Allocated storage (e.g. "20Gi"). Default "20Gi". Grow-only.
32    #[builder(default = default_storage())]
33    #[serde(default = "default_storage")]
34    #[cfg_attr(feature = "openapi", schema(default = default_storage))]
35    pub storage: String,
36    /// Multi-AZ / regional / zone-redundant high availability. Default false.
37    #[builder(default)]
38    #[serde(default)]
39    pub high_availability: bool,
40    /// Key used to encrypt database storage, snapshots, and performance data.
41    /// Cannot be changed after creation.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub encryption_key: Option<ResourceRef>,
44    /// Cloud backend selector; None resolves to the platform default. The field
45    /// exists so a single per-cloud controller can dispatch on it without a later
46    /// schema change — the registry allows only one controller per (resource, cloud).
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub backend: Option<PostgresBackend>,
49}
50
51/// Cloud backend for a Postgres resource. Only Aurora Serverless v2 ships in v1;
52/// the enum reserves the slot so provisioned RDS is an additive variant later.
53#[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    /// The resource type identifier for Postgres.
62    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("postgres");
63
64    /// Warn about the current single-instance compute topology without rejecting legacy declarations.
65    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    /// Aurora parameter groups are tied to the engine's major version, even when a
73    /// specific minor release is requested.
74    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    /// Requested positive finite vCPU count, including millicore quantities.
90    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    /// Requested memory in GiB. Binary and decimal units retain their meanings;
103    /// unitless memory retains its historical GiB meaning.
104    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    /// Requested disk size in GiB, rounded up to a whole provider allocation unit.
121    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        // Accept the conventional GiB/GB spellings as aliases of Gi/G.
128        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    /// Aurora Serverless v2 ceiling in half-ACU increments (2 GiB per ACU).
151    /// The supported engine configuration allows up to 256 ACU; never clamp a
152    /// larger request downward. The minimum ceiling is 1 ACU. An unspecified ceiling remains 2 ACU.
153    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    /// Returns the database's unique identifier.
168    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
181/// The fixed inner database name for **cloud** Postgres backends.
182/// AWS rejects hyphens in `DatabaseName`, and the cluster/instance/server name already
183/// carries the resource id, so the inner database is a fixed label rather than the id.
184/// Every cloud emitter and controller derives its database name from this single constant
185/// so a frozen import always binds to a database that exists. (Local names its database
186/// after the resource id and does not use this.)
187pub const POSTGRES_DATABASE_NAME: &str = "app";
188
189/// The administrator created for managed cloud Postgres databases.
190pub const POSTGRES_ADMIN_USERNAME: &str = "app_admin";
191
192/// One Aurora cluster parameter group value and how a running writer picks it up.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct AuroraClusterParameter {
195    /// Parameter name.
196    pub name: &'static str,
197    /// Parameter value.
198    pub value: &'static str,
199    /// RDS `ApplyMethod`: `immediate` (dynamic parameters only) or `pending-reboot`.
200    pub apply_method: &'static str,
201}
202
203/// Recommended cluster parameter group values for Aurora PostgreSQL, for any setup template
204/// or controller that creates or updates the group. Connections must use TLS, and pgaudit
205/// logs schema (`ddl`) and permission (`role`) changes. `shared_preload_libraries` keeps
206/// Aurora's default `pg_stat_statements` for families 14 to 18. It is a static parameter, so
207/// a running writer loads pgaudit at its next restart or its next resume from auto-pause.
208/// `log_min_error_statement = panic` keeps the text of failing statements, which can hold a
209/// password, out of the logs; the error message itself is still logged. It is dynamic and
210/// applies to a running writer without a restart.
211pub 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/// Outputs generated by a successfully provisioned Postgres database.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
237#[serde(rename_all = "camelCase")]
238pub struct PostgresOutputs {
239    /// Host (or host:port) the database listens on; never publicly reachable.
240    pub endpoint: String,
241    /// The default database created for this resource. On cloud backends this is the fixed
242    /// label `POSTGRES_DATABASE_NAME` (`app`); on Local it is the resource id.
243    pub database: String,
244    /// Listening port, when known.
245    #[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        // Deleting it destroys the database contents.
282        false
283    }
284
285    fn get_dependencies(&self) -> Vec<ResourceRef> {
286        // The network dependency is added by infrastructure preflights on cloud platforms and
287        // is irrelevant on Local; cloud controllers reach the network via
288        // require_dependency() at runtime, so nothing is declared statically here.
289        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        // `backend` selects the physical engine, so it is immutable once the resource exists. Reject
320        // ANY change including `None -> Some(...)`: `None` already resolves to the platform default, and
321        // once a second variant exists a `None -> Some(other)` edit would silently switch the live engine.
322        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        // Storage and version are monotonic: no cloud supports a volume shrink or an in-place
330        // engine downgrade, so reject rather than let Update reach an impossible state. Parse
331        // explicitly and fail on a malformed value — a value we can't parse must NOT silently skip
332        // the guard, or a shrink/downgrade would slip through unchecked (fail-open).
333        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        // `None` already resolves to the platform default, so even making it explicit is rejected —
468        // backend is pinned once the resource exists.
469        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        // A no-op (unchanged backend) is allowed.
478        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}