Skip to main content

alien_core/bindings/
postgres.rs

1//! Postgres binding definitions across platforms.
2//!
3//! The binding carries only connection details. Cloud variants keep the password
4//! out of state by referencing the cloud secret store (ARN / name / URI), resolved
5//! at load time; Local and External carry the password inline as a `BindingValue`.
6
7use super::BindingValue;
8use serde::{Deserialize, Serialize};
9
10/// Connection details for a Postgres database, one variant per backend.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
14// `rename_all = "lowercase"` would drop the hyphen (CloudSql -> cloudsql); the explicit
15// renames keep the wire tags `cloud-sql`/`flexible-server`/`local-postgres`. Every tag is
16// globally unique across all binding enums (serde dispatches on `service` alone).
17#[serde(tag = "service", rename_all = "lowercase")]
18pub enum PostgresBinding {
19    /// AWS Aurora Serverless v2 (cluster endpoint + secret ARN).
20    Aurora(AuroraPostgresBinding),
21    /// GCP Cloud SQL (host + secret name).
22    #[serde(rename = "cloud-sql")]
23    CloudSql(CloudSqlPostgresBinding),
24    /// Azure Database for PostgreSQL — Flexible Server (host + secret URI).
25    #[serde(rename = "flexible-server")]
26    FlexibleServer(FlexibleServerPostgresBinding),
27    /// Operator-provided / BYO database (Kubernetes, on-prem, or cloud override).
28    External(ExternalPostgresBinding),
29    /// Local embedded Postgres process.
30    #[serde(rename = "local-postgres")]
31    Local(LocalPostgresBinding),
32}
33
34/// AWS Aurora Serverless v2 binding.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
38#[serde(rename_all = "camelCase")]
39pub struct AuroraPostgresBinding {
40    pub cluster_endpoint: BindingValue<String>,
41    pub port: BindingValue<u16>,
42    pub database: BindingValue<String>,
43    pub username: BindingValue<String>,
44    /// Secrets Manager ARN of the connection password; resolved at load time.
45    pub password_secret_arn: BindingValue<String>,
46}
47
48/// GCP Cloud SQL binding.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
52#[serde(rename_all = "camelCase")]
53pub struct CloudSqlPostgresBinding {
54    pub host: BindingValue<String>,
55    pub port: BindingValue<u16>,
56    pub database: BindingValue<String>,
57    pub username: BindingValue<String>,
58    /// Secret Manager secret name of the connection password; resolved at load time.
59    pub password_secret_name: BindingValue<String>,
60}
61
62/// Azure Flexible Server binding.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
65#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "camelCase")]
67pub struct FlexibleServerPostgresBinding {
68    pub host: BindingValue<String>,
69    pub port: BindingValue<u16>,
70    pub database: BindingValue<String>,
71    pub username: BindingValue<String>,
72    /// Key Vault secret URI of the connection password; resolved at load time.
73    pub password_secret_uri: BindingValue<String>,
74}
75
76/// Operator-provided / BYO database binding.
77// No derived `Debug` — inline `password` would print cleartext; see the redacting impl below.
78#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
80#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
81#[serde(rename_all = "camelCase")]
82pub struct ExternalPostgresBinding {
83    pub host: BindingValue<String>,
84    pub port: BindingValue<u16>,
85    pub database: BindingValue<String>,
86    pub username: BindingValue<String>,
87    /// Connection password as a concrete value, never an unresolved `SecretRef`: the platform
88    /// materializes the Kubernetes secret into the pod env. The cloud variants carry a secret
89    /// locator instead.
90    pub password: String,
91}
92
93/// Local embedded Postgres binding.
94// No derived `Debug` — inline `password` would print cleartext; see the redacting impl below.
95#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
97#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
98#[serde(rename_all = "camelCase")]
99pub struct LocalPostgresBinding {
100    pub host: BindingValue<String>,
101    pub port: BindingValue<u16>,
102    pub database: BindingValue<String>,
103    pub username: BindingValue<String>,
104    pub password: String,
105}
106
107// These impls redact the inline password and keep every other field, mirroring
108// `PostgresConnectionParams`. Cloud variants carry only a secret identifier, so they keep the derive.
109impl std::fmt::Debug for ExternalPostgresBinding {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("ExternalPostgresBinding")
112            .field("host", &self.host)
113            .field("port", &self.port)
114            .field("database", &self.database)
115            .field("username", &self.username)
116            .field("password", &"<redacted>")
117            .finish()
118    }
119}
120
121impl std::fmt::Debug for LocalPostgresBinding {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("LocalPostgresBinding")
124            .field("host", &self.host)
125            .field("port", &self.port)
126            .field("database", &self.database)
127            .field("username", &self.username)
128            .field("password", &"<redacted>")
129            .finish()
130    }
131}
132
133impl PostgresBinding {
134    /// Creates a Local Postgres binding.
135    pub fn local(
136        host: impl Into<BindingValue<String>>,
137        port: u16,
138        database: impl Into<BindingValue<String>>,
139        username: impl Into<BindingValue<String>>,
140        password: impl Into<String>,
141    ) -> Self {
142        Self::Local(LocalPostgresBinding {
143            host: host.into(),
144            port: BindingValue::value(port),
145            database: database.into(),
146            username: username.into(),
147            password: password.into(),
148        })
149    }
150
151    /// Creates an External (BYO / Kubernetes) Postgres binding.
152    pub fn external(
153        host: impl Into<BindingValue<String>>,
154        port: u16,
155        database: impl Into<BindingValue<String>>,
156        username: impl Into<BindingValue<String>>,
157        password: impl Into<String>,
158    ) -> Self {
159        Self::External(ExternalPostgresBinding {
160            host: host.into(),
161            port: BindingValue::value(port),
162            database: database.into(),
163            username: username.into(),
164            password: password.into(),
165        })
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn local_binding_uses_local_postgres_tag() {
175        let binding = PostgresBinding::local("127.0.0.1", 5432, "db", "alien", "secret");
176        let json = serde_json::to_string(&binding).unwrap();
177        assert!(json.contains(r#""service":"local-postgres""#));
178        let deserialized: PostgresBinding = serde_json::from_str(&json).unwrap();
179        assert_eq!(binding, deserialized);
180    }
181
182    #[test]
183    fn external_binding_uses_external_tag() {
184        let binding = PostgresBinding::external("db.internal", 5432, "app", "alien", "secret");
185        let json = serde_json::to_string(&binding).unwrap();
186        assert!(json.contains(r#""service":"external""#));
187        let deserialized: PostgresBinding = serde_json::from_str(&json).unwrap();
188        assert_eq!(binding, deserialized);
189    }
190
191    #[test]
192    fn cloud_variants_keep_hyphenated_tags() {
193        let aurora = PostgresBinding::Aurora(AuroraPostgresBinding {
194            cluster_endpoint: "cluster.rds.amazonaws.com".into(),
195            port: BindingValue::value(5432),
196            database: "db".into(),
197            username: "alien".into(),
198            password_secret_arn: "arn:aws:secretsmanager:...".into(),
199        });
200        assert!(serde_json::to_string(&aurora)
201            .unwrap()
202            .contains(r#""service":"aurora""#));
203
204        let cloud_sql = PostgresBinding::CloudSql(CloudSqlPostgresBinding {
205            host: "10.0.0.5".into(),
206            port: BindingValue::value(5432),
207            database: "db".into(),
208            username: "alien".into(),
209            password_secret_name: "pg-credentials".into(),
210        });
211        assert!(serde_json::to_string(&cloud_sql)
212            .unwrap()
213            .contains(r#""service":"cloud-sql""#));
214
215        let flexible = PostgresBinding::FlexibleServer(FlexibleServerPostgresBinding {
216            host: "10.0.0.6".into(),
217            port: BindingValue::value(5432),
218            database: "db".into(),
219            username: "alien".into(),
220            password_secret_uri: "https://vault.vault.azure.net/secrets/pg".into(),
221        });
222        assert!(serde_json::to_string(&flexible)
223            .unwrap()
224            .contains(r#""service":"flexible-server""#));
225    }
226}