Skip to main content

mz_storage_types/
connections.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Connection types.
11
12use std::borrow::Cow;
13use std::collections::{BTreeMap, BTreeSet};
14use std::net::SocketAddr;
15use std::sync::Arc;
16use std::time::SystemTime;
17
18use anyhow::{Context, anyhow};
19use async_trait::async_trait;
20use aws_credential_types::provider::{ProvideCredentials, SharedCredentialsProvider};
21use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign};
22use aws_sigv4::sign::v4;
23// Aliased to avoid colliding with `mz_ccsr::tls::Identity`.
24use aws_smithy_runtime_api::client::identity::Identity as AwsIdentity;
25use base64::Engine;
26use http::{HeaderName, HeaderValue};
27use iceberg::Catalog;
28use iceberg::CatalogBuilder;
29use iceberg::io::{
30    GCS_CREDENTIALS_JSON, GCS_DISABLE_CONFIG_LOAD, GCS_DISABLE_VM_METADATA, GCS_USER_PROJECT,
31    S3_ACCESS_KEY_ID, S3_DISABLE_EC2_METADATA, S3_REGION, S3_SECRET_ACCESS_KEY,
32};
33use iceberg_catalog_rest::{
34    REST_CATALOG_PROP_URI, REST_CATALOG_PROP_WAREHOUSE, RequestAuthenticator, RestCatalogBuilder,
35};
36use iceberg_storage_opendal::{
37    AwsCredential, AwsCredentialLoad, CustomAwsCredentialLoader, OpenDalStorageFactory,
38};
39use itertools::Itertools;
40use mz_ccsr::tls::{Certificate, Identity};
41use mz_cloud_resources::{AwsExternalIdPrefix, CloudResourceReader, vpc_endpoint_host};
42use mz_dyncfg::ConfigSet;
43use mz_kafka_util::client::{
44    BrokerAddr, BrokerRewrite, HostMappingRules, MzClientContext, MzKafkaError, TunnelConfig,
45    TunnelingClientContext,
46};
47use mz_mysql_util::{MySqlConn, MySqlError};
48use mz_ore::assert_none;
49use mz_ore::error::ErrorExt;
50use mz_ore::future::{InTask, OreFutureExt};
51use mz_ore::netio::resolve_address;
52use mz_ore::num::NonNeg;
53use mz_repr::{CatalogItemId, GlobalId};
54use mz_secrets::SecretsReader;
55use mz_sql_parser::ast::ConnectionRulePattern;
56use mz_ssh_util::keys::SshKeyPair;
57use mz_ssh_util::tunnel::SshTunnelConfig;
58use mz_ssh_util::tunnel_manager::{ManagedSshTunnelHandle, SshTunnelManager};
59use mz_tracing::CloneableEnvFilter;
60use rdkafka::ClientContext;
61use rdkafka::config::FromClientConfigAndContext;
62use rdkafka::consumer::{BaseConsumer, Consumer};
63use regex::Regex;
64use reqwest::Request;
65use serde::{Deserialize, Deserializer, Serialize};
66use tokio::net;
67use tokio::runtime::Handle;
68use tokio_postgres::config::SslMode;
69use tracing::{debug, info, warn};
70use url::Url;
71
72use crate::AlterCompatible;
73use crate::configuration::StorageConfiguration;
74use crate::connections::aws::{
75    AwsAuth, AwsConnection, AwsConnectionReference, AwsConnectionValidationError,
76};
77use crate::connections::gcp::{GcpConnectionReference, GcpTokenProvider};
78use crate::connections::string_or_secret::StringOrSecret;
79use crate::controller::AlterError;
80use crate::dyncfgs::{
81    ENFORCE_EXTERNAL_ADDRESSES, KAFKA_CLIENT_ID_ENRICHMENT_RULES,
82    KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM, KAFKA_RECONNECT_BACKOFF,
83    KAFKA_RECONNECT_BACKOFF_MAX, KAFKA_RETRY_BACKOFF, KAFKA_RETRY_BACKOFF_MAX,
84};
85use crate::errors::{ContextCreationError, CsrConnectError};
86
87pub mod aws;
88pub mod gcp;
89pub mod inline;
90pub mod string_or_secret;
91
92const REST_CATALOG_PROP_SCOPE: &str = "scope";
93const REST_CATALOG_PROP_CREDENTIAL: &str = "credential";
94
95/// A credential loader that wraps an aws-sdk-rust credentials provider for use with
96/// iceberg/OpenDAL. This allows us to provide refreshable credentials from the AWS SDK
97/// credential chain (including the full assume role chain) to OpenDAL's S3 implementation.
98///
99/// We use this instead of OpenDAL's built-in assume role support because Materialize
100/// has a runtime-defined credential chain (ambient → jump role → user role with external ID)
101/// that can't be expressed via OpenDAL's static configuration properties.
102struct AwsSdkCredentialLoader {
103    /// The underlying AWS SDK credentials provider. For assume role auth, this provider
104    /// already handles the full chain: ambient creds -> jump role -> user role.
105    provider: SharedCredentialsProvider,
106}
107
108impl AwsSdkCredentialLoader {
109    fn new(provider: SharedCredentialsProvider) -> Self {
110        Self { provider }
111    }
112}
113
114#[async_trait]
115impl AwsCredentialLoad for AwsSdkCredentialLoader {
116    async fn load_credential(
117        &self,
118        _client: reqwest::Client,
119    ) -> anyhow::Result<Option<AwsCredential>> {
120        let creds = self
121            .provider
122            .provide_credentials()
123            .await
124            .map_err(|e| {
125                warn!(
126                    error = %e.display_with_causes(),
127                    "failed to load AWS credentials for Iceberg FileIO from SDK provider"
128                );
129                e
130            })
131            .context(
132                "failed to load AWS credentials from SDK provider for Iceberg FileIO \
133                 (credential source may be temporarily unavailable)",
134            )?;
135
136        Ok(Some(AwsCredential {
137            access_key_id: creds.access_key_id().to_string(),
138            secret_access_key: creds.secret_access_key().to_string(),
139            session_token: creds.session_token().map(|s| s.to_string()),
140            expires_in: creds.expiry().map(|t| t.into()),
141        }))
142    }
143}
144
145/// Signs each outgoing REST-catalog request with AWS SigV4.
146///
147/// Holds a [`SharedCredentialsProvider`] (not static `Credentials`) so each
148/// request signs with refreshable creds from Materialize's chain
149/// (ambient -> jump role -> user role w/ external ID).
150struct Sigv4Authenticator {
151    provider: SharedCredentialsProvider,
152    region: String,
153    /// The AWS signing name. `"s3tables"` for AWS S3 Tables REST catalog.
154    signing_name: String,
155}
156
157impl std::fmt::Debug for Sigv4Authenticator {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("Sigv4Authenticator")
160            .field("region", &self.region)
161            .field("signing_name", &self.signing_name)
162            .finish_non_exhaustive()
163    }
164}
165
166fn sigv4_err(e: impl Into<anyhow::Error>) -> iceberg::Error {
167    iceberg::Error::new(iceberg::ErrorKind::DataInvalid, "AWS SigV4").with_source(e)
168}
169
170#[async_trait]
171impl RequestAuthenticator for Sigv4Authenticator {
172    async fn authenticate_request(&self, req: &mut Request) -> iceberg::Result<()> {
173        let creds = self
174            .provider
175            .provide_credentials()
176            .await
177            .map_err(sigv4_err)?;
178        let identity: AwsIdentity = creds.into();
179        let params = v4::SigningParams::builder()
180            .identity(&identity)
181            .region(&self.region)
182            .name(&self.signing_name)
183            .time(SystemTime::now())
184            .settings(SigningSettings::default())
185            .build()
186            .map_err(sigv4_err)?
187            .into();
188        let body: &[u8] = req
189            .body()
190            .map(|b| match b.as_bytes() {
191                Some(b) => Ok(b),
192                None => Err(iceberg::Error::new(
193                    iceberg::ErrorKind::FeatureUnsupported,
194                    "SigV4 Authenticator cannot sign a streaming request body.",
195                )),
196            })
197            .transpose()?
198            .unwrap_or_default();
199        let headers = req
200            .headers()
201            .iter()
202            .map(|(k, v)| {
203                Ok((
204                    k.as_str(),
205                    v.to_str().map_err(|_| {
206                        iceberg::Error::new(
207                            iceberg::ErrorKind::DataInvalid,
208                            format!("header '{}' value is not all visible ASCII", k),
209                        )
210                    })?,
211                ))
212            })
213            .collect::<iceberg::Result<Vec<(&str, &str)>>>()?;
214        let signable = SignableRequest::new(
215            req.method().as_str(),
216            req.url().as_str(),
217            headers.into_iter(),
218            SignableBody::Bytes(body),
219        )
220        .map_err(sigv4_err)?;
221        let (instructions, _sig) = sign(signable, &params).map_err(sigv4_err)?.into_parts();
222        let (new_headers, new_query) = instructions.into_parts();
223        for header in new_headers {
224            let mut value = HeaderValue::from_str(header.value()).map_err(sigv4_err)?;
225            value.set_sensitive(header.sensitive());
226            req.headers_mut()
227                .insert(HeaderName::from_static(header.name()), value);
228        }
229        if !new_query.is_empty() {
230            let url = req.url_mut();
231            let mut pairs = url.query_pairs_mut();
232            for (name, value) in new_query {
233                pairs.append_pair(name, &value);
234            }
235        }
236        Ok(())
237    }
238
239    // SigV4 is stateless: nothing to cache, invalidate, or refresh.
240    async fn invalidate_cache(&self) -> iceberg::Result<()> {
241        Ok(())
242    }
243    async fn regenerate_cache(&self) -> iceberg::Result<()> {
244        Ok(())
245    }
246}
247
248/// An extension trait for [`SecretsReader`]
249#[async_trait::async_trait]
250trait SecretsReaderExt {
251    /// `SecretsReader::read`, but optionally run in a task.
252    async fn read_in_task_if(
253        &self,
254        in_task: InTask,
255        id: CatalogItemId,
256    ) -> Result<Vec<u8>, anyhow::Error>;
257
258    /// `SecretsReader::read_string`, but optionally run in a task.
259    async fn read_string_in_task_if(
260        &self,
261        in_task: InTask,
262        id: CatalogItemId,
263    ) -> Result<String, anyhow::Error>;
264}
265
266#[async_trait::async_trait]
267impl SecretsReaderExt for Arc<dyn SecretsReader> {
268    async fn read_in_task_if(
269        &self,
270        in_task: InTask,
271        id: CatalogItemId,
272    ) -> Result<Vec<u8>, anyhow::Error> {
273        let sr = Arc::clone(self);
274        async move { sr.read(id).await }
275            .run_in_task_if(in_task, || "secrets_reader_read".to_string())
276            .await
277    }
278    async fn read_string_in_task_if(
279        &self,
280        in_task: InTask,
281        id: CatalogItemId,
282    ) -> Result<String, anyhow::Error> {
283        let sr = Arc::clone(self);
284        async move { sr.read_string(id).await }
285            .run_in_task_if(in_task, || "secrets_reader_read".to_string())
286            .await
287    }
288}
289
290/// Extra context to pass through when instantiating a connection for a source
291/// or sink.
292///
293/// Should be kept cheaply cloneable.
294#[derive(Debug, Clone)]
295pub struct ConnectionContext {
296    /// An opaque identifier for the environment in which this process is
297    /// running.
298    ///
299    /// The storage layer is intentionally unaware of the structure within this
300    /// identifier. Higher layers of the stack can make use of that structure,
301    /// but the storage layer should be oblivious to it.
302    pub environment_id: String,
303    /// The level for librdkafka's logs.
304    pub librdkafka_log_level: tracing::Level,
305    /// A prefix for an external ID to use for all AWS AssumeRole operations.
306    pub aws_external_id_prefix: Option<AwsExternalIdPrefix>,
307    /// The ARN for a Materialize-controlled role to assume before assuming
308    /// a customer's requested role for an AWS connection.
309    pub aws_connection_role_arn: Option<String>,
310    /// A secrets reader.
311    pub secrets_reader: Arc<dyn SecretsReader>,
312    /// A cloud resource reader, if supported in this configuration.
313    pub cloud_resource_reader: Option<Arc<dyn CloudResourceReader>>,
314    /// A manager for SSH tunnels.
315    pub ssh_tunnel_manager: SshTunnelManager,
316}
317
318impl ConnectionContext {
319    /// Constructs a new connection context from command line arguments.
320    ///
321    /// **WARNING:** it is critical for security that the `aws_external_id` be
322    /// provided by the operator of the Materialize service (i.e., via a CLI
323    /// argument or environment variable) and not the end user of Materialize
324    /// (e.g., via a configuration option in a SQL statement). See
325    /// [`AwsExternalIdPrefix`] for details.
326    pub fn from_cli_args(
327        environment_id: String,
328        startup_log_level: &CloneableEnvFilter,
329        aws_external_id_prefix: Option<AwsExternalIdPrefix>,
330        aws_connection_role_arn: Option<String>,
331        secrets_reader: Arc<dyn SecretsReader>,
332        cloud_resource_reader: Option<Arc<dyn CloudResourceReader>>,
333    ) -> ConnectionContext {
334        ConnectionContext {
335            environment_id,
336            librdkafka_log_level: mz_ore::tracing::crate_level(
337                &startup_log_level.clone().into(),
338                "librdkafka",
339            ),
340            aws_external_id_prefix,
341            aws_connection_role_arn,
342            secrets_reader,
343            cloud_resource_reader,
344            ssh_tunnel_manager: SshTunnelManager::default(),
345        }
346    }
347
348    /// Constructs a new connection context for usage in tests.
349    pub fn for_tests(secrets_reader: Arc<dyn SecretsReader>) -> ConnectionContext {
350        ConnectionContext {
351            environment_id: "test-environment-id".into(),
352            librdkafka_log_level: tracing::Level::INFO,
353            aws_external_id_prefix: Some(
354                AwsExternalIdPrefix::new_from_cli_argument_or_environment_variable(
355                    "test-aws-external-id-prefix",
356                )
357                .expect("infallible"),
358            ),
359            aws_connection_role_arn: Some(
360                "arn:aws:iam::123456789000:role/MaterializeConnection".into(),
361            ),
362            secrets_reader,
363            cloud_resource_reader: None,
364            ssh_tunnel_manager: SshTunnelManager::default(),
365        }
366    }
367}
368
369#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
370pub enum Connection<C: ConnectionAccess = InlinedConnection> {
371    Kafka(KafkaConnection<C>),
372    Csr(CsrConnection<C>),
373    GlueSchemaRegistry(GlueSchemaRegistryConnection<C>),
374    Postgres(PostgresConnection<C>),
375    Ssh(SshConnection),
376    Aws(AwsConnection),
377    AwsPrivatelink(AwsPrivatelinkConnection),
378    Gcp(gcp::GcpConnection),
379    MySql(MySqlConnection<C>),
380    SqlServer(SqlServerConnectionDetails<C>),
381    IcebergCatalog(IcebergCatalogConnection<C>),
382}
383
384impl<R: ConnectionResolver> IntoInlineConnection<Connection, R>
385    for Connection<ReferencedConnection>
386{
387    fn into_inline_connection(self, r: R) -> Connection {
388        match self {
389            Connection::Kafka(kafka) => Connection::Kafka(kafka.into_inline_connection(r)),
390            Connection::Csr(csr) => Connection::Csr(csr.into_inline_connection(r)),
391            Connection::GlueSchemaRegistry(glue) => {
392                Connection::GlueSchemaRegistry(glue.into_inline_connection(r))
393            }
394            Connection::Postgres(pg) => Connection::Postgres(pg.into_inline_connection(r)),
395            Connection::Ssh(ssh) => Connection::Ssh(ssh),
396            Connection::Aws(aws) => Connection::Aws(aws),
397            Connection::AwsPrivatelink(awspl) => Connection::AwsPrivatelink(awspl),
398            Connection::Gcp(gcp) => Connection::Gcp(gcp),
399            Connection::MySql(mysql) => Connection::MySql(mysql.into_inline_connection(r)),
400            Connection::SqlServer(sql_server) => {
401                Connection::SqlServer(sql_server.into_inline_connection(r))
402            }
403            Connection::IcebergCatalog(iceberg) => {
404                Connection::IcebergCatalog(iceberg.into_inline_connection(r))
405            }
406        }
407    }
408}
409
410impl<C: ConnectionAccess> Connection<C> {
411    /// Whether this connection should be validated by default on creation.
412    pub fn validate_by_default(&self) -> bool {
413        match self {
414            Connection::Kafka(conn) => conn.validate_by_default(),
415            Connection::Csr(conn) => conn.validate_by_default(),
416            Connection::GlueSchemaRegistry(conn) => conn.validate_by_default(),
417            Connection::Postgres(conn) => conn.validate_by_default(),
418            Connection::Ssh(conn) => conn.validate_by_default(),
419            Connection::Aws(conn) => conn.validate_by_default(),
420            Connection::AwsPrivatelink(conn) => conn.validate_by_default(),
421            Connection::Gcp(conn) => conn.validate_by_default(),
422            Connection::MySql(conn) => conn.validate_by_default(),
423            Connection::SqlServer(conn) => conn.validate_by_default(),
424            Connection::IcebergCatalog(conn) => conn.validate_by_default(),
425        }
426    }
427}
428
429impl Connection<InlinedConnection> {
430    /// Validates this connection by attempting to connect to the upstream system.
431    pub async fn validate(
432        &self,
433        id: CatalogItemId,
434        storage_configuration: &StorageConfiguration,
435    ) -> Result<(), ConnectionValidationError> {
436        match self {
437            Connection::Kafka(conn) => conn.validate(id, storage_configuration).await?,
438            Connection::Csr(conn) => conn.validate(id, storage_configuration).await?,
439            Connection::GlueSchemaRegistry(conn) => {
440                conn.validate(id, storage_configuration).await?
441            }
442            Connection::Postgres(conn) => {
443                conn.validate(id, storage_configuration).await?;
444            }
445            Connection::Ssh(conn) => conn.validate(id, storage_configuration).await?,
446            Connection::Aws(conn) => conn.validate(id, storage_configuration).await?,
447            Connection::AwsPrivatelink(conn) => conn.validate(id, storage_configuration).await?,
448            Connection::Gcp(conn) => conn.validate(id, storage_configuration).await?,
449            Connection::MySql(conn) => {
450                conn.validate(id, storage_configuration).await?;
451            }
452            Connection::SqlServer(conn) => {
453                conn.validate(id, storage_configuration).await?;
454            }
455            Connection::IcebergCatalog(conn) => conn.validate(id, storage_configuration).await?,
456        }
457        Ok(())
458    }
459
460    pub fn unwrap_kafka(self) -> <InlinedConnection as ConnectionAccess>::Kafka {
461        match self {
462            Self::Kafka(conn) => conn,
463            o => unreachable!("{o:?} is not a Kafka connection"),
464        }
465    }
466
467    pub fn unwrap_pg(self) -> <InlinedConnection as ConnectionAccess>::Pg {
468        match self {
469            Self::Postgres(conn) => conn,
470            o => unreachable!("{o:?} is not a Postgres connection"),
471        }
472    }
473
474    pub fn unwrap_mysql(self) -> <InlinedConnection as ConnectionAccess>::MySql {
475        match self {
476            Self::MySql(conn) => conn,
477            o => unreachable!("{o:?} is not a MySQL connection"),
478        }
479    }
480
481    pub fn unwrap_sql_server(self) -> <InlinedConnection as ConnectionAccess>::SqlServer {
482        match self {
483            Self::SqlServer(conn) => conn,
484            o => unreachable!("{o:?} is not a SQL Server connection"),
485        }
486    }
487
488    pub fn unwrap_aws(self) -> <InlinedConnection as ConnectionAccess>::Aws {
489        match self {
490            Self::Aws(conn) => conn,
491            o => unreachable!("{o:?} is not an AWS connection"),
492        }
493    }
494
495    pub fn unwrap_gcp(self) -> <InlinedConnection as ConnectionAccess>::Gcp {
496        match self {
497            Self::Gcp(conn) => conn,
498            o => unreachable!("{o:?} is not a GCP connection"),
499        }
500    }
501
502    pub fn unwrap_ssh(self) -> <InlinedConnection as ConnectionAccess>::Ssh {
503        match self {
504            Self::Ssh(conn) => conn,
505            o => unreachable!("{o:?} is not an SSH connection"),
506        }
507    }
508
509    pub fn unwrap_csr(self) -> <InlinedConnection as ConnectionAccess>::Csr {
510        match self {
511            Self::Csr(conn) => conn,
512            o => unreachable!("{o:?} is not a Kafka connection"),
513        }
514    }
515
516    pub fn unwrap_glue_schema_registry(
517        self,
518    ) -> <InlinedConnection as ConnectionAccess>::GlueSchemaRegistry {
519        match self {
520            Self::GlueSchemaRegistry(conn) => conn,
521            o => unreachable!("{o:?} is not an AWS Glue Schema Registry connection"),
522        }
523    }
524
525    pub fn unwrap_iceberg_catalog(self) -> <InlinedConnection as ConnectionAccess>::IcebergCatalog {
526        match self {
527            Self::IcebergCatalog(conn) => conn,
528            o => unreachable!("{o:?} is not an Iceberg catalog connection"),
529        }
530    }
531}
532
533/// An error returned by [`Connection::validate`].
534#[derive(thiserror::Error, Debug)]
535pub enum ConnectionValidationError {
536    #[error(transparent)]
537    Postgres(#[from] PostgresConnectionValidationError),
538    #[error(transparent)]
539    MySql(#[from] MySqlConnectionValidationError),
540    #[error(transparent)]
541    SqlServer(#[from] SqlServerConnectionValidationError),
542    #[error(transparent)]
543    Aws(#[from] AwsConnectionValidationError),
544    #[error(transparent)]
545    Gcp(#[from] gcp::GcpConnectionValidationError),
546    #[error("{}", .0.display_with_causes())]
547    Other(#[from] anyhow::Error),
548}
549
550impl ConnectionValidationError {
551    /// Reports additional details about the error, if any are available.
552    pub fn detail(&self) -> Option<String> {
553        match self {
554            ConnectionValidationError::Postgres(e) => e.detail(),
555            ConnectionValidationError::MySql(e) => e.detail(),
556            ConnectionValidationError::SqlServer(e) => e.detail(),
557            ConnectionValidationError::Aws(e) => e.detail(),
558            ConnectionValidationError::Gcp(e) => e.detail(),
559            ConnectionValidationError::Other(_) => None,
560        }
561    }
562
563    /// Reports a hint for the user about how the error could be fixed.
564    pub fn hint(&self) -> Option<String> {
565        match self {
566            ConnectionValidationError::Postgres(e) => e.hint(),
567            ConnectionValidationError::MySql(e) => e.hint(),
568            ConnectionValidationError::SqlServer(e) => e.hint(),
569            ConnectionValidationError::Aws(e) => e.hint(),
570            ConnectionValidationError::Gcp(e) => e.hint(),
571            ConnectionValidationError::Other(_) => None,
572        }
573    }
574}
575
576impl<C: ConnectionAccess> AlterCompatible for Connection<C> {
577    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
578        match (self, other) {
579            (Self::Aws(s), Self::Aws(o)) => s.alter_compatible(id, o),
580            (Self::AwsPrivatelink(s), Self::AwsPrivatelink(o)) => s.alter_compatible(id, o),
581            (Self::Gcp(s), Self::Gcp(o)) => s.alter_compatible(id, o),
582            (Self::Ssh(s), Self::Ssh(o)) => s.alter_compatible(id, o),
583            (Self::Csr(s), Self::Csr(o)) => s.alter_compatible(id, o),
584            (Self::Kafka(s), Self::Kafka(o)) => s.alter_compatible(id, o),
585            (Self::Postgres(s), Self::Postgres(o)) => s.alter_compatible(id, o),
586            (Self::MySql(s), Self::MySql(o)) => s.alter_compatible(id, o),
587            _ => {
588                tracing::warn!(
589                    "Connection incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
590                    self,
591                    other
592                );
593                Err(AlterError { id })
594            }
595        }
596    }
597}
598
599/// Auth mechanism for Iceberg REST catalogs.
600#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
601pub enum IcebergCatalogAuth<C: ConnectionAccess = InlinedConnection> {
602    /// Use Iceberg catalog REST API's standard OAuth flow.
603    OAuth {
604        /// client_id:client_secret
605        credential: StringOrSecret,
606        /// OAuth2 scope
607        scope: Option<String>,
608    },
609    Gcp(GcpConnectionReference<C>),
610}
611
612#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
613pub struct RestIcebergCatalog<C: ConnectionAccess = InlinedConnection> {
614    pub auth: IcebergCatalogAuth<C>,
615    /// The warehouse for REST catalogs
616    pub warehouse: Option<String>,
617}
618
619#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
620pub struct S3TablesRestIcebergCatalog<C: ConnectionAccess = InlinedConnection> {
621    /// The AWS connection details, for s3tables
622    pub aws_connection: AwsConnectionReference<C>,
623    /// The warehouse for s3tables
624    pub warehouse: String,
625}
626
627impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogAuth, R>
628    for IcebergCatalogAuth<ReferencedConnection>
629{
630    fn into_inline_connection(self, r: R) -> IcebergCatalogAuth {
631        match self {
632            IcebergCatalogAuth::Gcp(x) => IcebergCatalogAuth::Gcp(x.into_inline_connection(&r)),
633            IcebergCatalogAuth::OAuth { credential, scope } => {
634                IcebergCatalogAuth::OAuth { credential, scope }
635            }
636        }
637    }
638}
639
640impl<R: ConnectionResolver> IntoInlineConnection<RestIcebergCatalog, R>
641    for RestIcebergCatalog<ReferencedConnection>
642{
643    fn into_inline_connection(self, r: R) -> RestIcebergCatalog {
644        RestIcebergCatalog {
645            auth: self.auth.into_inline_connection(&r),
646            warehouse: self.warehouse,
647        }
648    }
649}
650
651impl<R: ConnectionResolver> IntoInlineConnection<S3TablesRestIcebergCatalog, R>
652    for S3TablesRestIcebergCatalog<ReferencedConnection>
653{
654    fn into_inline_connection(self, r: R) -> S3TablesRestIcebergCatalog {
655        S3TablesRestIcebergCatalog {
656            aws_connection: self.aws_connection.into_inline_connection(&r),
657            warehouse: self.warehouse,
658        }
659    }
660}
661
662#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
663pub enum IcebergCatalogType {
664    Rest,
665    S3TablesRest,
666}
667
668#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
669pub enum IcebergCatalogImpl<C: ConnectionAccess = InlinedConnection> {
670    Rest(RestIcebergCatalog<C>),
671    S3TablesRest(S3TablesRestIcebergCatalog<C>),
672}
673
674impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogImpl, R>
675    for IcebergCatalogImpl<ReferencedConnection>
676{
677    fn into_inline_connection(self, r: R) -> IcebergCatalogImpl {
678        match self {
679            IcebergCatalogImpl::Rest(rest) => {
680                IcebergCatalogImpl::Rest(rest.into_inline_connection(r))
681            }
682            IcebergCatalogImpl::S3TablesRest(s3tables) => {
683                IcebergCatalogImpl::S3TablesRest(s3tables.into_inline_connection(r))
684            }
685        }
686    }
687}
688
689#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
690pub struct IcebergCatalogConnection<C: ConnectionAccess = InlinedConnection> {
691    /// The catalog impl impl of that catalog
692    pub catalog: IcebergCatalogImpl<C>,
693    /// Where the catalog is located
694    pub uri: reqwest::Url,
695}
696
697impl AlterCompatible for IcebergCatalogConnection {
698    fn alter_compatible(&self, id: GlobalId, _other: &Self) -> Result<(), AlterError> {
699        Err(AlterError { id })
700    }
701}
702
703impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogConnection, R>
704    for IcebergCatalogConnection<ReferencedConnection>
705{
706    fn into_inline_connection(self, r: R) -> IcebergCatalogConnection {
707        IcebergCatalogConnection {
708            catalog: self.catalog.into_inline_connection(&r),
709            uri: self.uri,
710        }
711    }
712}
713
714impl<C: ConnectionAccess> IcebergCatalogConnection<C> {
715    fn validate_by_default(&self) -> bool {
716        true
717    }
718}
719
720impl IcebergCatalogConnection<InlinedConnection> {
721    pub async fn connect(
722        &self,
723        storage_configuration: &StorageConfiguration,
724        in_task: InTask,
725    ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
726        match self.catalog {
727            IcebergCatalogImpl::S3TablesRest(ref s3tables) => {
728                self.connect_s3tables(s3tables, storage_configuration, in_task)
729                    .await
730            }
731            IcebergCatalogImpl::Rest(ref rest) => {
732                self.connect_rest(rest, storage_configuration, in_task)
733                    .await
734            }
735        }
736    }
737
738    pub fn catalog_type(&self) -> IcebergCatalogType {
739        match self.catalog {
740            IcebergCatalogImpl::S3TablesRest(_) => IcebergCatalogType::S3TablesRest,
741            IcebergCatalogImpl::Rest(_) => IcebergCatalogType::Rest,
742        }
743    }
744
745    pub fn s3tables_catalog(&self) -> Option<&S3TablesRestIcebergCatalog> {
746        match &self.catalog {
747            IcebergCatalogImpl::S3TablesRest(s3tables) => Some(s3tables),
748            IcebergCatalogImpl::Rest(_) => None,
749        }
750    }
751
752    pub fn rest_catalog(&self) -> Option<&RestIcebergCatalog> {
753        match &self.catalog {
754            IcebergCatalogImpl::Rest(rest) => Some(rest),
755            IcebergCatalogImpl::S3TablesRest(_) => None,
756        }
757    }
758
759    async fn connect_s3tables(
760        &self,
761        s3tables: &S3TablesRestIcebergCatalog,
762        storage_configuration: &StorageConfiguration,
763        in_task: InTask,
764    ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
765        let secret_reader = &storage_configuration.connection_context.secrets_reader;
766        let aws_ref = &s3tables.aws_connection;
767
768        let aws_region = aws_ref
769            .connection
770            .region
771            .clone()
772            .unwrap_or_else(|| "us-east-1".to_string());
773
774        let mut props = vec![
775            (S3_REGION.to_string(), aws_region.clone()),
776            (S3_DISABLE_EC2_METADATA.to_string(), "true".to_string()),
777            (
778                REST_CATALOG_PROP_WAREHOUSE.to_string(),
779                s3tables.warehouse.clone(),
780            ),
781            (REST_CATALOG_PROP_URI.to_string(), self.uri.to_string()),
782        ];
783
784        let aws_auth = aws_ref.connection.auth.clone();
785
786        if let AwsAuth::Credentials(creds) = &aws_auth {
787            props.push((
788                S3_ACCESS_KEY_ID.to_string(),
789                creds
790                    .access_key_id
791                    .get_string(in_task, secret_reader)
792                    .await?,
793            ));
794            props.push((
795                S3_SECRET_ACCESS_KEY.to_string(),
796                secret_reader.read_string(creds.secret_access_key).await?,
797            ));
798        }
799
800        // Sign REST catalog requests with the Materialize AWS credential chain
801        // via a custom `RequestAuthenticator`. For AssumeRole auth, also feed
802        // the chain to OpenDAL's S3 loader so data-file IO uses the same creds.
803        //
804        // For AssumeRole auth, the provider serves cached credentials that a
805        // background task keeps fresh, so no request through this catalog ever
806        // waits on STS. The task lives as long as the catalog holds the
807        // provider.
808        let credentials_provider = match &aws_auth {
809            // NOTE: This branch never contacts the connection's ENDPOINT.
810            // REST requests go to the catalog URI and the STS calls use the
811            // SDK defaults. The endpoint is still validated so a forbidden
812            // one is rejected rather than silently ignored.
813            AwsAuth::AssumeRole(assume_role) => {
814                aws_ref.connection.validate_endpoint(
815                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
816                )?;
817                assume_role
818                    .prefetch_credentials(
819                        &storage_configuration.connection_context,
820                        aws_ref.connection_id,
821                        storage_configuration.config_set(),
822                        format!("aws-connection-{}", aws_ref.connection_id),
823                    )
824                    .await
825                    .with_context(|| {
826                        format!(
827                            "failed to initialize AssumeRole credentials for S3 Tables Iceberg \
828                             catalog (catalog uri: {}, warehouse: {})",
829                            self.uri, s3tables.warehouse
830                        )
831                    })?
832            }
833            AwsAuth::Credentials(_) => {
834                let aws_config = aws_ref
835                    .connection
836                    .load_sdk_config(
837                        &storage_configuration.connection_context,
838                        aws_ref.connection_id,
839                        in_task,
840                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
841                    )
842                    .await
843                    .with_context(|| {
844                        format!(
845                            "failed to load AWS SDK config for S3 Tables Iceberg catalog \
846                             (connection id: {}, auth method: {}, catalog uri: {}, warehouse: {})",
847                            aws_ref.connection_id,
848                            aws_ref.connection.auth_method(),
849                            self.uri,
850                            s3tables.warehouse
851                        )
852                    })?;
853                aws_config
854                    .credentials_provider()
855                    .ok_or_else(|| anyhow!("aws_config missing credentials provider"))?
856            }
857        };
858
859        let authenticator = Arc::new(Sigv4Authenticator {
860            provider: credentials_provider.clone(),
861            region: aws_region.clone(),
862            signing_name: "s3tables".to_string(),
863        });
864
865        // N.B. We're using the AWS credentials from the catalog connection for the storage layer
866        //   even though the sink comes with its own (unused) AWS credentials for storage.
867        let customized_credential_load = if matches!(aws_auth, AwsAuth::AssumeRole(_)) {
868            Some(CustomAwsCredentialLoader::new(Arc::new(
869                AwsSdkCredentialLoader::new(credentials_provider),
870            )))
871        } else {
872            None
873        };
874
875        let storage_factory = Arc::new(OpenDalStorageFactory::S3 {
876            configured_scheme: "s3".to_string(),
877            customized_credential_load,
878        });
879
880        let catalog = RestCatalogBuilder::default()
881            .with_storage_factory(storage_factory)
882            .with_authenticator(authenticator)
883            .load("IcebergCatalog", props.into_iter().collect())
884            .await
885            .with_context(|| {
886                format!(
887                    "failed to create S3 Tables Iceberg catalog \
888                     (connection id: {}, catalog uri: {}, warehouse: {})",
889                    aws_ref.connection_id, self.uri, s3tables.warehouse
890                )
891            })?;
892
893        Ok(Arc::new(catalog))
894    }
895
896    async fn connect_rest(
897        &self,
898        rest: &RestIcebergCatalog,
899        storage_configuration: &StorageConfiguration,
900        in_task: InTask,
901    ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
902        let mut props = BTreeMap::from([(
903            REST_CATALOG_PROP_URI.to_string(),
904            self.uri.to_string().clone(),
905        )]);
906
907        if let Some(warehouse) = &rest.warehouse {
908            props.insert(REST_CATALOG_PROP_WAREHOUSE.to_string(), warehouse.clone());
909        }
910
911        // Catalog auth is configured through a combination of `props` and `.with_authenticator(...)`,
912        // which happen at different stages of the [`RestCatalogBuilder`] -> [`RestCatalog`]
913        // construction pipeline.
914        let (storage_factory, custom_authenticator) = match &rest.auth {
915            IcebergCatalogAuth::OAuth { credential, scope } => {
916                let credential = credential
917                    .get_string(
918                        in_task,
919                        &storage_configuration.connection_context.secrets_reader,
920                    )
921                    .await
922                    .map_err(|e| anyhow!("failed to read Iceberg catalog credential: {e}"))?;
923                props.insert(REST_CATALOG_PROP_CREDENTIAL.to_string(), credential);
924
925                if let Some(scope) = scope {
926                    props.insert(REST_CATALOG_PROP_SCOPE.to_string(), scope.clone());
927                }
928                (
929                    OpenDalStorageFactory::S3 {
930                        configured_scheme: "s3".to_string(),
931                        // When used with MinIO, Polaris returns a config with:
932                        //   s3.access-key-id, s3.secret-access-key, s3.endpoint, ...
933                        // `iceberg-rust` forwards these props to `opendal`.
934                        // N.B. This is not confirmed to work with other catalog & storage implementations.
935                        customized_credential_load: None,
936                    },
937                    None,
938                )
939            }
940            IcebergCatalogAuth::Gcp(gcp_connection_reference) => {
941                let (creds_json, service_account) = gcp_connection_reference
942                    .connection
943                    .read_credentials(storage_configuration)
944                    .await
945                    .map_err(|e| anyhow!("failed to parse GCP service account JSON: {e}"))?;
946
947                props.insert(
948                    GCS_CREDENTIALS_JSON.to_owned(),
949                    base64::engine::general_purpose::STANDARD.encode(creds_json),
950                );
951                // We supplied a service account key. Don't look elsewhere for GCP credentials.
952                props.insert(GCS_DISABLE_VM_METADATA.to_owned(), "true".to_owned());
953                props.insert(GCS_DISABLE_CONFIG_LOAD.to_owned(), "true".to_owned());
954                if let Some(project_id) = service_account.project_id() {
955                    props.insert(GCS_USER_PROJECT.to_owned(), project_id.to_owned());
956                    props.insert(
957                        "header.x-goog-user-project".to_owned(),
958                        project_id.to_owned(),
959                    );
960                }
961
962                (
963                    OpenDalStorageFactory::Gcs,
964                    Some(iceberg_catalog_rest::BearerTokenAuthenticator::new(
965                        Arc::new(GcpTokenProvider { service_account }),
966                    )),
967                )
968            }
969        };
970
971        let mut catalog =
972            RestCatalogBuilder::default().with_storage_factory(Arc::new(storage_factory));
973        if let Some(auth) = custom_authenticator {
974            catalog = catalog.with_authenticator(Arc::new(auth));
975        }
976        let catalog = catalog
977            .load("IcebergCatalog", props.into_iter().collect())
978            .await
979            .map_err(|e| anyhow!("failed to create Iceberg catalog: {e}"))?;
980        Ok(Arc::new(catalog))
981    }
982
983    async fn validate(
984        &self,
985        _id: CatalogItemId,
986        storage_configuration: &StorageConfiguration,
987    ) -> Result<(), ConnectionValidationError> {
988        let catalog = self
989            .connect(storage_configuration, InTask::No)
990            .await
991            .map_err(|e| {
992                ConnectionValidationError::Other(anyhow!("failed to connect to catalog: {e}"))
993            })?;
994
995        // If we can list namespaces, the connection is valid.
996        catalog.list_namespaces(None).await.map_err(|e| {
997            ConnectionValidationError::Other(anyhow!("failed to list namespaces: {e}"))
998        })?;
999
1000        Ok(())
1001    }
1002}
1003
1004#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1005pub struct AwsPrivatelinkConnection {
1006    pub service_name: String,
1007    pub availability_zones: Vec<String>,
1008}
1009
1010impl AlterCompatible for AwsPrivatelinkConnection {
1011    fn alter_compatible(&self, _id: GlobalId, _other: &Self) -> Result<(), AlterError> {
1012        // Every element of the AwsPrivatelinkConnection connection is configurable.
1013        Ok(())
1014    }
1015}
1016
1017#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1018pub struct KafkaTlsConfig {
1019    pub identity: Option<TlsIdentity>,
1020    pub root_cert: Option<StringOrSecret>,
1021}
1022
1023#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1024pub struct KafkaSaslConfig<C: ConnectionAccess = InlinedConnection> {
1025    pub mechanism: String,
1026    pub username: StringOrSecret,
1027    pub password: Option<CatalogItemId>,
1028    pub aws: Option<AwsConnectionReference<C>>,
1029}
1030
1031impl<R: ConnectionResolver> IntoInlineConnection<KafkaSaslConfig, R>
1032    for KafkaSaslConfig<ReferencedConnection>
1033{
1034    fn into_inline_connection(self, r: R) -> KafkaSaslConfig {
1035        KafkaSaslConfig {
1036            mechanism: self.mechanism,
1037            username: self.username,
1038            password: self.password,
1039            aws: self.aws.map(|aws| aws.into_inline_connection(&r)),
1040        }
1041    }
1042}
1043
1044/// Specifies a Kafka broker in a [`KafkaConnection`].
1045#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1046pub struct KafkaBroker<C: ConnectionAccess = InlinedConnection> {
1047    /// The address of the Kafka broker.
1048    pub address: String,
1049    /// An optional tunnel to use when connecting to the broker.
1050    pub tunnel: Tunnel<C>,
1051}
1052
1053impl<R: ConnectionResolver> IntoInlineConnection<KafkaBroker, R>
1054    for KafkaBroker<ReferencedConnection>
1055{
1056    fn into_inline_connection(self, r: R) -> KafkaBroker {
1057        let KafkaBroker { address, tunnel } = self;
1058        KafkaBroker {
1059            address,
1060            tunnel: tunnel.into_inline_connection(r),
1061        }
1062    }
1063}
1064
1065#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, Default)]
1066pub struct KafkaTopicOptions {
1067    /// The replication factor for the topic.
1068    /// If `None`, the broker default will be used.
1069    pub replication_factor: Option<NonNeg<i32>>,
1070    /// The number of partitions to create.
1071    /// If `None`, the broker default will be used.
1072    pub partition_count: Option<NonNeg<i32>>,
1073    /// The initial configuration parameters for the topic.
1074    pub topic_config: BTreeMap<String, String>,
1075}
1076
1077#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1078pub struct KafkaConnection<C: ConnectionAccess = InlinedConnection> {
1079    pub brokers: Vec<KafkaBroker<C>>,
1080    /// A tunnel through which to route traffic,
1081    /// that can be overridden for individual brokers
1082    /// in `brokers`.
1083    pub default_tunnel: Tunnel<C>,
1084    pub progress_topic: Option<String>,
1085    pub progress_topic_options: KafkaTopicOptions,
1086    pub options: BTreeMap<String, StringOrSecret>,
1087    pub tls: Option<KafkaTlsConfig>,
1088    pub sasl: Option<KafkaSaslConfig<C>>,
1089}
1090
1091impl<R: ConnectionResolver> IntoInlineConnection<KafkaConnection, R>
1092    for KafkaConnection<ReferencedConnection>
1093{
1094    fn into_inline_connection(self, r: R) -> KafkaConnection {
1095        let KafkaConnection {
1096            brokers,
1097            progress_topic,
1098            progress_topic_options,
1099            default_tunnel,
1100            options,
1101            tls,
1102            sasl,
1103        } = self;
1104
1105        let brokers = brokers
1106            .into_iter()
1107            .map(|broker| broker.into_inline_connection(&r))
1108            .collect();
1109
1110        KafkaConnection {
1111            brokers,
1112            progress_topic,
1113            progress_topic_options,
1114            default_tunnel: default_tunnel.into_inline_connection(&r),
1115            options,
1116            tls,
1117            sasl: sasl.map(|sasl| sasl.into_inline_connection(&r)),
1118        }
1119    }
1120}
1121
1122impl<C: ConnectionAccess> KafkaConnection<C> {
1123    /// Returns the name of the progress topic to use for the connection.
1124    ///
1125    /// The caller is responsible for providing the connection ID as it is not
1126    /// known to `KafkaConnection`.
1127    pub fn progress_topic(
1128        &self,
1129        connection_context: &ConnectionContext,
1130        connection_id: CatalogItemId,
1131    ) -> Cow<'_, str> {
1132        if let Some(progress_topic) = &self.progress_topic {
1133            Cow::Borrowed(progress_topic)
1134        } else {
1135            Cow::Owned(format!(
1136                "_materialize-progress-{}-{}",
1137                connection_context.environment_id, connection_id,
1138            ))
1139        }
1140    }
1141
1142    fn validate_by_default(&self) -> bool {
1143        true
1144    }
1145}
1146
1147impl KafkaConnection {
1148    /// Generates a string that can be used as the base for a configuration ID
1149    /// (e.g., `client.id`, `group.id`, `transactional.id`) for a Kafka source
1150    /// or sink.
1151    ///
1152    /// NOTE: the `mz_catalog.mz_kafka_sources` builtin materialized view
1153    /// reconstructs this exact `materialize-<env>-<conn>-<obj>` format in SQL
1154    /// (see `MZ_KAFKA_SOURCES` in `src/catalog/src/builtin/mz_catalog.rs`).
1155    /// The two must stay in sync. `test/testdrive/kafka-commit.td` guards this
1156    /// by feeding the view's reconstructed value into `kafka-verify-commit`,
1157    /// so a divergence here fails that test.
1158    pub fn id_base(
1159        connection_context: &ConnectionContext,
1160        connection_id: CatalogItemId,
1161        object_id: GlobalId,
1162    ) -> String {
1163        format!(
1164            "materialize-{}-{}-{}",
1165            connection_context.environment_id, connection_id, object_id,
1166        )
1167    }
1168
1169    /// Enriches the provided `client_id` according to any enrichment rules in
1170    /// the `kafka_client_id_enrichment_rules` configuration parameter.
1171    pub fn enrich_client_id(&self, configs: &ConfigSet, client_id: &mut String) {
1172        #[derive(Debug, Deserialize)]
1173        struct EnrichmentRule {
1174            #[serde(deserialize_with = "deserialize_regex")]
1175            pattern: Regex,
1176            payload: String,
1177        }
1178
1179        fn deserialize_regex<'de, D>(deserializer: D) -> Result<Regex, D::Error>
1180        where
1181            D: Deserializer<'de>,
1182        {
1183            let buf = String::deserialize(deserializer)?;
1184            Regex::new(&buf).map_err(serde::de::Error::custom)
1185        }
1186
1187        let rules = KAFKA_CLIENT_ID_ENRICHMENT_RULES.get(configs);
1188        let rules = match serde_json::from_value::<Vec<EnrichmentRule>>(rules) {
1189            Ok(rules) => rules,
1190            Err(e) => {
1191                warn!(%e, "failed to decode kafka_client_id_enrichment_rules");
1192                return;
1193            }
1194        };
1195
1196        // Check every rule against every broker. Rules are matched in the order
1197        // that they are specified. It is usually a configuration error if
1198        // multiple rules match the same list of Kafka brokers, but we
1199        // nonetheless want to provide well defined semantics.
1200        debug!(?self.brokers, "evaluating client ID enrichment rules");
1201        for rule in rules {
1202            let is_match = self
1203                .brokers
1204                .iter()
1205                .any(|b| rule.pattern.is_match(&b.address));
1206            debug!(?rule, is_match, "evaluated client ID enrichment rule");
1207            if is_match {
1208                client_id.push('-');
1209                client_id.push_str(&rule.payload);
1210            }
1211        }
1212    }
1213
1214    /// Creates a Kafka client for the connection.
1215    pub async fn create_with_context<C, T>(
1216        &self,
1217        storage_configuration: &StorageConfiguration,
1218        context: C,
1219        extra_options: &BTreeMap<&str, String>,
1220        in_task: InTask,
1221    ) -> Result<T, ContextCreationError>
1222    where
1223        C: ClientContext,
1224        T: FromClientConfigAndContext<TunnelingClientContext<C>>,
1225    {
1226        let mut options = self.options.clone();
1227
1228        // Ensure that Kafka topics are *not* automatically created when
1229        // consuming, producing, or fetching metadata for a topic. This ensures
1230        // that we don't accidentally create topics with the wrong number of
1231        // partitions.
1232        options.insert("allow.auto.create.topics".into(), "false".into());
1233
1234        let brokers = match &self.default_tunnel {
1235            Tunnel::AwsPrivatelink(t) => {
1236                assert!(&self.brokers.is_empty());
1237
1238                let algo = KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM
1239                    .get(storage_configuration.config_set());
1240                options.insert("ssl.endpoint.identification.algorithm".into(), algo.into());
1241
1242                // When using a default privatelink tunnel broker/brokers cannot be specified
1243                // instead the tunnel connection_id and port are used for the initial connection.
1244                format!(
1245                    "{}:{}",
1246                    vpc_endpoint_host(
1247                        t.connection_id,
1248                        None, // Default tunnel does not support availability zones.
1249                    ),
1250                    t.port.unwrap_or(9092)
1251                )
1252            }
1253            Tunnel::AwsPrivatelinks(_pl) => {
1254                let algo = KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM
1255                    .get(storage_configuration.config_set());
1256                options.insert("ssl.endpoint.identification.algorithm".into(), algo.into());
1257
1258                if self.brokers.is_empty() {
1259                    return Err(ContextCreationError::Other(anyhow::anyhow!(
1260                        "at least one static broker is required when using BROKER or BROKERS"
1261                    )));
1262                }
1263                self.brokers.iter().map(|b| &b.address).join(",")
1264            }
1265            _ => self.brokers.iter().map(|b| &b.address).join(","),
1266        };
1267        options.insert("bootstrap.servers".into(), brokers.clone().into());
1268        let security_protocol = match (self.tls.is_some(), self.sasl.is_some()) {
1269            (false, false) => "PLAINTEXT",
1270            (true, false) => "SSL",
1271            (false, true) => "SASL_PLAINTEXT",
1272            (true, true) => "SASL_SSL",
1273        };
1274        info!(
1275            "kafka: create_with_context bootstrap.servers={brokers}, security_protocol={security_protocol}"
1276        );
1277        options.insert("security.protocol".into(), security_protocol.into());
1278        if let Some(tls) = &self.tls {
1279            if let Some(root_cert) = &tls.root_cert {
1280                options.insert("ssl.ca.pem".into(), root_cert.clone());
1281            }
1282            if let Some(identity) = &tls.identity {
1283                options.insert("ssl.key.pem".into(), StringOrSecret::Secret(identity.key));
1284                options.insert("ssl.certificate.pem".into(), identity.cert.clone());
1285            }
1286        }
1287        if let Some(sasl) = &self.sasl {
1288            options.insert("sasl.mechanisms".into(), (&sasl.mechanism).into());
1289            options.insert("sasl.username".into(), sasl.username.clone());
1290            if let Some(password) = sasl.password {
1291                options.insert("sasl.password".into(), StringOrSecret::Secret(password));
1292            }
1293        }
1294
1295        options.insert(
1296            "retry.backoff.ms".into(),
1297            KAFKA_RETRY_BACKOFF
1298                .get(storage_configuration.config_set())
1299                .as_millis()
1300                .into(),
1301        );
1302        options.insert(
1303            "retry.backoff.max.ms".into(),
1304            KAFKA_RETRY_BACKOFF_MAX
1305                .get(storage_configuration.config_set())
1306                .as_millis()
1307                .into(),
1308        );
1309        options.insert(
1310            "reconnect.backoff.ms".into(),
1311            KAFKA_RECONNECT_BACKOFF
1312                .get(storage_configuration.config_set())
1313                .as_millis()
1314                .into(),
1315        );
1316        options.insert(
1317            "reconnect.backoff.max.ms".into(),
1318            KAFKA_RECONNECT_BACKOFF_MAX
1319                .get(storage_configuration.config_set())
1320                .as_millis()
1321                .into(),
1322        );
1323
1324        let mut config = mz_kafka_util::client::create_new_client_config(
1325            storage_configuration
1326                .connection_context
1327                .librdkafka_log_level,
1328            storage_configuration.parameters.kafka_timeout_config,
1329        );
1330        for (k, v) in options {
1331            config.set(
1332                k,
1333                v.get_string(
1334                    in_task,
1335                    &storage_configuration.connection_context.secrets_reader,
1336                )
1337                .await
1338                .context("reading kafka secret")?,
1339            );
1340        }
1341        for (k, v) in extra_options {
1342            config.set(*k, v);
1343        }
1344
1345        let aws_config = match self.sasl.as_ref().and_then(|sasl| sasl.aws.as_ref()) {
1346            None => None,
1347            Some(aws) => Some(
1348                aws.connection
1349                    .load_sdk_config(
1350                        &storage_configuration.connection_context,
1351                        aws.connection_id,
1352                        in_task,
1353                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1354                    )
1355                    .await?,
1356            ),
1357        };
1358
1359        // TODO(roshan): Implement enforcement of external address validation once
1360        // rdkafka client has been updated to support providing multiple resolved
1361        // addresses for brokers
1362        let mut context = TunnelingClientContext::new(
1363            context,
1364            Handle::current(),
1365            storage_configuration
1366                .connection_context
1367                .ssh_tunnel_manager
1368                .clone(),
1369            storage_configuration.parameters.ssh_timeout_config,
1370            aws_config,
1371            in_task,
1372        );
1373
1374        match &self.default_tunnel {
1375            Tunnel::Direct => {
1376                // By default, don't offer a default override for broker address lookup.
1377            }
1378            Tunnel::AwsPrivatelink(pl) => {
1379                context.set_default_tunnel(TunnelConfig::StaticHost(
1380                    // Possible bug: We have been ignoring the configured port.
1381                    KafkaConnection::from_default_aws_privatelink(pl).host,
1382                ));
1383            }
1384            Tunnel::AwsPrivatelinks(pl) => {
1385                context.set_default_tunnel(TunnelConfig::Rules(
1386                    KafkaConnection::from_aws_privatelinks(pl),
1387                ));
1388            }
1389            Tunnel::Ssh(ssh_tunnel) => {
1390                let secret = storage_configuration
1391                    .connection_context
1392                    .secrets_reader
1393                    .read_in_task_if(in_task, ssh_tunnel.connection_id)
1394                    .await?;
1395                let key_pair = SshKeyPair::from_bytes(&secret)?;
1396
1397                // Ensure any ssh-bastion address we connect to is resolved to an external address.
1398                let resolved = resolve_address(
1399                    &ssh_tunnel.connection.host,
1400                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1401                )
1402                .await?;
1403                context.set_default_tunnel(TunnelConfig::Ssh(SshTunnelConfig {
1404                    host: resolved
1405                        .iter()
1406                        .map(|a| a.to_string())
1407                        .collect::<BTreeSet<_>>(),
1408                    port: ssh_tunnel.connection.port,
1409                    user: ssh_tunnel.connection.user.clone(),
1410                    key_pair,
1411                }));
1412            }
1413        }
1414        info!(
1415            "kafka: tunnel config set to {}",
1416            match &self.default_tunnel {
1417                Tunnel::Direct => "Direct".to_string(),
1418                Tunnel::AwsPrivatelink(_) => "AwsPrivatelink (static host)".to_string(),
1419                Tunnel::AwsPrivatelinks(pl) =>
1420                    format!("AwsPrivatelinks ({} rules)", pl.rules.len()),
1421                Tunnel::Ssh(_) => "Ssh".to_string(),
1422            }
1423        );
1424
1425        // Here, we preemptively rewrite broker addresses.
1426        // In concept, this overlaps with 'TunnelingClientContext::resolve_broker_addr'.
1427        for broker in &self.brokers {
1428            let mut addr_parts = broker.address.splitn(2, ':');
1429            let addr = BrokerAddr {
1430                host: addr_parts
1431                    .next()
1432                    .context("BROKER is not address:port")?
1433                    .into(),
1434                port: addr_parts
1435                    .next()
1436                    .unwrap_or("9092")
1437                    .parse()
1438                    .context("parsing BROKER port")?,
1439            };
1440            match &broker.tunnel {
1441                Tunnel::Direct => {
1442                    // By default, don't override broker address lookup.
1443                    //
1444                    // N.B.
1445                    //
1446                    // We _could_ pre-setup the default ssh tunnel for all known brokers here, but
1447                    // we avoid doing because:
1448                    // - Its not necessary.
1449                    // - Not doing so makes it easier to test the `FailedDefaultSshTunnel` path
1450                    // in the `TunnelingClientContext`.
1451                }
1452                Tunnel::AwsPrivatelink(aws_privatelink) => {
1453                    context.add_broker_rewrite(
1454                        addr,
1455                        KafkaConnection::from_aws_privatelink(aws_privatelink),
1456                    );
1457                }
1458                Tunnel::AwsPrivatelinks(_) => unreachable!(
1459                    "Individually predefined brokers do not use rule-based PrivateLinks routing."
1460                ),
1461                Tunnel::Ssh(ssh_tunnel) => {
1462                    // Ensure any SSH bastion address we connect to is resolved to an external address.
1463                    let ssh_host_resolved = resolve_address(
1464                        &ssh_tunnel.connection.host,
1465                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1466                    )
1467                    .await?;
1468                    context
1469                        .add_ssh_tunnel(
1470                            addr,
1471                            SshTunnelConfig {
1472                                host: ssh_host_resolved
1473                                    .iter()
1474                                    .map(|a| a.to_string())
1475                                    .collect::<BTreeSet<_>>(),
1476                                port: ssh_tunnel.connection.port,
1477                                user: ssh_tunnel.connection.user.clone(),
1478                                key_pair: SshKeyPair::from_bytes(
1479                                    &storage_configuration
1480                                        .connection_context
1481                                        .secrets_reader
1482                                        .read_in_task_if(in_task, ssh_tunnel.connection_id)
1483                                        .await?,
1484                                )?,
1485                            },
1486                        )
1487                        .await
1488                        .map_err(ContextCreationError::Ssh)?;
1489                }
1490            }
1491        }
1492
1493        Ok(config.create_with_context(context)?)
1494    }
1495
1496    async fn validate(
1497        &self,
1498        _id: CatalogItemId,
1499        storage_configuration: &StorageConfiguration,
1500    ) -> Result<(), anyhow::Error> {
1501        let (context, error_rx) = MzClientContext::with_errors();
1502        let consumer: BaseConsumer<_> = self
1503            .create_with_context(
1504                storage_configuration,
1505                context,
1506                &BTreeMap::new(),
1507                // We are in a normal tokio context during validation, already.
1508                InTask::No,
1509            )
1510            .await?;
1511        let consumer = Arc::new(consumer);
1512
1513        let timeout = storage_configuration
1514            .parameters
1515            .kafka_timeout_config
1516            .fetch_metadata_timeout;
1517
1518        // librdkafka doesn't expose an API for determining whether a connection to
1519        // the Kafka cluster has been successfully established. So we make a
1520        // metadata request, though we don't care about the results, so that we can
1521        // report any errors making that request. If the request succeeds, we know
1522        // we were able to contact at least one broker, and that's a good proxy for
1523        // being able to contact all the brokers in the cluster.
1524        //
1525        // The downside of this approach is it produces a generic error message like
1526        // "metadata fetch error" with no additional details. The real networking
1527        // error is buried in the librdkafka logs, which are not visible to users.
1528        info!("kafka: starting connection validation via fetch_metadata (timeout={timeout:?})");
1529        let result = mz_ore::task::spawn_blocking(|| "kafka_get_metadata", {
1530            let consumer = Arc::clone(&consumer);
1531            move || consumer.fetch_metadata(None, timeout)
1532        })
1533        .await;
1534        info!(
1535            "kafka: connection validation result: {}",
1536            if result.is_ok() { "success" } else { "failed" },
1537        );
1538        match result {
1539            Ok(_) => Ok(()),
1540            // The error returned by `fetch_metadata` does not provide any details which makes for
1541            // a crappy user facing error message. For this reason we attempt to grab a better
1542            // error message from the client context, which should contain any error logs emitted
1543            // by librdkafka, and fallback to the generic error if there is nothing there.
1544            Err(err) => {
1545                // Multiple errors might have been logged during this validation but some are more
1546                // relevant than others. Specifically, we prefer non-internal errors over internal
1547                // errors since those give much more useful information to the users.
1548                let main_err = error_rx.try_iter().reduce(|cur, new| match cur {
1549                    MzKafkaError::Internal(_) => new,
1550                    _ => cur,
1551                });
1552
1553                // Don't drop the consumer until after we've drained the errors
1554                // channel. Dropping the consumer can introduce spurious errors.
1555                // See database-issues#7432.
1556                drop(consumer);
1557
1558                match main_err {
1559                    Some(err) => Err(err.into()),
1560                    None => Err(err.into()),
1561                }
1562            }
1563        }
1564    }
1565
1566    /// The "default" PrivateLink connection is used for bootstrapping Kafka.
1567    fn from_default_aws_privatelink(pl: &AwsPrivatelink) -> BrokerRewrite {
1568        BrokerRewrite {
1569            host: vpc_endpoint_host(
1570                pl.connection_id,
1571                None, // Default tunnel does not support availability zones.
1572            ),
1573            port: pl.port,
1574        }
1575    }
1576
1577    /// The "not default" PrivateLink connections are used for routing to specific Kafka brokers.
1578    fn from_aws_privatelink(pl: &AwsPrivatelink) -> BrokerRewrite {
1579        BrokerRewrite {
1580            host: vpc_endpoint_host(pl.connection_id, pl.availability_zone.as_deref()),
1581            port: pl.port,
1582        }
1583    }
1584
1585    fn from_aws_privatelink_rule(
1586        AwsPrivatelinkRule { pattern, to }: &AwsPrivatelinkRule,
1587    ) -> (mz_kafka_util::client::ConnectionRulePattern, BrokerRewrite) {
1588        (
1589            mz_kafka_util::client::ConnectionRulePattern {
1590                prefix_wildcard: pattern.prefix_wildcard,
1591                literal_match: pattern.literal_match.clone(),
1592                suffix_wildcard: pattern.suffix_wildcard,
1593            },
1594            KafkaConnection::from_aws_privatelink(to),
1595        )
1596    }
1597
1598    fn from_aws_privatelinks(pl: &AwsPrivatelinks) -> HostMappingRules {
1599        HostMappingRules {
1600            rules: pl
1601                .rules
1602                .iter()
1603                .map(KafkaConnection::from_aws_privatelink_rule)
1604                .collect_vec(),
1605        }
1606    }
1607}
1608
1609impl<C: ConnectionAccess> AlterCompatible for KafkaConnection<C> {
1610    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
1611        let KafkaConnection {
1612            brokers: _,
1613            default_tunnel: _,
1614            progress_topic,
1615            progress_topic_options,
1616            options: _,
1617            tls: _,
1618            sasl: _,
1619        } = self;
1620
1621        let compatibility_checks = [
1622            (progress_topic == &other.progress_topic, "progress_topic"),
1623            (
1624                progress_topic_options == &other.progress_topic_options,
1625                "progress_topic_options",
1626            ),
1627        ];
1628
1629        for (compatible, field) in compatibility_checks {
1630            if !compatible {
1631                tracing::warn!(
1632                    "KafkaConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
1633                    self,
1634                    other
1635                );
1636
1637                return Err(AlterError { id });
1638            }
1639        }
1640
1641        Ok(())
1642    }
1643}
1644
1645/// A connection to a Confluent Schema Registry.
1646#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1647pub struct CsrConnection<C: ConnectionAccess = InlinedConnection> {
1648    /// The URL of the schema registry.
1649    pub url: Url,
1650    /// Trusted root TLS certificate in PEM format.
1651    pub tls_root_cert: Option<StringOrSecret>,
1652    /// An optional TLS client certificate for authentication with the schema
1653    /// registry.
1654    pub tls_identity: Option<TlsIdentity>,
1655    /// Optional HTTP authentication credentials for the schema registry.
1656    pub http_auth: Option<CsrConnectionHttpAuth>,
1657    /// A tunnel through which to route traffic.
1658    pub tunnel: Tunnel<C>,
1659}
1660
1661impl<R: ConnectionResolver> IntoInlineConnection<CsrConnection, R>
1662    for CsrConnection<ReferencedConnection>
1663{
1664    fn into_inline_connection(self, r: R) -> CsrConnection {
1665        let CsrConnection {
1666            url,
1667            tls_root_cert,
1668            tls_identity,
1669            http_auth,
1670            tunnel,
1671        } = self;
1672        CsrConnection {
1673            url,
1674            tls_root_cert,
1675            tls_identity,
1676            http_auth,
1677            tunnel: tunnel.into_inline_connection(r),
1678        }
1679    }
1680}
1681
1682impl<C: ConnectionAccess> CsrConnection<C> {
1683    fn validate_by_default(&self) -> bool {
1684        true
1685    }
1686}
1687
1688impl CsrConnection {
1689    /// Constructs a schema registry client from the connection.
1690    pub async fn connect(
1691        &self,
1692        storage_configuration: &StorageConfiguration,
1693        in_task: InTask,
1694    ) -> Result<mz_ccsr::Client, CsrConnectError> {
1695        let mut client_config = mz_ccsr::ClientConfig::new(self.url.clone());
1696        if let Some(root_cert) = &self.tls_root_cert {
1697            let root_cert = root_cert
1698                .get_string(
1699                    in_task,
1700                    &storage_configuration.connection_context.secrets_reader,
1701                )
1702                .await?;
1703            let root_cert = Certificate::from_pem(root_cert.as_bytes())?;
1704            client_config = client_config.add_root_certificate(root_cert);
1705        }
1706
1707        if let Some(tls_identity) = &self.tls_identity {
1708            let key = &storage_configuration
1709                .connection_context
1710                .secrets_reader
1711                .read_string_in_task_if(in_task, tls_identity.key)
1712                .await?;
1713            let cert = tls_identity
1714                .cert
1715                .get_string(
1716                    in_task,
1717                    &storage_configuration.connection_context.secrets_reader,
1718                )
1719                .await?;
1720            let ident = Identity::from_pem(key.as_bytes(), cert.as_bytes())?;
1721            client_config = client_config.identity(ident);
1722        }
1723
1724        if let Some(http_auth) = &self.http_auth {
1725            let username = http_auth
1726                .username
1727                .get_string(
1728                    in_task,
1729                    &storage_configuration.connection_context.secrets_reader,
1730                )
1731                .await?;
1732            let password = match http_auth.password {
1733                None => None,
1734                Some(password) => Some(
1735                    storage_configuration
1736                        .connection_context
1737                        .secrets_reader
1738                        .read_string_in_task_if(in_task, password)
1739                        .await?,
1740                ),
1741            };
1742            client_config = client_config.auth(username, password);
1743        }
1744
1745        // TODO: use types to enforce that the URL has a string hostname.
1746        let host = self
1747            .url
1748            .host_str()
1749            .ok_or_else(|| anyhow!("url missing host"))?;
1750        match &self.tunnel {
1751            Tunnel::Direct => {
1752                // Ensure any host we connect to is resolved to an external address.
1753                let resolved = resolve_address(
1754                    host,
1755                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1756                )
1757                .await?;
1758                client_config = client_config.resolve_to_addrs(
1759                    host,
1760                    &resolved
1761                        .iter()
1762                        .map(|addr| SocketAddr::new(*addr, 0))
1763                        .collect::<Vec<_>>(),
1764                )
1765            }
1766            Tunnel::Ssh(ssh_tunnel) => {
1767                let ssh_tunnel = ssh_tunnel
1768                    .connect(
1769                        storage_configuration,
1770                        host,
1771                        // Honor the URL scheme's default port (443 for https,
1772                        // 80 for http) if no explicit port was provided.
1773                        self.url.port_or_known_default().unwrap_or(80),
1774                        in_task,
1775                    )
1776                    .await
1777                    .map_err(CsrConnectError::Ssh)?;
1778
1779                // Carefully inject the SSH tunnel into the client
1780                // configuration. This is delicate because we need TLS
1781                // verification to continue to use the remote hostname rather
1782                // than the tunnel hostname.
1783
1784                client_config = client_config
1785                    // `resolve_to_addrs` allows us to rewrite the hostname
1786                    // at the DNS level, which means the TCP connection is
1787                    // correctly routed through the tunnel, but TLS verification
1788                    // is still performed against the remote hostname.
1789                    // Unfortunately the port here is ignored if the URL also
1790                    // specifies a port...
1791                    .resolve_to_addrs(host, &[SocketAddr::new(ssh_tunnel.local_addr().ip(), 0)])
1792                    // ...so we also dynamically rewrite the URL to use the
1793                    // current port for the SSH tunnel.
1794                    //
1795                    // WARNING: this is brittle, because we only dynamically
1796                    // update the client configuration with the tunnel *port*,
1797                    // and not the hostname This works fine in practice, because
1798                    // only the SSH tunnel port will change if the tunnel fails
1799                    // and has to be restarted (the hostname is always
1800                    // 127.0.0.1)--but this is an an implementation detail of
1801                    // the SSH tunnel code that we're relying on.
1802                    .dynamic_url({
1803                        let remote_url = self.url.clone();
1804                        move || {
1805                            let mut url = remote_url.clone();
1806                            url.set_port(Some(ssh_tunnel.local_addr().port()))
1807                                .expect("cannot fail");
1808                            url
1809                        }
1810                    });
1811            }
1812            Tunnel::AwsPrivatelink(connection) => {
1813                assert_none!(connection.port);
1814
1815                let privatelink_host = mz_cloud_resources::vpc_endpoint_host(
1816                    connection.connection_id,
1817                    connection.availability_zone.as_deref(),
1818                );
1819                let addrs: Vec<_> = net::lookup_host((privatelink_host, 0))
1820                    .await
1821                    .context("resolving PrivateLink host")?
1822                    .collect();
1823                client_config = client_config.resolve_to_addrs(host, &addrs)
1824            }
1825            Tunnel::AwsPrivatelinks(_) => {
1826                unreachable!("MATCHING broker rules are only available for Kafka connections.");
1827            }
1828        }
1829
1830        Ok(client_config.build()?)
1831    }
1832
1833    async fn validate(
1834        &self,
1835        _id: CatalogItemId,
1836        storage_configuration: &StorageConfiguration,
1837    ) -> Result<(), anyhow::Error> {
1838        let client = self
1839            .connect(
1840                storage_configuration,
1841                // We are in a normal tokio context during validation, already.
1842                InTask::No,
1843            )
1844            .await?;
1845        client.list_subjects().await?;
1846        Ok(())
1847    }
1848}
1849
1850impl<C: ConnectionAccess> AlterCompatible for CsrConnection<C> {
1851    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
1852        let CsrConnection {
1853            tunnel,
1854            // All non-tunnel fields may change
1855            url: _,
1856            tls_root_cert: _,
1857            tls_identity: _,
1858            http_auth: _,
1859        } = self;
1860
1861        let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
1862
1863        for (compatible, field) in compatibility_checks {
1864            if !compatible {
1865                tracing::warn!(
1866                    "CsrConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
1867                    self,
1868                    other
1869                );
1870
1871                return Err(AlterError { id });
1872            }
1873        }
1874        Ok(())
1875    }
1876}
1877
1878/// A connection to an AWS Glue Schema Registry.
1879///
1880/// AWS credentials, region, and endpoint are inherited from the referenced
1881/// [`AwsConnection`]; this struct only carries the per-registry settings.
1882#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1883pub struct GlueSchemaRegistryConnection<C: ConnectionAccess = InlinedConnection> {
1884    /// The referenced AWS connection that supplies credentials, region, and
1885    /// (optional) endpoint.
1886    pub aws_connection: AwsConnectionReference<C>,
1887    /// The Glue Schema Registry name within the AWS account/region.
1888    pub registry_name: String,
1889}
1890
1891impl<R: ConnectionResolver> IntoInlineConnection<GlueSchemaRegistryConnection, R>
1892    for GlueSchemaRegistryConnection<ReferencedConnection>
1893{
1894    fn into_inline_connection(self, r: R) -> GlueSchemaRegistryConnection {
1895        let GlueSchemaRegistryConnection {
1896            aws_connection,
1897            registry_name,
1898        } = self;
1899        GlueSchemaRegistryConnection {
1900            aws_connection: aws_connection.into_inline_connection(&r),
1901            registry_name,
1902        }
1903    }
1904}
1905
1906impl<C: ConnectionAccess> GlueSchemaRegistryConnection<C> {
1907    fn validate_by_default(&self) -> bool {
1908        // Matches CSR: default-validate so a bad registry name fails at
1909        // `CREATE CONNECTION` rather than surfacing later on first use.
1910        // Users can still opt out with `WITH (VALIDATE = false)`.
1911        true
1912    }
1913}
1914
1915impl GlueSchemaRegistryConnection {
1916    async fn validate(
1917        &self,
1918        _id: CatalogItemId,
1919        storage_configuration: &StorageConfiguration,
1920    ) -> Result<(), anyhow::Error> {
1921        let enforce_external_addresses =
1922            crate::dyncfgs::ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set());
1923        let sdk_config = self
1924            .aws_connection
1925            .connection
1926            .load_sdk_config(
1927                &storage_configuration.connection_context,
1928                self.aws_connection.connection_id,
1929                // We are in a normal tokio context during validation.
1930                InTask::No,
1931                enforce_external_addresses,
1932            )
1933            .await?;
1934        let client = mz_aws_glue_schema_registry::ClientConfig::new(sdk_config).build();
1935        match client.get_registry(&self.registry_name).await {
1936            Ok(_) => Ok(()),
1937            Err(mz_aws_glue_schema_registry::GetRegistryError::NotFound) => Err(anyhow!(
1938                "AWS Glue Schema Registry {:?} does not exist in the configured account/region",
1939                self.registry_name
1940            )),
1941            Err(err) => Err(anyhow::Error::new(err).context(format!(
1942                "failed to validate AWS Glue Schema Registry connection (registry={:?})",
1943                self.registry_name
1944            ))),
1945        }
1946    }
1947}
1948
1949impl<C: ConnectionAccess> AlterCompatible for GlueSchemaRegistryConnection<C> {
1950    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
1951        let GlueSchemaRegistryConnection {
1952            registry_name,
1953            // The referenced AWS connection itself may be swapped; matches
1954            // the permissive policy of MySqlConnection / SqlServerConnection.
1955            aws_connection: _,
1956        } = self;
1957
1958        let compatibility_checks = [(registry_name == &other.registry_name, "registry_name")];
1959
1960        for (compatible, field) in compatibility_checks {
1961            if !compatible {
1962                tracing::warn!(
1963                    "GlueSchemaRegistryConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
1964                    self,
1965                    other
1966                );
1967
1968                return Err(AlterError { id });
1969            }
1970        }
1971        Ok(())
1972    }
1973}
1974
1975/// A TLS key pair used for client identity.
1976#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1977pub struct TlsIdentity {
1978    /// The client's TLS public certificate in PEM format.
1979    pub cert: StringOrSecret,
1980    /// The ID of the secret containing the client's TLS private key in PEM
1981    /// format.
1982    pub key: CatalogItemId,
1983}
1984
1985/// HTTP authentication credentials in a [`CsrConnection`].
1986#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1987pub struct CsrConnectionHttpAuth {
1988    /// The username.
1989    pub username: StringOrSecret,
1990    /// The ID of the secret containing the password, if any.
1991    pub password: Option<CatalogItemId>,
1992}
1993
1994/// A connection to a PostgreSQL server.
1995#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1996pub struct PostgresConnection<C: ConnectionAccess = InlinedConnection> {
1997    /// The hostname of the server.
1998    pub host: String,
1999    /// The port of the server.
2000    pub port: u16,
2001    /// The name of the database to connect to.
2002    pub database: String,
2003    /// The username to authenticate as.
2004    pub user: StringOrSecret,
2005    /// An optional password for authentication.
2006    pub password: Option<CatalogItemId>,
2007    /// A tunnel through which to route traffic.
2008    pub tunnel: Tunnel<C>,
2009    /// Whether to use TLS for encryption, authentication, or both.
2010    pub tls_mode: SslMode,
2011    /// An optional root TLS certificate in PEM format, to verify the server's
2012    /// identity.
2013    pub tls_root_cert: Option<StringOrSecret>,
2014    /// An optional TLS client certificate for authentication.
2015    pub tls_identity: Option<TlsIdentity>,
2016}
2017
2018impl<R: ConnectionResolver> IntoInlineConnection<PostgresConnection, R>
2019    for PostgresConnection<ReferencedConnection>
2020{
2021    fn into_inline_connection(self, r: R) -> PostgresConnection {
2022        let PostgresConnection {
2023            host,
2024            port,
2025            database,
2026            user,
2027            password,
2028            tunnel,
2029            tls_mode,
2030            tls_root_cert,
2031            tls_identity,
2032        } = self;
2033
2034        PostgresConnection {
2035            host,
2036            port,
2037            database,
2038            user,
2039            password,
2040            tunnel: tunnel.into_inline_connection(r),
2041            tls_mode,
2042            tls_root_cert,
2043            tls_identity,
2044        }
2045    }
2046}
2047
2048impl<C: ConnectionAccess> PostgresConnection<C> {
2049    fn validate_by_default(&self) -> bool {
2050        true
2051    }
2052}
2053
2054impl PostgresConnection<InlinedConnection> {
2055    pub async fn config(
2056        &self,
2057        secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2058        storage_configuration: &StorageConfiguration,
2059        in_task: InTask,
2060    ) -> Result<mz_postgres_util::Config, anyhow::Error> {
2061        let params = &storage_configuration.parameters;
2062
2063        let mut config = tokio_postgres::Config::new();
2064        config
2065            .host(&self.host)
2066            .port(self.port)
2067            .dbname(&self.database)
2068            .user(&self.user.get_string(in_task, secrets_reader).await?)
2069            .ssl_mode(self.tls_mode);
2070        if let Some(password) = self.password {
2071            let password = secrets_reader
2072                .read_string_in_task_if(in_task, password)
2073                .await?;
2074            config.password(password);
2075        }
2076        if let Some(tls_root_cert) = &self.tls_root_cert {
2077            let tls_root_cert = tls_root_cert.get_string(in_task, secrets_reader).await?;
2078            config.ssl_root_cert(tls_root_cert.as_bytes());
2079        }
2080        if let Some(tls_identity) = &self.tls_identity {
2081            let cert = tls_identity
2082                .cert
2083                .get_string(in_task, secrets_reader)
2084                .await?;
2085            let key = secrets_reader
2086                .read_string_in_task_if(in_task, tls_identity.key)
2087                .await?;
2088            config.ssl_cert(cert.as_bytes()).ssl_key(key.as_bytes());
2089        }
2090
2091        if let Some(connect_timeout) = params.pg_source_connect_timeout {
2092            config.connect_timeout(connect_timeout);
2093        }
2094        if let Some(keepalives_retries) = params.pg_source_tcp_keepalives_retries {
2095            config.keepalives_retries(keepalives_retries);
2096        }
2097        if let Some(keepalives_idle) = params.pg_source_tcp_keepalives_idle {
2098            config.keepalives_idle(keepalives_idle);
2099        }
2100        if let Some(keepalives_interval) = params.pg_source_tcp_keepalives_interval {
2101            config.keepalives_interval(keepalives_interval);
2102        }
2103        if let Some(tcp_user_timeout) = params.pg_source_tcp_user_timeout {
2104            config.tcp_user_timeout(tcp_user_timeout);
2105        }
2106
2107        let mut options = vec![];
2108        if let Some(wal_sender_timeout) = params.pg_source_wal_sender_timeout {
2109            options.push(format!(
2110                "--wal_sender_timeout={}",
2111                wal_sender_timeout.as_millis()
2112            ));
2113        };
2114        if params.pg_source_tcp_configure_server {
2115            if let Some(keepalives_retries) = params.pg_source_tcp_keepalives_retries {
2116                options.push(format!("--tcp_keepalives_count={}", keepalives_retries));
2117            }
2118            if let Some(keepalives_idle) = params.pg_source_tcp_keepalives_idle {
2119                options.push(format!(
2120                    "--tcp_keepalives_idle={}",
2121                    keepalives_idle.as_secs()
2122                ));
2123            }
2124            if let Some(keepalives_interval) = params.pg_source_tcp_keepalives_interval {
2125                options.push(format!(
2126                    "--tcp_keepalives_interval={}",
2127                    keepalives_interval.as_secs()
2128                ));
2129            }
2130            if let Some(tcp_user_timeout) = params.pg_source_tcp_user_timeout {
2131                options.push(format!(
2132                    "--tcp_user_timeout={}",
2133                    tcp_user_timeout.as_millis()
2134                ));
2135            }
2136        }
2137        config.options(options.join(" ").as_str());
2138
2139        let tunnel = match &self.tunnel {
2140            Tunnel::Direct => {
2141                // Ensure any host we connect to is resolved to an external address.
2142                let resolved = resolve_address(
2143                    &self.host,
2144                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2145                )
2146                .await?;
2147                mz_postgres_util::TunnelConfig::Direct {
2148                    resolved_ips: Some(resolved),
2149                }
2150            }
2151            Tunnel::Ssh(SshTunnel {
2152                connection_id,
2153                connection,
2154            }) => {
2155                let secret = secrets_reader
2156                    .read_in_task_if(in_task, *connection_id)
2157                    .await?;
2158                let key_pair = SshKeyPair::from_bytes(&secret)?;
2159                // Ensure any ssh-bastion host we connect to is resolved to an external address.
2160                let resolved = resolve_address(
2161                    &connection.host,
2162                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2163                )
2164                .await?;
2165                mz_postgres_util::TunnelConfig::Ssh {
2166                    config: SshTunnelConfig {
2167                        host: resolved
2168                            .iter()
2169                            .map(|a| a.to_string())
2170                            .collect::<BTreeSet<_>>(),
2171                        port: connection.port,
2172                        user: connection.user.clone(),
2173                        key_pair,
2174                    },
2175                }
2176            }
2177            Tunnel::AwsPrivatelink(connection) => {
2178                assert_none!(connection.port);
2179                mz_postgres_util::TunnelConfig::AwsPrivatelink {
2180                    connection_id: connection.connection_id,
2181                }
2182            }
2183            Tunnel::AwsPrivatelinks(_) => {
2184                unreachable!("MATCHING broker rules are only available for Kafka connections.");
2185            }
2186        };
2187
2188        Ok(mz_postgres_util::Config::new(
2189            config,
2190            tunnel,
2191            params.ssh_timeout_config,
2192            in_task,
2193        )?)
2194    }
2195
2196    pub async fn validate(
2197        &self,
2198        _id: CatalogItemId,
2199        storage_configuration: &StorageConfiguration,
2200    ) -> Result<mz_postgres_util::Client, anyhow::Error> {
2201        let config = self
2202            .config(
2203                &storage_configuration.connection_context.secrets_reader,
2204                storage_configuration,
2205                // We are in a normal tokio context during validation, already.
2206                InTask::No,
2207            )
2208            .await?;
2209        let client = config
2210            .connect(
2211                "connection validation",
2212                &storage_configuration.connection_context.ssh_tunnel_manager,
2213            )
2214            .await?;
2215
2216        let wal_level = mz_postgres_util::get_wal_level(&client).await?;
2217
2218        if wal_level < mz_postgres_util::replication::WalLevel::Logical {
2219            Err(PostgresConnectionValidationError::InsufficientWalLevel { wal_level })?;
2220        }
2221
2222        let max_wal_senders = mz_postgres_util::get_max_wal_senders(&client).await?;
2223
2224        if max_wal_senders < 1 {
2225            Err(PostgresConnectionValidationError::ReplicationDisabled)?;
2226        }
2227
2228        let available_replication_slots =
2229            mz_postgres_util::available_replication_slots(&client).await?;
2230
2231        // We need 1 replication slot for the snapshots and 1 for the continuing replication
2232        if available_replication_slots < 2 {
2233            Err(
2234                PostgresConnectionValidationError::InsufficientReplicationSlotsAvailable {
2235                    count: 2,
2236                },
2237            )?;
2238        }
2239
2240        Ok(client)
2241    }
2242}
2243
2244#[derive(Debug, Clone, thiserror::Error)]
2245pub enum PostgresConnectionValidationError {
2246    #[error("PostgreSQL server has insufficient number of replication slots available")]
2247    InsufficientReplicationSlotsAvailable { count: usize },
2248    #[error("server must have wal_level >= logical, but has {wal_level}")]
2249    InsufficientWalLevel {
2250        wal_level: mz_postgres_util::replication::WalLevel,
2251    },
2252    #[error("replication disabled on server")]
2253    ReplicationDisabled,
2254}
2255
2256impl PostgresConnectionValidationError {
2257    pub fn detail(&self) -> Option<String> {
2258        match self {
2259            Self::InsufficientReplicationSlotsAvailable { count } => Some(format!(
2260                "executing this statement requires {} replication slot{}",
2261                count,
2262                if *count == 1 { "" } else { "s" }
2263            )),
2264            _ => None,
2265        }
2266    }
2267
2268    pub fn hint(&self) -> Option<String> {
2269        match self {
2270            Self::InsufficientReplicationSlotsAvailable { .. } => Some(
2271                "you might be able to wait for other sources to finish snapshotting and try again"
2272                    .into(),
2273            ),
2274            Self::ReplicationDisabled => Some("set max_wal_senders to a value > 0".into()),
2275            Self::InsufficientWalLevel { .. } => None,
2276        }
2277    }
2278}
2279
2280impl<C: ConnectionAccess> AlterCompatible for PostgresConnection<C> {
2281    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2282        let PostgresConnection {
2283            tunnel,
2284            // All non-tunnel options may change arbitrarily
2285            host: _,
2286            port: _,
2287            database: _,
2288            user: _,
2289            password: _,
2290            tls_mode: _,
2291            tls_root_cert: _,
2292            tls_identity: _,
2293        } = self;
2294
2295        let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2296
2297        for (compatible, field) in compatibility_checks {
2298            if !compatible {
2299                tracing::warn!(
2300                    "PostgresConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2301                    self,
2302                    other
2303                );
2304
2305                return Err(AlterError { id });
2306            }
2307        }
2308        Ok(())
2309    }
2310}
2311
2312/// Specifies how to tunnel a connection.
2313#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2314pub enum Tunnel<C: ConnectionAccess = InlinedConnection> {
2315    /// No tunneling.
2316    Direct,
2317    /// Via the specified SSH tunnel connection.
2318    Ssh(SshTunnel<C>),
2319    /// Via the specified AWS PrivateLink connection.
2320    AwsPrivatelink(AwsPrivatelink),
2321    AwsPrivatelinks(AwsPrivatelinks),
2322}
2323
2324impl<R: ConnectionResolver> IntoInlineConnection<Tunnel, R> for Tunnel<ReferencedConnection> {
2325    fn into_inline_connection(self, r: R) -> Tunnel {
2326        match self {
2327            Tunnel::Direct => Tunnel::Direct,
2328            Tunnel::Ssh(ssh) => Tunnel::Ssh(ssh.into_inline_connection(r)),
2329            Tunnel::AwsPrivatelink(awspl) => Tunnel::AwsPrivatelink(awspl),
2330            Tunnel::AwsPrivatelinks(x) => Tunnel::AwsPrivatelinks(x),
2331        }
2332    }
2333}
2334
2335impl<C: ConnectionAccess> AlterCompatible for Tunnel<C> {
2336    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2337        let compatible = match (self, other) {
2338            (Self::Ssh(s), Self::Ssh(o)) => s.alter_compatible(id, o).is_ok(),
2339            (s, o) => s == o,
2340        };
2341
2342        if !compatible {
2343            tracing::warn!(
2344                "Tunnel incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
2345                self,
2346                other
2347            );
2348
2349            return Err(AlterError { id });
2350        }
2351
2352        Ok(())
2353    }
2354}
2355
2356/// Specifies which MySQL SSL Mode to use:
2357/// <https://dev.mysql.com/doc/refman/8.0/en/connection-options.html#option_general_ssl-mode>
2358/// This is not available as an enum in the mysql-async crate, so we define our own.
2359#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2360pub enum MySqlSslMode {
2361    Disabled,
2362    Required,
2363    VerifyCa,
2364    VerifyIdentity,
2365}
2366
2367/// A connection to a MySQL server.
2368#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2369pub struct MySqlConnection<C: ConnectionAccess = InlinedConnection> {
2370    /// The hostname of the server.
2371    pub host: String,
2372    /// The port of the server.
2373    pub port: u16,
2374    /// The username to authenticate as.
2375    pub user: StringOrSecret,
2376    /// An optional password for authentication.
2377    pub password: Option<CatalogItemId>,
2378    /// A tunnel through which to route traffic.
2379    pub tunnel: Tunnel<C>,
2380    /// Whether to use TLS for encryption, verify the server's certificate, and identity.
2381    pub tls_mode: MySqlSslMode,
2382    /// An optional root TLS certificate in PEM format, to verify the server's
2383    /// identity.
2384    pub tls_root_cert: Option<StringOrSecret>,
2385    /// An optional TLS client certificate for authentication.
2386    pub tls_identity: Option<TlsIdentity>,
2387    /// Reference to the AWS connection information to be used for IAM authenitcation and
2388    /// assuming AWS roles.
2389    pub aws_connection: Option<AwsConnectionReference<C>>,
2390}
2391
2392impl<R: ConnectionResolver> IntoInlineConnection<MySqlConnection, R>
2393    for MySqlConnection<ReferencedConnection>
2394{
2395    fn into_inline_connection(self, r: R) -> MySqlConnection {
2396        let MySqlConnection {
2397            host,
2398            port,
2399            user,
2400            password,
2401            tunnel,
2402            tls_mode,
2403            tls_root_cert,
2404            tls_identity,
2405            aws_connection,
2406        } = self;
2407
2408        MySqlConnection {
2409            host,
2410            port,
2411            user,
2412            password,
2413            tunnel: tunnel.into_inline_connection(&r),
2414            tls_mode,
2415            tls_root_cert,
2416            tls_identity,
2417            aws_connection: aws_connection.map(|aws| aws.into_inline_connection(&r)),
2418        }
2419    }
2420}
2421
2422impl<C: ConnectionAccess> MySqlConnection<C> {
2423    fn validate_by_default(&self) -> bool {
2424        true
2425    }
2426}
2427
2428impl MySqlConnection<InlinedConnection> {
2429    pub async fn config(
2430        &self,
2431        secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2432        storage_configuration: &StorageConfiguration,
2433        in_task: InTask,
2434    ) -> Result<mz_mysql_util::Config, anyhow::Error> {
2435        // TODO(roshan): Set appropriate connection timeouts
2436        let mut opts = mysql_async::OptsBuilder::default()
2437            .ip_or_hostname(&self.host)
2438            .tcp_port(self.port)
2439            .user(Some(&self.user.get_string(in_task, secrets_reader).await?));
2440
2441        if let Some(password) = self.password {
2442            let password = secrets_reader
2443                .read_string_in_task_if(in_task, password)
2444                .await?;
2445            opts = opts.pass(Some(password));
2446        }
2447
2448        // Our `MySqlSslMode` enum matches the official MySQL Client `--ssl-mode` parameter values
2449        // which uses opt-in security features (SSL, CA verification, & Identity verification).
2450        // The mysql_async crate `SslOpts` struct uses an opt-out mechanism for each of these, so
2451        // we need to appropriately disable features to match the intent of each enum value.
2452        let mut ssl_opts = match self.tls_mode {
2453            MySqlSslMode::Disabled => None,
2454            MySqlSslMode::Required => Some(
2455                mysql_async::SslOpts::default()
2456                    .with_danger_accept_invalid_certs(true)
2457                    .with_danger_skip_domain_validation(true),
2458            ),
2459            MySqlSslMode::VerifyCa => {
2460                Some(mysql_async::SslOpts::default().with_danger_skip_domain_validation(true))
2461            }
2462            MySqlSslMode::VerifyIdentity => Some(mysql_async::SslOpts::default()),
2463        };
2464
2465        if matches!(
2466            self.tls_mode,
2467            MySqlSslMode::VerifyCa | MySqlSslMode::VerifyIdentity
2468        ) {
2469            if let Some(tls_root_cert) = &self.tls_root_cert {
2470                let tls_root_cert = tls_root_cert.get_string(in_task, secrets_reader).await?;
2471                ssl_opts = ssl_opts.map(|opts| {
2472                    opts.with_root_certs(vec![tls_root_cert.as_bytes().to_vec().into()])
2473                });
2474            }
2475        }
2476
2477        if let Some(identity) = &self.tls_identity {
2478            let key = secrets_reader
2479                .read_string_in_task_if(in_task, identity.key)
2480                .await?;
2481            let cert = identity.cert.get_string(in_task, secrets_reader).await?;
2482            let (der, pass) =
2483                mz_tls_util::pkcs12der_from_pem(key.as_bytes(), cert.as_bytes())?.into_parts();
2484
2485            // Add client identity to SSLOpts
2486            ssl_opts = ssl_opts.map(|opts| {
2487                opts.with_client_identity(Some(
2488                    mysql_async::ClientIdentity::new(der.into()).with_password(pass),
2489                ))
2490            });
2491        }
2492
2493        opts = opts.ssl_opts(ssl_opts);
2494
2495        let tunnel = match &self.tunnel {
2496            Tunnel::Direct => {
2497                // Ensure any host we connect to is resolved to an external address.
2498                let resolved = resolve_address(
2499                    &self.host,
2500                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2501                )
2502                .await?;
2503                mz_mysql_util::TunnelConfig::Direct {
2504                    resolved_ips: Some(resolved),
2505                }
2506            }
2507            Tunnel::Ssh(SshTunnel {
2508                connection_id,
2509                connection,
2510            }) => {
2511                let secret = secrets_reader
2512                    .read_in_task_if(in_task, *connection_id)
2513                    .await?;
2514                let key_pair = SshKeyPair::from_bytes(&secret)?;
2515                // Ensure any ssh-bastion host we connect to is resolved to an external address.
2516                let resolved = resolve_address(
2517                    &connection.host,
2518                    ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2519                )
2520                .await?;
2521                mz_mysql_util::TunnelConfig::Ssh {
2522                    config: SshTunnelConfig {
2523                        host: resolved
2524                            .iter()
2525                            .map(|a| a.to_string())
2526                            .collect::<BTreeSet<_>>(),
2527                        port: connection.port,
2528                        user: connection.user.clone(),
2529                        key_pair,
2530                    },
2531                }
2532            }
2533            Tunnel::AwsPrivatelink(connection) => {
2534                assert_none!(connection.port);
2535                mz_mysql_util::TunnelConfig::AwsPrivatelink {
2536                    connection_id: connection.connection_id,
2537                }
2538            }
2539            Tunnel::AwsPrivatelinks(_) => {
2540                unreachable!("MATCHING broker rules are only available for Kafka connections.");
2541            }
2542        };
2543
2544        let aws_config = match self.aws_connection.as_ref() {
2545            None => None,
2546            Some(aws_ref) => Some(
2547                aws_ref
2548                    .connection
2549                    .load_sdk_config(
2550                        &storage_configuration.connection_context,
2551                        aws_ref.connection_id,
2552                        in_task,
2553                        ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2554                    )
2555                    .await?,
2556            ),
2557        };
2558
2559        Ok(mz_mysql_util::Config::new(
2560            opts,
2561            tunnel,
2562            storage_configuration.parameters.ssh_timeout_config,
2563            in_task,
2564            storage_configuration
2565                .parameters
2566                .mysql_source_timeouts
2567                .clone(),
2568            aws_config,
2569        )?)
2570    }
2571
2572    pub async fn validate(
2573        &self,
2574        _id: CatalogItemId,
2575        storage_configuration: &StorageConfiguration,
2576    ) -> Result<MySqlConn, MySqlConnectionValidationError> {
2577        let config = self
2578            .config(
2579                &storage_configuration.connection_context.secrets_reader,
2580                storage_configuration,
2581                // We are in a normal tokio context during validation, already.
2582                InTask::No,
2583            )
2584            .await?;
2585        let mut conn = config
2586            .connect(
2587                "connection validation",
2588                &storage_configuration.connection_context.ssh_tunnel_manager,
2589            )
2590            .await?;
2591
2592        // Check if the MySQL database is configured to allow row-based consistent GTID replication
2593        let mut setting_errors = vec![];
2594        let gtid_res = mz_mysql_util::ensure_gtid_consistency(&mut conn).await;
2595        let binlog_res = mz_mysql_util::ensure_full_row_binlog_format(&mut conn).await;
2596        let order_res = mz_mysql_util::ensure_replication_commit_order(&mut conn).await;
2597        for res in [gtid_res, binlog_res, order_res] {
2598            match res {
2599                Err(MySqlError::InvalidSystemSetting {
2600                    setting,
2601                    expected,
2602                    actual,
2603                }) => {
2604                    setting_errors.push((setting, expected, actual));
2605                }
2606                Err(err) => Err(err)?,
2607                Ok(()) => {}
2608            }
2609        }
2610        if !setting_errors.is_empty() {
2611            Err(MySqlConnectionValidationError::ReplicationSettingsError(
2612                setting_errors,
2613            ))?;
2614        }
2615
2616        Ok(conn)
2617    }
2618}
2619
2620#[derive(Debug, thiserror::Error)]
2621pub enum MySqlConnectionValidationError {
2622    #[error("Invalid MySQL system replication settings")]
2623    ReplicationSettingsError(Vec<(String, String, String)>),
2624    #[error(transparent)]
2625    Client(#[from] MySqlError),
2626    #[error("{}", .0.display_with_causes())]
2627    Other(#[from] anyhow::Error),
2628}
2629
2630impl MySqlConnectionValidationError {
2631    pub fn detail(&self) -> Option<String> {
2632        match self {
2633            Self::ReplicationSettingsError(settings) => Some(format!(
2634                "Invalid MySQL system replication settings: {}",
2635                itertools::join(
2636                    settings.iter().map(|(setting, expected, actual)| format!(
2637                        "{}: expected {}, got {}",
2638                        setting, expected, actual
2639                    )),
2640                    "; "
2641                )
2642            )),
2643            _ => None,
2644        }
2645    }
2646
2647    pub fn hint(&self) -> Option<String> {
2648        match self {
2649            Self::ReplicationSettingsError(_) => {
2650                Some("Set the necessary MySQL database system settings.".into())
2651            }
2652            _ => None,
2653        }
2654    }
2655}
2656
2657impl<C: ConnectionAccess> AlterCompatible for MySqlConnection<C> {
2658    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2659        let MySqlConnection {
2660            tunnel,
2661            // All non-tunnel options may change arbitrarily
2662            host: _,
2663            port: _,
2664            user: _,
2665            password: _,
2666            tls_mode: _,
2667            tls_root_cert: _,
2668            tls_identity: _,
2669            aws_connection: _,
2670        } = self;
2671
2672        let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2673
2674        for (compatible, field) in compatibility_checks {
2675            if !compatible {
2676                tracing::warn!(
2677                    "MySqlConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2678                    self,
2679                    other
2680                );
2681
2682                return Err(AlterError { id });
2683            }
2684        }
2685        Ok(())
2686    }
2687}
2688
2689/// Details how to connect to an instance of Microsoft SQL Server.
2690///
2691/// For specifics of connecting to SQL Server for purposes of creating a
2692/// Materialize Source, see [`SqlServerSourceConnection`] which wraps this type.
2693///
2694/// [`SqlServerSourceConnection`]: crate::sources::SqlServerSourceConnection
2695#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2696pub struct SqlServerConnectionDetails<C: ConnectionAccess = InlinedConnection> {
2697    /// The hostname of the server.
2698    pub host: String,
2699    /// The port of the server.
2700    pub port: u16,
2701    /// Database we should connect to.
2702    pub database: String,
2703    /// The username to authenticate as.
2704    pub user: StringOrSecret,
2705    /// Password used for authentication.
2706    pub password: CatalogItemId,
2707    /// A tunnel through which to route traffic.
2708    pub tunnel: Tunnel<C>,
2709    /// Level of encryption to use for the connection.
2710    pub encryption: mz_sql_server_util::config::EncryptionLevel,
2711    /// Certificate validation policy
2712    pub certificate_validation_policy: mz_sql_server_util::config::CertificateValidationPolicy,
2713    /// TLS CA Certifiecate in PEM format
2714    pub tls_root_cert: Option<StringOrSecret>,
2715}
2716
2717impl<C: ConnectionAccess> SqlServerConnectionDetails<C> {
2718    fn validate_by_default(&self) -> bool {
2719        true
2720    }
2721}
2722
2723impl SqlServerConnectionDetails<InlinedConnection> {
2724    /// Attempts to open a connection to the upstream SQL Server instance.
2725    pub async fn validate(
2726        &self,
2727        _id: CatalogItemId,
2728        storage_configuration: &StorageConfiguration,
2729    ) -> Result<mz_sql_server_util::Client, anyhow::Error> {
2730        let config = self
2731            .resolve_config(
2732                &storage_configuration.connection_context.secrets_reader,
2733                storage_configuration,
2734                InTask::No,
2735            )
2736            .await?;
2737        tracing::debug!(?config, "Validating SQL Server connection");
2738
2739        let mut client = mz_sql_server_util::Client::connect(config).await?;
2740
2741        // Ensure the upstream SQL Server instance is configured to allow CDC.
2742        //
2743        // Run all of the checks necessary and collect the errors to provide the best
2744        // guidance as to which system settings need to be enabled.
2745        let mut replication_errors = vec![];
2746        for error in [
2747            mz_sql_server_util::inspect::ensure_database_cdc_enabled(&mut client).await,
2748            mz_sql_server_util::inspect::ensure_snapshot_isolation_enabled(&mut client).await,
2749            mz_sql_server_util::inspect::ensure_sql_server_agent_running(&mut client).await,
2750        ] {
2751            match error {
2752                Err(mz_sql_server_util::SqlServerError::InvalidSystemSetting {
2753                    name,
2754                    expected,
2755                    actual,
2756                }) => replication_errors.push((name, expected, actual)),
2757                Err(other) => Err(other)?,
2758                Ok(()) => (),
2759            }
2760        }
2761        if !replication_errors.is_empty() {
2762            Err(SqlServerConnectionValidationError::ReplicationSettingsError(replication_errors))?;
2763        }
2764
2765        Ok(client)
2766    }
2767
2768    /// Resolve all of the connection details (e.g. read from the [`SecretsReader`])
2769    /// so the returned [`Config`] can be used to open a connection with the
2770    /// upstream system.
2771    ///
2772    /// The provided [`InTask`] argument determines whether any I/O is run in an
2773    /// [`mz_ore::task`] (i.e. a different thread) or directly in the returned
2774    /// future. The main goal here is to prevent running I/O in timely threads.
2775    ///
2776    /// [`Config`]: mz_sql_server_util::Config
2777    pub async fn resolve_config(
2778        &self,
2779        secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2780        storage_configuration: &StorageConfiguration,
2781        in_task: InTask,
2782    ) -> Result<mz_sql_server_util::Config, anyhow::Error> {
2783        let dyncfg = storage_configuration.config_set();
2784        let mut inner_config = tiberius::Config::new();
2785
2786        // Setup default connection params.
2787        inner_config.host(&self.host);
2788        inner_config.port(self.port);
2789        inner_config.database(self.database.clone());
2790        inner_config.encryption(self.encryption.into());
2791        match self.certificate_validation_policy {
2792            mz_sql_server_util::config::CertificateValidationPolicy::TrustAll => {
2793                inner_config.trust_cert()
2794            }
2795            mz_sql_server_util::config::CertificateValidationPolicy::VerifyCA => {
2796                inner_config.trust_cert_ca_pem(
2797                    self.tls_root_cert
2798                        .as_ref()
2799                        .unwrap()
2800                        .get_string(in_task, secrets_reader)
2801                        .await
2802                        .context("ca certificate")?,
2803                );
2804            }
2805            mz_sql_server_util::config::CertificateValidationPolicy::VerifySystem => (), // no-op
2806        }
2807
2808        inner_config.application_name("materialize");
2809
2810        // Read our auth settings from
2811        let user = self
2812            .user
2813            .get_string(in_task, secrets_reader)
2814            .await
2815            .context("username")?;
2816        let password = secrets_reader
2817            .read_string_in_task_if(in_task, self.password)
2818            .await
2819            .context("password")?;
2820        // TODO(sql_server3): Support other methods of authentication besides
2821        // username and password.
2822        inner_config.authentication(tiberius::AuthMethod::sql_server(user, password));
2823
2824        // Prevent users from probing our internal network ports by trying to
2825        // connect to localhost, or another non-external IP.
2826        let enforce_external_addresses = ENFORCE_EXTERNAL_ADDRESSES.get(dyncfg);
2827
2828        let tunnel = match &self.tunnel {
2829            Tunnel::Direct => {
2830                let resolved_addresses: Vec<SocketAddr> =
2831                    resolve_address(&self.host, enforce_external_addresses)
2832                        .await?
2833                        .into_iter()
2834                        .map(|ip| SocketAddr::new(ip, self.port))
2835                        .collect();
2836                mz_sql_server_util::config::TunnelConfig::Direct {
2837                    resolved_addresses: resolved_addresses.into_boxed_slice(),
2838                }
2839            }
2840            Tunnel::Ssh(SshTunnel {
2841                connection_id,
2842                connection: ssh_connection,
2843            }) => {
2844                let secret = secrets_reader
2845                    .read_in_task_if(in_task, *connection_id)
2846                    .await
2847                    .context("ssh secret")?;
2848                let key_pair = SshKeyPair::from_bytes(&secret).context("ssh key pair")?;
2849                // Ensure any SSH-bastion host we connect to is resolved to an
2850                // external address.
2851                let addresses = resolve_address(&ssh_connection.host, enforce_external_addresses)
2852                    .await
2853                    .context("ssh tunnel")?;
2854
2855                let config = SshTunnelConfig {
2856                    host: addresses.into_iter().map(|a| a.to_string()).collect(),
2857                    port: ssh_connection.port,
2858                    user: ssh_connection.user.clone(),
2859                    key_pair,
2860                };
2861                mz_sql_server_util::config::TunnelConfig::Ssh {
2862                    config,
2863                    manager: storage_configuration
2864                        .connection_context
2865                        .ssh_tunnel_manager
2866                        .clone(),
2867                    timeout: storage_configuration.parameters.ssh_timeout_config.clone(),
2868                    host: self.host.clone(),
2869                    port: self.port,
2870                }
2871            }
2872            Tunnel::AwsPrivatelink(private_link_connection) => {
2873                assert_none!(private_link_connection.port);
2874                mz_sql_server_util::config::TunnelConfig::AwsPrivatelink {
2875                    connection_id: private_link_connection.connection_id,
2876                    port: self.port,
2877                }
2878            }
2879            Tunnel::AwsPrivatelinks(_) => {
2880                unreachable!("MATCHING broker rules are only available for Kafka connections.");
2881            }
2882        };
2883
2884        Ok(mz_sql_server_util::Config::new(
2885            inner_config,
2886            tunnel,
2887            in_task,
2888        ))
2889    }
2890}
2891
2892#[derive(Debug, Clone, thiserror::Error)]
2893pub enum SqlServerConnectionValidationError {
2894    #[error("Invalid SQL Server system replication settings")]
2895    ReplicationSettingsError(Vec<(String, String, String)>),
2896}
2897
2898impl SqlServerConnectionValidationError {
2899    pub fn detail(&self) -> Option<String> {
2900        match self {
2901            Self::ReplicationSettingsError(settings) => Some(format!(
2902                "Invalid SQL Server system replication settings: {}",
2903                itertools::join(
2904                    settings.iter().map(|(setting, expected, actual)| format!(
2905                        "{}: expected {}, got {}",
2906                        setting, expected, actual
2907                    )),
2908                    "; "
2909                )
2910            )),
2911        }
2912    }
2913
2914    pub fn hint(&self) -> Option<String> {
2915        match self {
2916            _ => None,
2917        }
2918    }
2919}
2920
2921impl<R: ConnectionResolver> IntoInlineConnection<SqlServerConnectionDetails, R>
2922    for SqlServerConnectionDetails<ReferencedConnection>
2923{
2924    fn into_inline_connection(self, r: R) -> SqlServerConnectionDetails {
2925        let SqlServerConnectionDetails {
2926            host,
2927            port,
2928            database,
2929            user,
2930            password,
2931            tunnel,
2932            encryption,
2933            certificate_validation_policy,
2934            tls_root_cert,
2935        } = self;
2936
2937        SqlServerConnectionDetails {
2938            host,
2939            port,
2940            database,
2941            user,
2942            password,
2943            tunnel: tunnel.into_inline_connection(&r),
2944            encryption,
2945            certificate_validation_policy,
2946            tls_root_cert,
2947        }
2948    }
2949}
2950
2951impl<C: ConnectionAccess> AlterCompatible for SqlServerConnectionDetails<C> {
2952    fn alter_compatible(
2953        &self,
2954        id: mz_repr::GlobalId,
2955        other: &Self,
2956    ) -> Result<(), crate::controller::AlterError> {
2957        let SqlServerConnectionDetails {
2958            tunnel,
2959            // TODO(sql_server2): Figure out how these variables are allowed to change.
2960            host: _,
2961            port: _,
2962            database: _,
2963            user: _,
2964            password: _,
2965            encryption: _,
2966            certificate_validation_policy: _,
2967            tls_root_cert: _,
2968        } = self;
2969
2970        let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2971
2972        for (compatible, field) in compatibility_checks {
2973            if !compatible {
2974                tracing::warn!(
2975                    "SqlServerConnectionDetails incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2976                    self,
2977                    other
2978                );
2979
2980                return Err(AlterError { id });
2981            }
2982        }
2983        Ok(())
2984    }
2985}
2986
2987/// A connection to an SSH tunnel.
2988#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2989pub struct SshConnection {
2990    pub host: String,
2991    pub port: u16,
2992    pub user: String,
2993}
2994
2995use self::inline::{
2996    ConnectionAccess, ConnectionResolver, InlinedConnection, IntoInlineConnection,
2997    ReferencedConnection,
2998};
2999
3000impl AlterCompatible for SshConnection {
3001    fn alter_compatible(&self, _id: GlobalId, _other: &Self) -> Result<(), AlterError> {
3002        // Every element of the SSH connection is configurable.
3003        Ok(())
3004    }
3005}
3006
3007/// Specifies an AWS PrivateLink service for a [`Tunnel`].
3008#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3009pub struct AwsPrivatelink {
3010    /// The ID of the connection to the AWS PrivateLink service.
3011    pub connection_id: CatalogItemId,
3012    // The availability zone to use when connecting to the AWS PrivateLink service.
3013    pub availability_zone: Option<String>,
3014    /// The port to use when connecting to the AWS PrivateLink service, if
3015    /// different from the port in [`KafkaBroker::address`].
3016    pub port: Option<u16>,
3017}
3018
3019impl AlterCompatible for AwsPrivatelink {
3020    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3021        let AwsPrivatelink {
3022            connection_id,
3023            availability_zone: _,
3024            port: _,
3025        } = self;
3026
3027        let compatibility_checks = [(connection_id == &other.connection_id, "connection_id")];
3028
3029        for (compatible, field) in compatibility_checks {
3030            if !compatible {
3031                tracing::warn!(
3032                    "AwsPrivatelink incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3033                    self,
3034                    other
3035                );
3036
3037                return Err(AlterError { id });
3038            }
3039        }
3040
3041        Ok(())
3042    }
3043}
3044
3045#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3046pub struct AwsPrivatelinks {
3047    /// Route to brokers through PrivateLink connections according to these rules.
3048    /// Exact-match rules (no wildcards) are used as bootstrap brokers.
3049    /// Wildcard rules are applied dynamically to discovered brokers.
3050    pub rules: Vec<AwsPrivatelinkRule>,
3051}
3052
3053#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3054pub struct AwsPrivatelinkRule {
3055    /// Given a broker's host:port, should we use this route?
3056    pub pattern: ConnectionRulePattern,
3057    /// Route to the broker through this PrivateLink connection.
3058    pub to: AwsPrivatelink,
3059}
3060
3061/// Specifies an SSH tunnel connection.
3062#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3063pub struct SshTunnel<C: ConnectionAccess = InlinedConnection> {
3064    /// id of the ssh connection
3065    pub connection_id: CatalogItemId,
3066    /// ssh connection object
3067    pub connection: C::Ssh,
3068}
3069
3070impl<R: ConnectionResolver> IntoInlineConnection<SshTunnel, R> for SshTunnel<ReferencedConnection> {
3071    fn into_inline_connection(self, r: R) -> SshTunnel {
3072        let SshTunnel {
3073            connection,
3074            connection_id,
3075        } = self;
3076
3077        SshTunnel {
3078            connection: r.resolve_connection(connection).unwrap_ssh(),
3079            connection_id,
3080        }
3081    }
3082}
3083
3084impl SshTunnel<InlinedConnection> {
3085    /// Like [`SshTunnelConfig::connect`], but the SSH key is loaded from a
3086    /// secret.
3087    async fn connect(
3088        &self,
3089        storage_configuration: &StorageConfiguration,
3090        remote_host: &str,
3091        remote_port: u16,
3092        in_task: InTask,
3093    ) -> Result<ManagedSshTunnelHandle, anyhow::Error> {
3094        // Ensure any ssh-bastion host we connect to is resolved to an external address.
3095        let resolved = resolve_address(
3096            &self.connection.host,
3097            ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3098        )
3099        .await?;
3100        storage_configuration
3101            .connection_context
3102            .ssh_tunnel_manager
3103            .connect(
3104                SshTunnelConfig {
3105                    host: resolved
3106                        .iter()
3107                        .map(|a| a.to_string())
3108                        .collect::<BTreeSet<_>>(),
3109                    port: self.connection.port,
3110                    user: self.connection.user.clone(),
3111                    key_pair: SshKeyPair::from_bytes(
3112                        &storage_configuration
3113                            .connection_context
3114                            .secrets_reader
3115                            .read_in_task_if(in_task, self.connection_id)
3116                            .await?,
3117                    )?,
3118                },
3119                remote_host,
3120                remote_port,
3121                storage_configuration.parameters.ssh_timeout_config,
3122                in_task,
3123            )
3124            .await
3125    }
3126}
3127
3128impl<C: ConnectionAccess> AlterCompatible for SshTunnel<C> {
3129    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3130        let SshTunnel {
3131            connection_id,
3132            connection,
3133        } = self;
3134
3135        let compatibility_checks = [
3136            (connection_id == &other.connection_id, "connection_id"),
3137            (
3138                connection.alter_compatible(id, &other.connection).is_ok(),
3139                "connection",
3140            ),
3141        ];
3142
3143        for (compatible, field) in compatibility_checks {
3144            if !compatible {
3145                tracing::warn!(
3146                    "SshTunnel incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3147                    self,
3148                    other
3149                );
3150
3151                return Err(AlterError { id });
3152            }
3153        }
3154
3155        Ok(())
3156    }
3157}
3158
3159impl SshConnection {
3160    #[allow(clippy::unused_async)]
3161    async fn validate(
3162        &self,
3163        id: CatalogItemId,
3164        storage_configuration: &StorageConfiguration,
3165    ) -> Result<(), anyhow::Error> {
3166        let secret = storage_configuration
3167            .connection_context
3168            .secrets_reader
3169            .read_in_task_if(
3170                // We are in a normal tokio context during validation, already.
3171                InTask::No,
3172                id,
3173            )
3174            .await?;
3175        let key_pair = SshKeyPair::from_bytes(&secret)?;
3176
3177        // Ensure any ssh-bastion host we connect to is resolved to an external address.
3178        let resolved = resolve_address(
3179            &self.host,
3180            ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3181        )
3182        .await?;
3183
3184        let config = SshTunnelConfig {
3185            host: resolved
3186                .iter()
3187                .map(|a| a.to_string())
3188                .collect::<BTreeSet<_>>(),
3189            port: self.port,
3190            user: self.user.clone(),
3191            key_pair,
3192        };
3193        // Note that we do NOT use the `SshTunnelManager` here, as we want to validate that we
3194        // can actually create a new connection to the ssh bastion, without tunneling.
3195        config
3196            .validate(storage_configuration.parameters.ssh_timeout_config)
3197            .await
3198    }
3199
3200    fn validate_by_default(&self) -> bool {
3201        false
3202    }
3203}
3204
3205impl AwsPrivatelinkConnection {
3206    #[allow(clippy::unused_async)]
3207    async fn validate(
3208        &self,
3209        id: CatalogItemId,
3210        storage_configuration: &StorageConfiguration,
3211    ) -> Result<(), anyhow::Error> {
3212        let Some(ref cloud_resource_reader) = storage_configuration
3213            .connection_context
3214            .cloud_resource_reader
3215        else {
3216            return Err(anyhow!("AWS PrivateLink connections are unsupported"));
3217        };
3218
3219        // No need to optionally run this in a task, as we are just validating from envd.
3220        let status = cloud_resource_reader.read(id).await?;
3221
3222        let availability = status
3223            .conditions
3224            .as_ref()
3225            .and_then(|conditions| conditions.iter().find(|c| c.type_ == "Available"));
3226
3227        match availability {
3228            Some(condition) if condition.status == "True" => Ok(()),
3229            Some(condition) => Err(anyhow!("{}", condition.message)),
3230            None => Err(anyhow!("Endpoint availability is unknown")),
3231        }
3232    }
3233
3234    fn validate_by_default(&self) -> bool {
3235        false
3236    }
3237}