1use super::BindingValue;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
14#[serde(tag = "service", rename_all = "lowercase")]
18pub enum PostgresBinding {
19 Aurora(AuroraPostgresBinding),
21 #[serde(rename = "aurora-managed")]
24 AuroraManaged(AuroraPostgresBinding),
25 #[serde(rename = "cloud-sql")]
27 CloudSql(CloudSqlPostgresBinding),
28 #[serde(rename = "flexible-server")]
30 FlexibleServer(FlexibleServerPostgresBinding),
31 External(ExternalPostgresBinding),
33 #[serde(rename = "local-postgres")]
35 Local(LocalPostgresBinding),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
41#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
42#[serde(rename_all = "camelCase")]
43pub struct AuroraPostgresBinding {
44 pub cluster_endpoint: BindingValue<String>,
45 pub port: BindingValue<u16>,
46 pub database: BindingValue<String>,
47 pub username: BindingValue<String>,
48 pub password_secret_arn: BindingValue<String>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "camelCase")]
57pub struct CloudSqlPostgresBinding {
58 pub host: BindingValue<String>,
59 pub port: BindingValue<u16>,
60 pub database: BindingValue<String>,
61 pub username: BindingValue<String>,
62 pub server_ca_certificates: BindingValue<Vec<String>>,
65 pub password_secret_name: BindingValue<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
72#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
73#[serde(rename_all = "camelCase")]
74pub struct FlexibleServerPostgresBinding {
75 pub host: BindingValue<String>,
76 pub port: BindingValue<u16>,
77 pub database: BindingValue<String>,
78 pub username: BindingValue<String>,
79 pub password_secret_uri: BindingValue<String>,
81}
82
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
90#[serde(rename_all = "kebab-case")]
91pub enum ExternalPostgresSslMode {
92 #[default]
94 VerifyFull,
95 Disable,
97}
98
99#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
103#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
104#[serde(rename_all = "camelCase")]
105pub struct ExternalPostgresBinding {
106 pub host: BindingValue<String>,
107 pub port: BindingValue<u16>,
108 pub database: BindingValue<String>,
109 pub username: BindingValue<String>,
110 #[serde(deserialize_with = "deserialize_external_password")]
113 #[cfg_attr(feature = "openapi", schema(value_type = ExternalPasswordInput, inline))]
114 #[cfg_attr(feature = "jsonschema", schemars(with = "ExternalPasswordInput"))]
115 pub password: BindingValue<String>,
116 #[serde(default)]
118 pub ssl_mode: ExternalPostgresSslMode,
119}
120
121#[derive(Deserialize)]
124#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
125#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
126#[serde(untagged)]
127enum ExternalPasswordInput {
128 Literal(String),
129 Reference(ExternalPasswordSecretRef),
130 ChartReference(ExternalPasswordSecretKeyRef),
131}
132
133#[derive(Deserialize)]
134#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
135#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137struct ExternalPasswordSecretRef {
138 secret_ref: super::SecretReference,
139}
140
141#[derive(Deserialize)]
144#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
145#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147struct ExternalPasswordSecretKeyRef {
148 secret_key_ref: super::SecretReference,
149}
150
151fn deserialize_external_password<'de, D>(deserializer: D) -> Result<BindingValue<String>, D::Error>
152where
153 D: serde::Deserializer<'de>,
154{
155 let value = serde_json::Value::deserialize(deserializer)?;
156 if !value.is_string()
157 && !["secretRef", "secretKeyRef"]
158 .iter()
159 .any(|key| value.get(key).is_some_and(serde_json::Value::is_object))
160 {
161 return Err(serde::de::Error::custom(
162 "expected a password string or a Secret reference with name and key",
163 ));
164 }
165 match serde_json::from_value::<ExternalPasswordInput>(value)
166 .map_err(serde::de::Error::custom)?
167 {
168 ExternalPasswordInput::Literal(value) => Ok(BindingValue::Value(value)),
169 ExternalPasswordInput::Reference(reference) => Ok(BindingValue::SecretRef {
170 secret_ref: reference.secret_ref,
171 }),
172 ExternalPasswordInput::ChartReference(reference) => {
173 Ok(BindingValue::Expression(serde_json::json!({
174 "secretKeyRef": {"name": reference.secret_key_ref.name, "key": reference.secret_key_ref.key}
175 })))
176 }
177 }
178}
179
180#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
184#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
185#[serde(rename_all = "camelCase")]
186pub struct LocalPostgresBinding {
187 pub host: BindingValue<String>,
188 pub port: BindingValue<u16>,
189 pub database: BindingValue<String>,
190 pub username: BindingValue<String>,
191 pub password: String,
192}
193
194impl std::fmt::Debug for ExternalPostgresBinding {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 f.debug_struct("ExternalPostgresBinding")
199 .field("host", &self.host)
200 .field("port", &self.port)
201 .field("database", &self.database)
202 .field("username", &self.username)
203 .field("password", &"<redacted>")
204 .field("ssl_mode", &self.ssl_mode)
205 .finish()
206 }
207}
208
209impl std::fmt::Debug for LocalPostgresBinding {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.debug_struct("LocalPostgresBinding")
212 .field("host", &self.host)
213 .field("port", &self.port)
214 .field("database", &self.database)
215 .field("username", &self.username)
216 .field("password", &"<redacted>")
217 .finish()
218 }
219}
220
221impl PostgresBinding {
222 pub fn local(
224 host: impl Into<BindingValue<String>>,
225 port: u16,
226 database: impl Into<BindingValue<String>>,
227 username: impl Into<BindingValue<String>>,
228 password: impl Into<String>,
229 ) -> Self {
230 Self::Local(LocalPostgresBinding {
231 host: host.into(),
232 port: BindingValue::value(port),
233 database: database.into(),
234 username: username.into(),
235 password: password.into(),
236 })
237 }
238
239 pub fn external(
241 host: impl Into<BindingValue<String>>,
242 port: u16,
243 database: impl Into<BindingValue<String>>,
244 username: impl Into<BindingValue<String>>,
245 password: impl Into<String>,
246 ) -> Self {
247 Self::External(ExternalPostgresBinding {
248 host: host.into(),
249 port: BindingValue::value(port),
250 database: database.into(),
251 username: username.into(),
252 password: BindingValue::value(password.into()),
253 ssl_mode: ExternalPostgresSslMode::default(),
254 })
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn external_password_rejects_non_string_literals() {
264 for password in [
265 serde_json::json!(42),
266 serde_json::json!(false),
267 serde_json::json!(null),
268 serde_json::json!([]),
269 serde_json::json!({}),
270 serde_json::json!([{"name":"credentials", "key":"password"}]),
271 serde_json::json!({"secretRef":["credentials", "password"]}),
272 serde_json::json!({"secretKeyRef":{"name":"credentials"}}),
273 serde_json::json!({"secretKeyRef":{"key":"password"}}),
274 serde_json::json!({"secretRef": {"name": "credentials"}}),
275 serde_json::json!({"secretRef": {"key": "password"}}),
276 serde_json::json!({"secretRef": {"name": 4, "key": "password"}}),
277 serde_json::json!({"envRef": "PASSWORD"}),
278 ] {
279 let binding = serde_json::json!({
280 "service": "external", "host": "db.internal", "port": 5432,
281 "database": "app", "username": "app", "password": password
282 });
283 assert!(serde_json::from_value::<PostgresBinding>(binding).is_err());
284 }
285 }
286
287 #[test]
288 fn local_binding_uses_local_postgres_tag() {
289 let binding = PostgresBinding::local("127.0.0.1", 5432, "db", "alien", "secret");
290 let json = serde_json::to_string(&binding).unwrap();
291 assert!(json.contains(r#""service":"local-postgres""#));
292 let deserialized: PostgresBinding = serde_json::from_str(&json).unwrap();
293 assert_eq!(binding, deserialized);
294 }
295
296 #[test]
297 fn external_binding_uses_external_tag() {
298 let binding = PostgresBinding::external("db.internal", 5432, "app", "alien", "secret");
299 let json = serde_json::to_string(&binding).expect("external binding serializes");
300 assert!(json.contains(r#""service":"external""#));
301 assert!(json.contains(r#""sslMode":"verify-full""#));
302 let deserialized: PostgresBinding =
303 serde_json::from_str(&json).expect("external binding deserializes");
304 assert_eq!(binding, deserialized);
305 }
306
307 #[test]
308 fn chart_password_reference_keeps_its_projection_mode() {
309 let input = serde_json::json!({"service":"external", "host":"database", "port":5432,
310 "database":"app", "username":"app", "password":{"secretKeyRef":{"name":"database", "key":"password"}}});
311 let binding: PostgresBinding = serde_json::from_value(input.clone()).unwrap();
312 assert_eq!(
313 serde_json::to_value(binding).unwrap()["password"],
314 input["password"]
315 );
316 }
317
318 #[test]
319 fn external_binding_preserves_kubernetes_password_reference() {
320 let json = serde_json::json!({
321 "service": "external",
322 "host": "database",
323 "port": 5432,
324 "database": "app",
325 "username": "app",
326 "password": {"secretRef": {"name": "database-credentials", "key": "password"}},
327 "sslMode": "disable"
328 });
329 let binding: PostgresBinding = serde_json::from_value(json.clone())
330 .expect("external binding accepts a Kubernetes password reference");
331 assert_eq!(serde_json::to_value(&binding).unwrap(), json);
332 assert!(!format!("{binding:?}").contains("database-credentials"));
333 }
334
335 #[test]
336 fn external_binding_without_ssl_mode_defaults_to_verified_tls() {
337 let json = r#"{
338 "service": "external",
339 "host": "db.internal",
340 "port": 5432,
341 "database": "app",
342 "username": "alien",
343 "password": "secret"
344 }"#;
345
346 let binding: PostgresBinding =
347 serde_json::from_str(json).expect("legacy external binding deserializes");
348
349 let PostgresBinding::External(binding) = binding else {
350 panic!("expected external Postgres binding");
351 };
352 assert_eq!(binding.ssl_mode, ExternalPostgresSslMode::VerifyFull);
353 }
354
355 #[test]
356 fn external_binding_accepts_explicit_plaintext_opt_out() {
357 let json = r#"{
358 "service": "external",
359 "host": "db.internal",
360 "port": 5432,
361 "database": "app",
362 "username": "alien",
363 "password": "secret",
364 "sslMode": "disable"
365 }"#;
366
367 let binding: PostgresBinding =
368 serde_json::from_str(json).expect("plaintext external binding deserializes");
369
370 let PostgresBinding::External(binding) = binding else {
371 panic!("expected external Postgres binding");
372 };
373 assert_eq!(binding.ssl_mode, ExternalPostgresSslMode::Disable);
374 }
375
376 #[test]
377 fn cloud_variants_keep_hyphenated_tags() {
378 let aurora = PostgresBinding::Aurora(AuroraPostgresBinding {
379 cluster_endpoint: "cluster.rds.amazonaws.com".into(),
380 port: BindingValue::value(5432),
381 database: "db".into(),
382 username: "alien".into(),
383 password_secret_arn: "arn:aws:secretsmanager:...".into(),
384 });
385 assert!(serde_json::to_string(&aurora)
386 .unwrap()
387 .contains(r#""service":"aurora""#));
388
389 let cloud_sql = PostgresBinding::CloudSql(CloudSqlPostgresBinding {
390 host: "10.0.0.5".into(),
391 port: BindingValue::value(5432),
392 database: "db".into(),
393 username: "alien".into(),
394 server_ca_certificates: BindingValue::value(vec!["cloud-sql-root".to_string()]),
395 password_secret_name: "pg-credentials".into(),
396 });
397 assert!(serde_json::to_string(&cloud_sql)
398 .unwrap()
399 .contains(r#""service":"cloud-sql""#));
400
401 let flexible = PostgresBinding::FlexibleServer(FlexibleServerPostgresBinding {
402 host: "10.0.0.6".into(),
403 port: BindingValue::value(5432),
404 database: "db".into(),
405 username: "alien".into(),
406 password_secret_uri: "https://vault.vault.azure.net/secrets/pg".into(),
407 });
408 assert!(serde_json::to_string(&flexible)
409 .unwrap()
410 .contains(r#""service":"flexible-server""#));
411 }
412}