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