storage_kit/state_stores/config/postgres.rs
1use std::{fmt, time::Duration};
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeStruct};
4
5use crate::{Password, StateStoreError};
6
7const DEFAULT_POSTGRES_PORT: u16 = 5432;
8const DEFAULT_OPERATION_TIMEOUT_MS: u64 = 5_000;
9
10/// TLS behavior for the initial PostgreSQL StateStore backend.
11///
12/// The first Postgres backend intentionally supports only `NoTls`.
13///
14/// # Examples
15///
16/// ```rust
17/// let tls = storage_kit::state::config::PostgresStateStoreTlsMode::Disable;
18/// if tls != storage_kit::state::config::PostgresStateStoreTlsMode::Disable {
19/// return Err("unexpected TLS mode".into());
20/// }
21/// # Ok::<(), Box<dyn std::error::Error>>(())
22/// ```
23pub enum PostgresStateStoreTlsMode {
24 /// Connect with `tokio_postgres::NoTls`.
25 Disable,
26}
27
28impl Clone for PostgresStateStoreTlsMode {
29 fn clone(&self) -> Self {
30 *self
31 }
32}
33
34impl Copy for PostgresStateStoreTlsMode {}
35
36impl fmt::Debug for PostgresStateStoreTlsMode {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 Self::Disable => f.write_str("Disable"),
40 }
41 }
42}
43
44impl PartialEq for PostgresStateStoreTlsMode {
45 fn eq(&self, other: &Self) -> bool {
46 matches!((self, other), (Self::Disable, Self::Disable))
47 }
48}
49
50impl Eq for PostgresStateStoreTlsMode {}
51
52impl Serialize for PostgresStateStoreTlsMode {
53 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
54 where
55 S: Serializer,
56 {
57 match self {
58 Self::Disable => serializer.serialize_str("Disable"),
59 }
60 }
61}
62
63impl<'de> Deserialize<'de> for PostgresStateStoreTlsMode {
64 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65 where
66 D: Deserializer<'de>,
67 {
68 match String::deserialize(deserializer)?.as_str() {
69 "Disable" => Ok(Self::Disable),
70 other => Err(de::Error::unknown_variant(other, &["Disable"])),
71 }
72 }
73}
74
75/// PostgreSQL StateStore configuration.
76///
77/// PostgreSQL StateStore support requires the `postgres` feature and is intended
78/// for remote durable control-plane state. This always-available config stays
79/// pure data until it is passed
80/// to [`crate::state::config::StateStoreConfig::postgres`] and opened through
81/// [`crate::StateStore::connect`]. Password bytes are only exposed at the
82/// final authentication boundary inside the private PostgreSQL backend.
83///
84/// # Examples
85///
86/// ```rust
87/// let password = storage_kit::state::config::Password::from_string("state-store-secret")?;
88/// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost(
89/// "storage_kit_state",
90/// "storage_kit",
91/// )
92/// .with_password(password)
93/// .with_port(15432)
94/// .with_operation_timeout(std::time::Duration::from_secs(2))?;
95/// let state_config = storage_kit::state::config::StateStoreConfig::postgres(config.clone());
96///
97/// assert_eq!(config.host(), "127.0.0.1");
98/// assert_eq!(config.port(), 15432);
99/// assert_eq!(config.tls_mode(), storage_kit::state::config::PostgresStateStoreTlsMode::Disable);
100/// assert!(!format!("{config:?}").contains("state-store-secret"));
101/// assert_eq!(state_config.validate(), Ok(()));
102/// # Ok::<(), Box<dyn std::error::Error>>(())
103/// ```
104pub struct PostgresStateStoreConfig {
105 host: String,
106 port: u16,
107 database: String,
108 username: String,
109 password: Option<Password>,
110 tls_mode: PostgresStateStoreTlsMode,
111 operation_timeout_ms: u64,
112}
113
114impl PostgresStateStoreConfig {
115 /// Builds a PostgreSQL StateStore config.
116 ///
117 /// # Examples
118 ///
119 /// ```rust
120 /// let config = storage_kit::state::config::PostgresStateStoreConfig::new(
121 /// "127.0.0.1",
122 /// "storage_kit_state",
123 /// "storage_kit",
124 /// );
125 /// if config.host() != "127.0.0.1" {
126 /// return Err("unexpected host".into());
127 /// }
128 /// # Ok::<(), Box<dyn std::error::Error>>(())
129 /// ```
130 #[must_use]
131 pub fn new(
132 host: impl Into<String>,
133 database: impl Into<String>,
134 username: impl Into<String>,
135 ) -> Self {
136 Self {
137 host: host.into(),
138 port: DEFAULT_POSTGRES_PORT,
139 database: database.into(),
140 username: username.into(),
141 password: None,
142 tls_mode: PostgresStateStoreTlsMode::Disable,
143 operation_timeout_ms: DEFAULT_OPERATION_TIMEOUT_MS,
144 }
145 }
146
147 /// Builds a PostgreSQL StateStore config for `127.0.0.1`.
148 ///
149 /// # Examples
150 ///
151 /// ```rust
152 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost(
153 /// "storage_kit_state",
154 /// "storage_kit",
155 /// );
156 /// if config.host() != "127.0.0.1" {
157 /// return Err("unexpected host".into());
158 /// }
159 /// # Ok::<(), Box<dyn std::error::Error>>(())
160 /// ```
161 #[must_use]
162 pub fn localhost(database: impl Into<String>, username: impl Into<String>) -> Self {
163 Self::new("127.0.0.1", database, username)
164 }
165
166 /// Replaces the PostgreSQL server port.
167 ///
168 /// # Examples
169 ///
170 /// ```rust
171 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user")
172 /// .with_port(15432);
173 /// if config.port() != 15432 {
174 /// return Err("unexpected port".into());
175 /// }
176 /// # Ok::<(), Box<dyn std::error::Error>>(())
177 /// ```
178 #[must_use]
179 pub fn with_port(mut self, port: u16) -> Self {
180 self.port = port;
181 self
182 }
183
184 /// Sets the password used by PostgreSQL authentication.
185 ///
186 /// # Examples
187 ///
188 /// ```rust
189 /// let password = storage_kit::state::config::Password::from_string("secret")?;
190 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user")
191 /// .with_password(password);
192 /// if config.password().is_none() {
193 /// return Err("expected password".into());
194 /// }
195 /// # Ok::<(), Box<dyn std::error::Error>>(())
196 /// ```
197 #[must_use]
198 pub fn with_password(mut self, password: Password) -> Self {
199 self.password = Some(password);
200 self
201 }
202
203 /// Clears any configured password.
204 ///
205 /// # Examples
206 ///
207 /// ```rust
208 /// let password = storage_kit::state::config::Password::from_string("secret")?;
209 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user")
210 /// .with_password(password)
211 /// .without_password();
212 /// if config.password().is_some() {
213 /// return Err("unexpected password".into());
214 /// }
215 /// # Ok::<(), Box<dyn std::error::Error>>(())
216 /// ```
217 #[must_use]
218 pub fn without_password(mut self) -> Self {
219 self.password = None;
220 self
221 }
222
223 /// Replaces the per-operation timeout.
224 ///
225 /// # Errors
226 ///
227 /// Returns an invalid-config error when the timeout cannot be represented
228 /// as milliseconds.
229 ///
230 /// # Examples
231 ///
232 /// ```rust
233 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user")
234 /// .with_operation_timeout(std::time::Duration::from_millis(250))?;
235 /// if config.operation_timeout().as_millis() != 250 {
236 /// return Err("unexpected timeout".into());
237 /// }
238 /// # Ok::<(), Box<dyn std::error::Error>>(())
239 /// ```
240 pub fn with_operation_timeout(mut self, timeout: Duration) -> Result<Self, StateStoreError> {
241 self.operation_timeout_ms = u64::try_from(timeout.as_millis()).map_err(|err| {
242 StateStoreError::invalid_config({
243 let mut message =
244 String::from("PostgreSQL state store operation_timeout overflow: ");
245 message.push_str(&err.to_string());
246 message
247 })
248 })?;
249 Ok(self)
250 }
251
252 /// Returns the configured host.
253 ///
254 /// # Examples
255 ///
256 /// ```rust
257 /// let config = storage_kit::state::config::PostgresStateStoreConfig::new(
258 /// "db.internal",
259 /// "storage_kit_state",
260 /// "storage_kit",
261 /// );
262 /// assert_eq!(config.host(), "db.internal");
263 /// ```
264 #[must_use]
265 pub fn host(&self) -> &str {
266 &self.host
267 }
268
269 /// Returns the configured port.
270 ///
271 /// # Examples
272 ///
273 /// ```rust
274 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user")
275 /// .with_port(15432);
276 /// assert_eq!(config.port(), 15432);
277 /// ```
278 #[must_use]
279 pub fn port(&self) -> u16 {
280 self.port
281 }
282
283 /// Returns the configured database name.
284 ///
285 /// # Examples
286 ///
287 /// ```rust
288 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost(
289 /// "storage_kit_state",
290 /// "storage_kit",
291 /// );
292 /// assert_eq!(config.database(), "storage_kit_state");
293 /// ```
294 #[must_use]
295 pub fn database(&self) -> &str {
296 &self.database
297 }
298
299 /// Returns the configured username.
300 ///
301 /// # Examples
302 ///
303 /// ```rust
304 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost(
305 /// "storage_kit_state",
306 /// "storage_kit",
307 /// );
308 /// assert_eq!(config.username(), "storage_kit");
309 /// ```
310 #[must_use]
311 pub fn username(&self) -> &str {
312 &self.username
313 }
314
315 /// Returns the configured password, if present.
316 ///
317 /// # Examples
318 ///
319 /// ```rust
320 /// let password = storage_kit::state::config::Password::from_string("secret")?;
321 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user")
322 /// .with_password(password);
323 /// assert!(config.password().is_some());
324 /// # Ok::<(), storage_kit::StorageError>(())
325 /// ```
326 #[must_use]
327 pub fn password(&self) -> Option<&Password> {
328 self.password.as_ref()
329 }
330
331 /// Returns the configured TLS mode.
332 ///
333 /// # Examples
334 ///
335 /// ```rust
336 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user");
337 /// assert_eq!(
338 /// config.tls_mode(),
339 /// storage_kit::state::config::PostgresStateStoreTlsMode::Disable
340 /// );
341 /// ```
342 #[must_use]
343 pub fn tls_mode(&self) -> PostgresStateStoreTlsMode {
344 self.tls_mode
345 }
346
347 /// Returns the configured operation timeout.
348 ///
349 /// # Examples
350 ///
351 /// ```rust
352 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user")
353 /// .with_operation_timeout(std::time::Duration::from_millis(750))?;
354 /// assert_eq!(config.operation_timeout().as_millis(), 750);
355 /// # Ok::<(), storage_kit::StateStoreError>(())
356 /// ```
357 #[must_use]
358 pub fn operation_timeout(&self) -> Duration {
359 Duration::from_millis(self.operation_timeout_ms)
360 }
361
362 #[cfg(feature = "postgres")]
363 pub(crate) fn operation_timeout_ms(&self) -> u64 {
364 self.operation_timeout_ms
365 }
366
367 /// Validates PostgreSQL StateStore configuration.
368 ///
369 /// # Errors
370 ///
371 /// Returns an invalid-config error when text fields, port, or operation
372 /// timeout are unusable.
373 ///
374 /// # Examples
375 ///
376 /// ```rust
377 /// let config = storage_kit::state::config::PostgresStateStoreConfig::localhost("db", "user");
378 /// config.validate()?;
379 /// let invalid = storage_kit::state::config::PostgresStateStoreConfig::new("", "db", "user");
380 /// assert!(matches!(
381 /// invalid.validate(),
382 /// Err(storage_kit::StateStoreError::InvalidConfig(_))
383 /// ));
384 /// # Ok::<(), storage_kit::StateStoreError>(())
385 /// ```
386 pub fn validate(&self) -> Result<(), StateStoreError> {
387 PostgresConfigText::new("PostgreSQL state store host", &self.host).validate()?;
388 PostgresConfigText::new("PostgreSQL state store database", &self.database).validate()?;
389 PostgresConfigText::new("PostgreSQL state store username", &self.username).validate()?;
390 if self.port == 0 {
391 return Err(StateStoreError::invalid_config(
392 "PostgreSQL state store port must be greater than zero",
393 ));
394 }
395 if self.operation_timeout_ms == 0 {
396 return Err(StateStoreError::invalid_config(
397 "PostgreSQL state store operation_timeout must be greater than zero",
398 ));
399 }
400 Ok(())
401 }
402
403 pub(crate) fn from_json_value(value: &serde_json::Value) -> Result<Self, String> {
404 let object = value
405 .as_object()
406 .ok_or_else(|| "PostgresStateStoreConfig must be an object".to_owned())?;
407 let host = required_string(object, "host")?;
408 let port = required_u64(object, "port")
409 .and_then(|value| u16::try_from(value).map_err(|err| err.to_string()))?;
410 let database = required_string(object, "database")?;
411 let username = required_string(object, "username")?;
412 let password = match object.get("password") {
413 Some(value) if !value.is_null() => {
414 Some(Password::deserialize(value).map_err(|err| err.to_string())?)
415 }
416 _ => None,
417 };
418 let tls_mode = match object.get("tls_mode") {
419 Some(value) => {
420 PostgresStateStoreTlsMode::deserialize(value).map_err(|err| err.to_string())?
421 }
422 None => PostgresStateStoreTlsMode::Disable,
423 };
424 let operation_timeout_ms = required_u64(object, "operation_timeout_ms")?;
425 Ok(Self {
426 host,
427 port,
428 database,
429 username,
430 password,
431 tls_mode,
432 operation_timeout_ms,
433 })
434 }
435}
436
437impl Clone for PostgresStateStoreConfig {
438 fn clone(&self) -> Self {
439 Self {
440 host: self.host.clone(),
441 port: self.port,
442 database: self.database.clone(),
443 username: self.username.clone(),
444 password: self.password.clone(),
445 tls_mode: self.tls_mode,
446 operation_timeout_ms: self.operation_timeout_ms,
447 }
448 }
449}
450
451impl fmt::Debug for PostgresStateStoreConfig {
452 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
453 f.debug_struct("PostgresStateStoreConfig")
454 .field("host", &self.host)
455 .field("port", &self.port)
456 .field("database", &self.database)
457 .field("username", &self.username)
458 .field("password", &self.password.as_ref().map(|_| "[redacted]"))
459 .field("tls_mode", &self.tls_mode)
460 .field("operation_timeout_ms", &self.operation_timeout_ms)
461 .finish()
462 }
463}
464
465impl Serialize for PostgresStateStoreConfig {
466 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
467 where
468 S: Serializer,
469 {
470 let mut state = serializer.serialize_struct("PostgresStateStoreConfig", 7)?;
471 state.serialize_field("host", &self.host)?;
472 state.serialize_field("port", &self.port)?;
473 state.serialize_field("database", &self.database)?;
474 state.serialize_field("username", &self.username)?;
475 state.serialize_field("password", &self.password)?;
476 state.serialize_field("tls_mode", &self.tls_mode)?;
477 state.serialize_field("operation_timeout_ms", &self.operation_timeout_ms)?;
478 state.end()
479 }
480}
481
482impl<'de> Deserialize<'de> for PostgresStateStoreConfig {
483 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
484 where
485 D: Deserializer<'de>,
486 {
487 let value = serde_json::Value::deserialize(deserializer)?;
488 Self::from_json_value(&value).map_err(de::Error::custom)
489 }
490}
491
492struct PostgresConfigText<'a> {
493 label: &'static str,
494 value: &'a str,
495}
496
497impl<'a> PostgresConfigText<'a> {
498 fn new(label: &'static str, value: &'a str) -> Self {
499 Self { label, value }
500 }
501
502 fn validate(&self) -> Result<(), StateStoreError> {
503 if self.value.is_empty() {
504 return Err(StateStoreError::invalid_config(format!(
505 "{} must not be empty",
506 self.label
507 )));
508 }
509 if self.value.trim() != self.value {
510 return Err(StateStoreError::invalid_config(format!(
511 "{} must not contain leading or trailing whitespace",
512 self.label
513 )));
514 }
515 if self
516 .value
517 .chars()
518 .any(|character| character == '\0' || character.is_control())
519 {
520 return Err(StateStoreError::invalid_config(format!(
521 "{} must not contain NUL or control characters",
522 self.label
523 )));
524 }
525 Ok(())
526 }
527}
528
529fn required_string(
530 object: &serde_json::Map<String, serde_json::Value>,
531 field: &'static str,
532) -> Result<String, String> {
533 object
534 .get(field)
535 .ok_or_else(|| required_message(field))
536 .and_then(|value| {
537 value
538 .as_str()
539 .map(ToOwned::to_owned)
540 .ok_or_else(|| type_message(field, "a string"))
541 })
542}
543
544fn required_u64(
545 object: &serde_json::Map<String, serde_json::Value>,
546 field: &'static str,
547) -> Result<u64, String> {
548 object
549 .get(field)
550 .ok_or_else(|| required_message(field))
551 .and_then(|value| value.as_u64().ok_or_else(|| type_message(field, "a u64")))
552}
553
554fn required_message(field: &str) -> String {
555 let mut message = String::from("PostgresStateStoreConfig.");
556 message.push_str(field);
557 message.push_str(" is required");
558 message
559}
560
561fn type_message(field: &str, expected: &str) -> String {
562 let mut message = String::from("PostgresStateStoreConfig.");
563 message.push_str(field);
564 message.push_str(" must be ");
565 message.push_str(expected);
566 message
567}