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/// Outputs generated by a successfully provisioned Postgres database.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
195#[serde(rename_all = "camelCase")]
196pub struct PostgresOutputs {
197    /// Host (or host:port) the database listens on; never publicly reachable.
198    pub endpoint: String,
199    /// The default database created for this resource. On cloud backends this is the fixed
200    /// label `POSTGRES_DATABASE_NAME` (`app`); on Local it is the resource id.
201    pub database: String,
202    /// Listening port, when known.
203    #[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        // Deleting it destroys the database contents.
240        false
241    }
242
243    fn get_dependencies(&self) -> Vec<ResourceRef> {
244        // The network dependency is added by infrastructure preflights on cloud platforms and
245        // is irrelevant on Local; cloud controllers reach the network via
246        // require_dependency() at runtime, so nothing is declared statically here.
247        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        // `backend` selects the physical engine, so it is immutable once the resource exists. Reject
278        // ANY change including `None -> Some(...)`: `None` already resolves to the platform default, and
279        // once a second variant exists a `None -> Some(other)` edit would silently switch the live engine.
280        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        // Storage and version are monotonic: no cloud supports a volume shrink or an in-place
288        // engine downgrade, so reject rather than let Update reach an impossible state. Parse
289        // explicitly and fail on a malformed value — a value we can't parse must NOT silently skip
290        // the guard, or a shrink/downgrade would slip through unchecked (fail-open).
291        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        // `None` already resolves to the platform default, so even making it explicit is rejected —
426        // backend is pinned once the resource exists.
427        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        // A no-op (unchanged backend) is allowed.
436        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}