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