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    /// Aurora parameter groups are tied to the engine's major version, even when a
65    /// specific minor release is requested.
66    pub fn aurora_parameter_group_family(&self) -> Result<String> {
67        let major = self
68            .version
69            .split('.')
70            .next()
71            .filter(|major| !major.is_empty() && major.bytes().all(|byte| byte.is_ascii_digit()))
72            .ok_or_else(|| {
73                AlienError::new(ErrorData::OperationNotSupported {
74                    operation: "Aurora parameter group family".to_string(),
75                    reason: format!("invalid Postgres engine version '{}'", self.version),
76                })
77            })?;
78        Ok(format!("aurora-postgresql{major}"))
79    }
80
81    /// Returns the database's unique identifier.
82    pub fn id(&self) -> &str {
83        &self.id
84    }
85}
86
87fn default_version() -> String {
88    "17".to_string()
89}
90
91fn default_storage() -> String {
92    "20Gi".to_string()
93}
94
95/// The fixed inner database name for **cloud** Postgres backends.
96/// AWS rejects hyphens in `DatabaseName`, and the cluster/instance/server name already
97/// carries the resource id, so the inner database is a fixed label rather than the id.
98/// Every cloud emitter and controller derives its database name from this single constant
99/// so a frozen import always binds to a database that exists. (Local names its database
100/// after the resource id and does not use this.)
101pub const POSTGRES_DATABASE_NAME: &str = "app";
102
103/// The administrator created for managed cloud Postgres databases.
104pub const POSTGRES_ADMIN_USERNAME: &str = "app_admin";
105
106/// Outputs generated by a successfully provisioned Postgres database.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
109#[serde(rename_all = "camelCase")]
110pub struct PostgresOutputs {
111    /// Host (or host:port) the database listens on; never publicly reachable.
112    pub endpoint: String,
113    /// The default database created for this resource. On cloud backends this is the fixed
114    /// label `POSTGRES_DATABASE_NAME` (`app`); on Local it is the resource id.
115    pub database: String,
116    /// Listening port, when known.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub port: Option<u16>,
119}
120
121impl ResourceOutputsDefinition for PostgresOutputs {
122    fn get_resource_type(&self) -> ResourceType {
123        Postgres::RESOURCE_TYPE.clone()
124    }
125
126    fn as_any(&self) -> &dyn Any {
127        self
128    }
129
130    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
131        Box::new(self.clone())
132    }
133
134    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
135        other.as_any().downcast_ref::<PostgresOutputs>() == Some(self)
136    }
137
138    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
139        serde_json::to_value(self)
140    }
141}
142
143impl ResourceDefinition for Postgres {
144    fn get_resource_type(&self) -> ResourceType {
145        Self::RESOURCE_TYPE
146    }
147
148    fn id(&self) -> &str {
149        &self.id
150    }
151
152    fn replace_after_failed_create_is_safe(&self) -> bool {
153        // Deleting it destroys the database contents.
154        false
155    }
156
157    fn get_dependencies(&self) -> Vec<ResourceRef> {
158        // The network dependency is added by infrastructure preflights on cloud platforms and
159        // is irrelevant on Local; cloud controllers reach the network via
160        // require_dependency() at runtime, so nothing is declared statically here.
161        self.encryption_key.iter().cloned().collect()
162    }
163
164    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
165        let new_pg = new_config
166            .as_any()
167            .downcast_ref::<Postgres>()
168            .ok_or_else(|| {
169                AlienError::new(ErrorData::UnexpectedResourceType {
170                    resource_id: self.id.clone(),
171                    expected: Self::RESOURCE_TYPE,
172                    actual: new_config.get_resource_type(),
173                })
174            })?;
175
176        if self.id != new_pg.id {
177            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
178                resource_id: self.id.clone(),
179                reason: "the 'id' field is immutable".to_string(),
180            }));
181        }
182
183        if self.encryption_key != new_pg.encryption_key {
184            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
185                resource_id: self.id.clone(),
186                reason: "the 'encryptionKey' field is immutable once the resource exists"
187                    .to_string(),
188            }));
189        }
190
191        // `backend` selects the physical engine, so it is immutable once the resource exists. Reject
192        // ANY change including `None -> Some(...)`: `None` already resolves to the platform default, and
193        // once a second variant exists a `None -> Some(other)` edit would silently switch the live engine.
194        if self.backend != new_pg.backend {
195            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
196                resource_id: self.id.clone(),
197                reason: "the 'backend' field is immutable once the resource exists".to_string(),
198            }));
199        }
200
201        // Storage and version are monotonic: no cloud supports a volume shrink or an in-place
202        // engine downgrade, so reject rather than let Update reach an impossible state. Parse
203        // explicitly and fail on a malformed value — a value we can't parse must NOT silently skip
204        // the guard, or a shrink/downgrade would slip through unchecked (fail-open).
205        let old_storage =
206            crate::instance_catalog::parse_memory_bytes(&self.storage).map_err(|e| {
207                AlienError::new(ErrorData::InvalidResourceUpdate {
208                    resource_id: self.id.clone(),
209                    reason: format!(
210                        "current storage '{}' is not a valid size: {e}",
211                        self.storage
212                    ),
213                })
214            })?;
215        let new_storage =
216            crate::instance_catalog::parse_memory_bytes(&new_pg.storage).map_err(|e| {
217                AlienError::new(ErrorData::InvalidResourceUpdate {
218                    resource_id: self.id.clone(),
219                    reason: format!(
220                        "requested storage '{}' is not a valid size: {e}",
221                        new_pg.storage
222                    ),
223                })
224            })?;
225        if new_storage < old_storage {
226            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
227                resource_id: self.id.clone(),
228                reason: format!(
229                    "storage cannot shrink (from '{}' to '{}')",
230                    self.storage, new_pg.storage
231                ),
232            }));
233        }
234
235        let old_version = self.version.parse::<u32>().map_err(|_| {
236            AlienError::new(ErrorData::InvalidResourceUpdate {
237                resource_id: self.id.clone(),
238                reason: format!(
239                    "current engine version '{}' is not a numeric major",
240                    self.version
241                ),
242            })
243        })?;
244        let new_version = new_pg.version.parse::<u32>().map_err(|_| {
245            AlienError::new(ErrorData::InvalidResourceUpdate {
246                resource_id: self.id.clone(),
247                reason: format!(
248                    "requested engine version '{}' is not a numeric major",
249                    new_pg.version
250                ),
251            })
252        })?;
253        if new_version < old_version {
254            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
255                resource_id: self.id.clone(),
256                reason: format!(
257                    "engine version cannot downgrade (from '{}' to '{}')",
258                    self.version, new_pg.version
259                ),
260            }));
261        }
262
263        Ok(())
264    }
265
266    fn as_any(&self) -> &dyn Any {
267        self
268    }
269
270    fn as_any_mut(&mut self) -> &mut dyn Any {
271        self
272    }
273
274    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
275        Box::new(self.clone())
276    }
277
278    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
279        other.as_any().downcast_ref::<Postgres>() == Some(self)
280    }
281
282    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
283        serde_json::to_value(self)
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn aurora_family_uses_the_major_of_pinned_minor_versions() {
293        for (version, family) in [
294            ("15", "aurora-postgresql15"),
295            ("16.8", "aurora-postgresql16"),
296            ("17.7", "aurora-postgresql17"),
297        ] {
298            let pg = Postgres::new("db".to_string())
299                .version(version.to_string())
300                .build();
301            assert_eq!(pg.aurora_parameter_group_family().unwrap(), family);
302        }
303        for version in ["", ".17", "latest", "17x"] {
304            let pg = Postgres::new("db".to_string())
305                .version(version.to_string())
306                .build();
307            assert!(pg.aurora_parameter_group_family().is_err());
308        }
309    }
310
311    #[test]
312    fn builder_applies_defaults() {
313        let pg = Postgres::new("db".to_string()).build();
314        assert_eq!(pg.id, "db");
315        assert_eq!(pg.version, "17");
316        assert_eq!(pg.storage, "20Gi");
317        assert!(!pg.high_availability);
318        assert_eq!(pg.cpu, None);
319        assert_eq!(pg.backend, None);
320    }
321
322    #[test]
323    fn resource_type_is_postgres() {
324        assert_eq!(Postgres::RESOURCE_TYPE.as_ref(), "postgres");
325    }
326
327    #[test]
328    fn validate_update_rejects_id_change() {
329        let original = Postgres::new("db".to_string()).build();
330        let renamed = Postgres::new("other".to_string()).build();
331        let err = original
332            .validate_update(&renamed)
333            .expect_err("changing the id must be rejected");
334        assert!(err.to_string().contains("'id' field is immutable"));
335    }
336
337    #[test]
338    fn validate_update_rejects_backend_change_including_none_to_some() {
339        // `None` already resolves to the platform default, so even making it explicit is rejected —
340        // backend is pinned once the resource exists.
341        let default_backend = Postgres::new("db".to_string()).build();
342        let explicit = Postgres::new("db".to_string())
343            .backend(PostgresBackend::AuroraServerlessV2)
344            .build();
345        let err = default_backend
346            .validate_update(&explicit)
347            .expect_err("None -> Some(backend) must be rejected");
348        assert!(err.to_string().contains("'backend' field is immutable"));
349        // A no-op (unchanged backend) is allowed.
350        assert!(explicit.validate_update(&explicit).is_ok());
351    }
352
353    #[test]
354    fn validate_update_rejects_storage_shrink_and_allows_growth() {
355        let original = Postgres::new("db".to_string())
356            .storage("100Gi".to_string())
357            .build();
358        let shrunk = Postgres::new("db".to_string())
359            .storage("20Gi".to_string())
360            .build();
361        let grown = Postgres::new("db".to_string())
362            .storage("200Gi".to_string())
363            .build();
364
365        let err = original
366            .validate_update(&shrunk)
367            .expect_err("shrinking storage must be rejected");
368        assert!(err.to_string().contains("storage cannot shrink"));
369        assert!(original.validate_update(&grown).is_ok());
370    }
371
372    #[test]
373    fn validate_update_rejects_version_downgrade_and_allows_upgrade() {
374        let original = Postgres::new("db".to_string())
375            .version("16".to_string())
376            .build();
377        let downgrade = Postgres::new("db".to_string())
378            .version("15".to_string())
379            .build();
380        let upgrade = Postgres::new("db".to_string())
381            .version("17".to_string())
382            .build();
383
384        let err = original
385            .validate_update(&downgrade)
386            .expect_err("version downgrade must be rejected");
387        assert!(err.to_string().contains("engine version cannot downgrade"));
388        assert!(original.validate_update(&upgrade).is_ok());
389    }
390
391    #[test]
392    fn serializes_with_camel_case_and_backend() {
393        let pg = Postgres::new("db".to_string())
394            .backend(PostgresBackend::AuroraServerlessV2)
395            .high_availability(true)
396            .build();
397        let json = serde_json::to_value(&pg).unwrap();
398        assert_eq!(json["highAvailability"], true);
399        assert_eq!(json["backend"]["type"], "auroraServerlessV2");
400
401        let roundtrip: Postgres = serde_json::from_value(json).unwrap();
402        assert_eq!(pg, roundtrip);
403    }
404
405    #[test]
406    fn outputs_roundtrip() {
407        let outputs = PostgresOutputs {
408            endpoint: "127.0.0.1".to_string(),
409            database: "db".to_string(),
410            port: Some(5432),
411        };
412        let json = serde_json::to_string(&outputs).unwrap();
413        let deserialized: PostgresOutputs = serde_json::from_str(&json).unwrap();
414        assert_eq!(outputs, deserialized);
415    }
416}