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 replace_after_failed_create_is_safe(&self) -> bool {
129        // Deleting it destroys the database contents.
130        false
131    }
132
133    fn get_dependencies(&self) -> Vec<ResourceRef> {
134        // The network dependency is added by infrastructure preflights on cloud platforms and
135        // is irrelevant on Local; cloud controllers reach the network via
136        // require_dependency() at runtime, so nothing is declared statically here.
137        Vec::new()
138    }
139
140    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
141        let new_pg = new_config
142            .as_any()
143            .downcast_ref::<Postgres>()
144            .ok_or_else(|| {
145                AlienError::new(ErrorData::UnexpectedResourceType {
146                    resource_id: self.id.clone(),
147                    expected: Self::RESOURCE_TYPE,
148                    actual: new_config.get_resource_type(),
149                })
150            })?;
151
152        if self.id != new_pg.id {
153            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
154                resource_id: self.id.clone(),
155                reason: "the 'id' field is immutable".to_string(),
156            }));
157        }
158
159        // `backend` selects the physical engine, so it is immutable once the resource exists. Reject
160        // ANY change including `None -> Some(...)`: `None` already resolves to the platform default, and
161        // once a second variant exists a `None -> Some(other)` edit would silently switch the live engine.
162        if self.backend != new_pg.backend {
163            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
164                resource_id: self.id.clone(),
165                reason: "the 'backend' field is immutable once the resource exists".to_string(),
166            }));
167        }
168
169        // Storage and version are monotonic: no cloud supports a volume shrink or an in-place
170        // engine downgrade, so reject rather than let Update reach an impossible state. Parse
171        // explicitly and fail on a malformed value — a value we can't parse must NOT silently skip
172        // the guard, or a shrink/downgrade would slip through unchecked (fail-open).
173        let old_storage =
174            crate::instance_catalog::parse_memory_bytes(&self.storage).map_err(|e| {
175                AlienError::new(ErrorData::InvalidResourceUpdate {
176                    resource_id: self.id.clone(),
177                    reason: format!(
178                        "current storage '{}' is not a valid size: {e}",
179                        self.storage
180                    ),
181                })
182            })?;
183        let new_storage =
184            crate::instance_catalog::parse_memory_bytes(&new_pg.storage).map_err(|e| {
185                AlienError::new(ErrorData::InvalidResourceUpdate {
186                    resource_id: self.id.clone(),
187                    reason: format!(
188                        "requested storage '{}' is not a valid size: {e}",
189                        new_pg.storage
190                    ),
191                })
192            })?;
193        if new_storage < old_storage {
194            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
195                resource_id: self.id.clone(),
196                reason: format!(
197                    "storage cannot shrink (from '{}' to '{}')",
198                    self.storage, new_pg.storage
199                ),
200            }));
201        }
202
203        let old_version = self.version.parse::<u32>().map_err(|_| {
204            AlienError::new(ErrorData::InvalidResourceUpdate {
205                resource_id: self.id.clone(),
206                reason: format!(
207                    "current engine version '{}' is not a numeric major",
208                    self.version
209                ),
210            })
211        })?;
212        let new_version = new_pg.version.parse::<u32>().map_err(|_| {
213            AlienError::new(ErrorData::InvalidResourceUpdate {
214                resource_id: self.id.clone(),
215                reason: format!(
216                    "requested engine version '{}' is not a numeric major",
217                    new_pg.version
218                ),
219            })
220        })?;
221        if new_version < old_version {
222            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
223                resource_id: self.id.clone(),
224                reason: format!(
225                    "engine version cannot downgrade (from '{}' to '{}')",
226                    self.version, new_pg.version
227                ),
228            }));
229        }
230
231        Ok(())
232    }
233
234    fn as_any(&self) -> &dyn Any {
235        self
236    }
237
238    fn as_any_mut(&mut self) -> &mut dyn Any {
239        self
240    }
241
242    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
243        Box::new(self.clone())
244    }
245
246    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
247        other.as_any().downcast_ref::<Postgres>() == Some(self)
248    }
249
250    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
251        serde_json::to_value(self)
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn builder_applies_defaults() {
261        let pg = Postgres::new("db".to_string()).build();
262        assert_eq!(pg.id, "db");
263        assert_eq!(pg.version, "17");
264        assert_eq!(pg.storage, "20Gi");
265        assert!(!pg.high_availability);
266        assert_eq!(pg.cpu, None);
267        assert_eq!(pg.backend, None);
268    }
269
270    #[test]
271    fn resource_type_is_postgres() {
272        assert_eq!(Postgres::RESOURCE_TYPE.as_ref(), "postgres");
273    }
274
275    #[test]
276    fn validate_update_rejects_id_change() {
277        let original = Postgres::new("db".to_string()).build();
278        let renamed = Postgres::new("other".to_string()).build();
279        let err = original
280            .validate_update(&renamed)
281            .expect_err("changing the id must be rejected");
282        assert!(err.to_string().contains("'id' field is immutable"));
283    }
284
285    #[test]
286    fn validate_update_rejects_backend_change_including_none_to_some() {
287        // `None` already resolves to the platform default, so even making it explicit is rejected —
288        // backend is pinned once the resource exists.
289        let default_backend = Postgres::new("db".to_string()).build();
290        let explicit = Postgres::new("db".to_string())
291            .backend(PostgresBackend::AuroraServerlessV2)
292            .build();
293        let err = default_backend
294            .validate_update(&explicit)
295            .expect_err("None -> Some(backend) must be rejected");
296        assert!(err.to_string().contains("'backend' field is immutable"));
297        // A no-op (unchanged backend) is allowed.
298        assert!(explicit.validate_update(&explicit).is_ok());
299    }
300
301    #[test]
302    fn validate_update_rejects_storage_shrink_and_allows_growth() {
303        let original = Postgres::new("db".to_string())
304            .storage("100Gi".to_string())
305            .build();
306        let shrunk = Postgres::new("db".to_string())
307            .storage("20Gi".to_string())
308            .build();
309        let grown = Postgres::new("db".to_string())
310            .storage("200Gi".to_string())
311            .build();
312
313        let err = original
314            .validate_update(&shrunk)
315            .expect_err("shrinking storage must be rejected");
316        assert!(err.to_string().contains("storage cannot shrink"));
317        assert!(original.validate_update(&grown).is_ok());
318    }
319
320    #[test]
321    fn validate_update_rejects_version_downgrade_and_allows_upgrade() {
322        let original = Postgres::new("db".to_string())
323            .version("16".to_string())
324            .build();
325        let downgrade = Postgres::new("db".to_string())
326            .version("15".to_string())
327            .build();
328        let upgrade = Postgres::new("db".to_string())
329            .version("17".to_string())
330            .build();
331
332        let err = original
333            .validate_update(&downgrade)
334            .expect_err("version downgrade must be rejected");
335        assert!(err.to_string().contains("engine version cannot downgrade"));
336        assert!(original.validate_update(&upgrade).is_ok());
337    }
338
339    #[test]
340    fn serializes_with_camel_case_and_backend() {
341        let pg = Postgres::new("db".to_string())
342            .backend(PostgresBackend::AuroraServerlessV2)
343            .high_availability(true)
344            .build();
345        let json = serde_json::to_value(&pg).unwrap();
346        assert_eq!(json["highAvailability"], true);
347        assert_eq!(json["backend"]["type"], "auroraServerlessV2");
348
349        let roundtrip: Postgres = serde_json::from_value(json).unwrap();
350        assert_eq!(pg, roundtrip);
351    }
352
353    #[test]
354    fn outputs_roundtrip() {
355        let outputs = PostgresOutputs {
356            endpoint: "127.0.0.1".to_string(),
357            database: "db".to_string(),
358            port: Some(5432),
359        };
360        let json = serde_json::to_string(&outputs).unwrap();
361        let deserialized: PostgresOutputs = serde_json::from_str(&json).unwrap();
362        assert_eq!(outputs, deserialized);
363    }
364}