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