Skip to main content

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}