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/// Recommended cluster parameter group values for Aurora PostgreSQL, for any setup template
193/// or controller that creates or updates the group. Connections must use TLS, and pgaudit
194/// logs schema (`ddl`) and permission (`role`) changes. `shared_preload_libraries` keeps
195/// Aurora's default `pg_stat_statements` for families 14 to 18. It is a static parameter, so
196/// a running writer loads pgaudit at its next restart or its next resume from auto-pause.
197pub 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/// Outputs generated by a successfully provisioned Postgres database.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
206#[serde(rename_all = "camelCase")]
207pub struct PostgresOutputs {
208    /// Host (or host:port) the database listens on; never publicly reachable.
209    pub endpoint: String,
210    /// The default database created for this resource. On cloud backends this is the fixed
211    /// label `POSTGRES_DATABASE_NAME` (`app`); on Local it is the resource id.
212    pub database: String,
213    /// Listening port, when known.
214    #[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        // Deleting it destroys the database contents.
251        false
252    }
253
254    fn get_dependencies(&self) -> Vec<ResourceRef> {
255        // The network dependency is added by infrastructure preflights on cloud platforms and
256        // is irrelevant on Local; cloud controllers reach the network via
257        // require_dependency() at runtime, so nothing is declared statically here.
258        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        // `backend` selects the physical engine, so it is immutable once the resource exists. Reject
289        // ANY change including `None -> Some(...)`: `None` already resolves to the platform default, and
290        // once a second variant exists a `None -> Some(other)` edit would silently switch the live engine.
291        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        // Storage and version are monotonic: no cloud supports a volume shrink or an in-place
299        // engine downgrade, so reject rather than let Update reach an impossible state. Parse
300        // explicitly and fail on a malformed value — a value we can't parse must NOT silently skip
301        // the guard, or a shrink/downgrade would slip through unchecked (fail-open).
302        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        // `None` already resolves to the platform default, so even making it explicit is rejected —
437        // backend is pinned once the resource exists.
438        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        // A no-op (unchanged backend) is allowed.
447        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}