Skip to main content

faucet_common_redshift/
pool.rs

1//! Connection-option and pool construction over `sqlx`'s Postgres driver.
2//!
3//! Both the Redshift source and sink build their pools here so TLS, auth, and
4//! pooling behave identically. Redshift is wire-compatible with PostgreSQL, so
5//! this is the same `sqlx::PgPool` machinery the native Postgres connectors use.
6
7use faucet_core::FaucetError;
8use sqlx::PgPool;
9use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
10
11use crate::config::{RedshiftConnection, RedshiftCredentials};
12
13/// Resolve the password for the connection's credentials.
14///
15/// Returns [`FaucetError::Config`] for the `iam` / `redshift_data_api` variants,
16/// which are reserved but not yet implemented in v1.
17pub fn resolve_password(creds: &RedshiftCredentials) -> Result<&str, FaucetError> {
18    match creds {
19        RedshiftCredentials::Password { password } => Ok(password.as_str()),
20        RedshiftCredentials::Iam { .. } => Err(FaucetError::Config(
21            "redshift: IAM authentication is not yet supported (v1 supports password auth only) \
22             — use credentials: { type: password, config: { password: … } }"
23                .into(),
24        )),
25        RedshiftCredentials::RedshiftDataApi { .. } => Err(FaucetError::Config(
26            "redshift: Redshift Data API authentication is not yet supported (v1 supports \
27             password auth only) — use credentials: { type: password, config: { password: … } }"
28                .into(),
29        )),
30    }
31}
32
33/// Build the `sqlx` [`PgConnectOptions`] for a [`RedshiftConnection`].
34///
35/// Pure (no I/O): validates the required fields, resolves the password (this is
36/// where an unsupported credential variant surfaces its typed error), and maps
37/// the TLS toggle onto an `sslmode`.
38pub fn build_connect_options(conn: &RedshiftConnection) -> Result<PgConnectOptions, FaucetError> {
39    if conn.host.trim().is_empty() {
40        return Err(FaucetError::Config(
41            "redshift: `host` must not be empty".into(),
42        ));
43    }
44    if conn.database.trim().is_empty() {
45        return Err(FaucetError::Config(
46            "redshift: `database` must not be empty".into(),
47        ));
48    }
49    if conn.user.trim().is_empty() {
50        return Err(FaucetError::Config(
51            "redshift: `user` must not be empty".into(),
52        ));
53    }
54    let password = resolve_password(&conn.credentials)?;
55    let ssl_mode = if conn.tls {
56        PgSslMode::Require
57    } else {
58        PgSslMode::Prefer
59    };
60    Ok(PgConnectOptions::new()
61        .host(&conn.host)
62        .port(conn.port)
63        .database(&conn.database)
64        .username(&conn.user)
65        .password(password)
66        .ssl_mode(ssl_mode)
67        .application_name("faucet"))
68}
69
70/// Build a lazily-connected pool (no I/O at construction). The first query
71/// establishes the connection; connectivity/auth errors surface then (or via
72/// [`faucet_core::Source::check`] / [`faucet_core::Sink::check`]). Used by the
73/// connectors' `new()` so construction stays offline-safe.
74pub fn build_pool_lazy(
75    conn: &RedshiftConnection,
76    max_connections: u32,
77) -> Result<PgPool, FaucetError> {
78    let opts = build_connect_options(conn)?;
79    Ok(PgPoolOptions::new()
80        .max_connections(max_connections.max(1))
81        .connect_lazy_with(opts))
82}
83
84/// Build a pool and eagerly validate one connection so bad credentials / an
85/// unreachable host fail fast.
86pub async fn build_pool(
87    conn: &RedshiftConnection,
88    max_connections: u32,
89) -> Result<PgPool, FaucetError> {
90    let opts = build_connect_options(conn)?;
91    PgPoolOptions::new()
92        .max_connections(max_connections.max(1))
93        .connect_with(opts)
94        .await
95        .map_err(|e| FaucetError::Config(format!("redshift connection failed: {e}")))
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn conn() -> RedshiftConnection {
103        RedshiftConnection::new("host.example.com", "dev", "admin", "pw")
104    }
105
106    #[test]
107    fn build_options_succeeds_for_password() {
108        assert!(build_connect_options(&conn()).is_ok());
109    }
110
111    #[test]
112    fn build_options_rejects_empty_host() {
113        let mut c = conn();
114        c.host = "  ".into();
115        assert!(matches!(
116            build_connect_options(&c),
117            Err(FaucetError::Config(_))
118        ));
119    }
120
121    #[test]
122    fn build_options_rejects_empty_database() {
123        let mut c = conn();
124        c.database = String::new();
125        assert!(matches!(
126            build_connect_options(&c),
127            Err(FaucetError::Config(_))
128        ));
129    }
130
131    #[test]
132    fn build_options_rejects_empty_user() {
133        let mut c = conn();
134        c.user = String::new();
135        assert!(matches!(
136            build_connect_options(&c),
137            Err(FaucetError::Config(_))
138        ));
139    }
140
141    #[test]
142    fn resolve_password_returns_password() {
143        let creds = RedshiftCredentials::Password {
144            password: "hunter2".into(),
145        };
146        assert_eq!(resolve_password(&creds).unwrap(), "hunter2");
147    }
148
149    #[test]
150    fn resolve_password_rejects_iam_with_typed_error() {
151        let creds = RedshiftCredentials::Iam {
152            region: None,
153            cluster_identifier: None,
154            db_user: None,
155        };
156        match resolve_password(&creds) {
157            Err(FaucetError::Config(m)) => assert!(m.contains("IAM"), "got: {m}"),
158            other => panic!("expected Config error, got {other:?}"),
159        }
160    }
161
162    #[test]
163    fn resolve_password_rejects_data_api_with_typed_error() {
164        let creds = RedshiftCredentials::RedshiftDataApi {
165            region: None,
166            cluster_identifier: None,
167            workgroup_name: None,
168            secret_arn: None,
169            db_user: None,
170        };
171        match resolve_password(&creds) {
172            Err(FaucetError::Config(m)) => assert!(m.contains("Data API"), "got: {m}"),
173            other => panic!("expected Config error, got {other:?}"),
174        }
175    }
176
177    #[test]
178    fn build_connect_options_surfaces_unsupported_credentials() {
179        let mut c = conn();
180        c.credentials = RedshiftCredentials::Iam {
181            region: None,
182            cluster_identifier: None,
183            db_user: None,
184        };
185        assert!(build_connect_options(&c).is_err());
186    }
187
188    #[tokio::test]
189    async fn build_pool_lazy_does_no_io() {
190        // connect_lazy_with never contacts the server, so this is Ok even
191        // against an unreachable host, and no connections are opened yet.
192        // (sqlx spawns a pool maintenance task, so this needs a Tokio runtime.)
193        let pool = build_pool_lazy(&conn(), 4).unwrap();
194        assert_eq!(pool.size(), 0);
195    }
196}