1use 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;
24use aws_smithy_runtime_api::client::identity::Identity as AwsIdentity;
26use base64::Engine;
27use http::{HeaderMap, HeaderName, HeaderValue};
28use iceberg::Catalog;
29use iceberg::CatalogBuilder;
30use iceberg::TableIdent;
31use iceberg::io::{
32 GCS_CREDENTIALS_JSON, GCS_DISABLE_CONFIG_LOAD, GCS_DISABLE_VM_METADATA, GCS_USER_PROJECT,
33 S3_ACCESS_KEY_ID, S3_DISABLE_EC2_METADATA, S3_REGION, S3_SECRET_ACCESS_KEY,
34};
35use iceberg_catalog_rest::{
36 OAuth2TokenProvider, REST_CATALOG_PROP_URI, REST_CATALOG_PROP_WAREHOUSE, RequestAuthenticator,
37 RestCatalogBuilder, TokenProvider,
38};
39use iceberg_storage_opendal::{
40 AwsCredential, CustomAwsCredentialLoader, CustomAzdlsCredentialLoader,
41 CustomGcsCredentialLoader, OpenDalStorageFactory, ProvideCredential,
42};
43use itertools::Itertools;
44use mz_ccsr::tls::{Certificate, Identity};
45use mz_cloud_resources::{AwsExternalIdPrefix, CloudResourceReader, vpc_endpoint_host};
46use mz_dyncfg::ConfigSet;
47use mz_kafka_util::client::{
48 BrokerAddr, BrokerRewrite, HostMappingRules, MzClientContext, MzKafkaError, TunnelConfig,
49 TunnelingClientContext,
50};
51use mz_mysql_util::{MySqlConn, MySqlError};
52use mz_ore::assert_none;
53use mz_ore::error::ErrorExt;
54use mz_ore::future::{InTask, OreFutureExt};
55use mz_ore::netio::resolve_address;
56use mz_ore::num::NonNeg;
57use mz_ore::str::StrExt;
58use mz_repr::{CatalogItemId, GlobalId};
59use mz_secrets::SecretsReader;
60use mz_sql_parser::ast::ConnectionRulePattern;
61use mz_ssh_util::keys::SshKeyPair;
62use mz_ssh_util::tunnel::SshTunnelConfig;
63use mz_ssh_util::tunnel_manager::{ManagedSshTunnelHandle, SshTunnelManager};
64use mz_tracing::CloneableEnvFilter;
65use rdkafka::ClientContext;
66use rdkafka::config::FromClientConfigAndContext;
67use rdkafka::consumer::{BaseConsumer, Consumer};
68use regex::Regex;
69use reqsign_core::time::Timestamp;
70use reqwest_0_12::Request;
71use serde::{Deserialize, Deserializer, Serialize};
72use tokio::net;
73use tokio::runtime::Handle;
74use tokio_postgres::config::SslMode;
75use tracing::{debug, info, warn};
76use url::Url;
77
78use crate::AlterCompatible;
79use crate::configuration::StorageConfiguration;
80use crate::connections::aws::{
81 AwsAuth, AwsConnection, AwsConnectionReference, AwsConnectionValidationError,
82};
83use crate::connections::gcp::{GcpConnectionReference, GcpTokenProvider};
84use crate::connections::string_or_secret::StringOrSecret;
85use crate::controller::AlterError;
86use crate::dyncfgs::{
87 ENFORCE_EXTERNAL_ADDRESSES, KAFKA_CLIENT_ID_ENRICHMENT_RULES,
88 KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM, KAFKA_RECONNECT_BACKOFF,
89 KAFKA_RECONNECT_BACKOFF_MAX, KAFKA_RETRY_BACKOFF, KAFKA_RETRY_BACKOFF_MAX,
90};
91use crate::errors::{ContextCreationError, CsrConnectError};
92
93pub mod aws;
94pub mod gcp;
95mod iceberg_credentials;
96pub mod inline;
97pub mod string_or_secret;
98
99const OAUTH2_PARAM_SCOPE: &str = "scope";
105
106const REST_CATALOG_PROP_OAUTH2_SERVER_URI: &str = "oauth2-server-uri";
107const REST_CATALOG_HEADER_PROP_PREFIX: &str = "header.";
110const REST_CATALOG_PROP_ACCESS_DELEGATION: &str = "header.X-Iceberg-Access-Delegation";
113
114#[derive(Debug)]
122struct AwsSdkCredentialLoader {
123 provider: SharedCredentialsProvider,
126}
127
128impl AwsSdkCredentialLoader {
129 fn new(provider: SharedCredentialsProvider) -> Self {
130 Self { provider }
131 }
132}
133
134impl ProvideCredential for AwsSdkCredentialLoader {
135 type Credential = AwsCredential;
136
137 async fn provide_credential(
138 &self,
139 _ctx: &reqsign_core::Context,
140 ) -> reqsign_core::Result<Option<Self::Credential>> {
141 let creds = self.provider.provide_credentials().await.map_err(|e| {
142 warn!(
143 error = %e.display_with_causes(),
144 "failed to load AWS credentials for Iceberg FileIO from SDK provider"
145 );
146 reqsign_core::Error::credential_invalid(
147 "failed to load AWS credentials from SDK provider for Iceberg FileIO \
148 (credential source may be temporarily unavailable)",
149 )
150 .with_source(e)
151 })?;
152
153 let expires_in = creds.expiry().map(aws_expiry_to_timestamp).transpose()?;
157
158 Ok(Some(AwsCredential {
159 access_key_id: creds.access_key_id().to_string(),
160 secret_access_key: creds.secret_access_key().to_string(),
161 session_token: creds.session_token().map(|s| s.to_string()),
162 expires_in,
163 }))
164 }
165}
166
167fn aws_expiry_to_timestamp(expiry: SystemTime) -> reqsign_core::Result<Timestamp> {
173 let millis = expiry
174 .duration_since(SystemTime::UNIX_EPOCH)
175 .map_err(|e| {
176 reqsign_core::Error::unexpected("AWS credential expiry precedes the Unix epoch")
177 .with_source(e)
178 })?
179 .as_millis();
180 let millis = i64::try_from(millis).map_err(|e| {
181 reqsign_core::Error::unexpected("AWS credential expiry overflows a millisecond timestamp")
182 .with_source(e)
183 })?;
184 Timestamp::from_millisecond(millis)
185}
186
187struct Sigv4Authenticator {
193 provider: SharedCredentialsProvider,
194 region: String,
195 signing_name: String,
197}
198
199impl std::fmt::Debug for Sigv4Authenticator {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.debug_struct("Sigv4Authenticator")
202 .field("region", &self.region)
203 .field("signing_name", &self.signing_name)
204 .finish_non_exhaustive()
205 }
206}
207
208fn sigv4_err(e: impl Into<anyhow::Error>) -> iceberg::Error {
209 iceberg::Error::new(iceberg::ErrorKind::DataInvalid, "AWS SigV4").with_source(e)
210}
211
212#[async_trait]
213impl RequestAuthenticator for Sigv4Authenticator {
214 async fn authenticate_request(&self, req: &mut Request) -> iceberg::Result<()> {
215 let creds = self
216 .provider
217 .provide_credentials()
218 .await
219 .map_err(sigv4_err)?;
220 let identity: AwsIdentity = creds.into();
221 let params = v4::SigningParams::builder()
222 .identity(&identity)
223 .region(&self.region)
224 .name(&self.signing_name)
225 .time(SystemTime::now())
226 .settings(SigningSettings::default())
227 .build()
228 .map_err(sigv4_err)?
229 .into();
230 let body: &[u8] = req
231 .body()
232 .map(|b| match b.as_bytes() {
233 Some(b) => Ok(b),
234 None => Err(iceberg::Error::new(
235 iceberg::ErrorKind::FeatureUnsupported,
236 "SigV4 Authenticator cannot sign a streaming request body.",
237 )),
238 })
239 .transpose()?
240 .unwrap_or_default();
241 let headers = req
242 .headers()
243 .iter()
244 .map(|(k, v)| {
245 Ok((
246 k.as_str(),
247 v.to_str().map_err(|_| {
248 iceberg::Error::new(
249 iceberg::ErrorKind::DataInvalid,
250 format!("header '{}' value is not all visible ASCII", k),
251 )
252 })?,
253 ))
254 })
255 .collect::<iceberg::Result<Vec<(&str, &str)>>>()?;
256 let signable = SignableRequest::new(
257 req.method().as_str(),
258 req.url().as_str(),
259 headers.into_iter(),
260 SignableBody::Bytes(body),
261 )
262 .map_err(sigv4_err)?;
263 let (instructions, _sig) = sign(signable, ¶ms).map_err(sigv4_err)?.into_parts();
264 let (new_headers, new_query) = instructions.into_parts();
265 for header in new_headers {
266 let mut value = HeaderValue::from_str(header.value()).map_err(sigv4_err)?;
267 value.set_sensitive(header.sensitive());
268 req.headers_mut()
269 .insert(HeaderName::from_static(header.name()), value);
270 }
271 if !new_query.is_empty() {
272 let url = req.url_mut();
273 let mut pairs = url.query_pairs_mut();
274 for (name, value) in new_query {
275 pairs.append_pair(name, &value);
276 }
277 }
278 Ok(())
279 }
280
281 async fn invalidate_cache(&self) -> iceberg::Result<()> {
283 Ok(())
284 }
285 async fn regenerate_cache(&self) -> iceberg::Result<()> {
286 Ok(())
287 }
288}
289
290#[async_trait::async_trait]
292trait SecretsReaderExt {
293 async fn read_in_task_if(
295 &self,
296 in_task: InTask,
297 id: CatalogItemId,
298 ) -> Result<Vec<u8>, anyhow::Error>;
299
300 async fn read_string_in_task_if(
302 &self,
303 in_task: InTask,
304 id: CatalogItemId,
305 ) -> Result<String, anyhow::Error>;
306}
307
308#[async_trait::async_trait]
309impl SecretsReaderExt for Arc<dyn SecretsReader> {
310 async fn read_in_task_if(
311 &self,
312 in_task: InTask,
313 id: CatalogItemId,
314 ) -> Result<Vec<u8>, anyhow::Error> {
315 let sr = Arc::clone(self);
316 async move { sr.read(id).await }
317 .run_in_task_if(in_task, || "secrets_reader_read".to_string())
318 .await
319 }
320 async fn read_string_in_task_if(
321 &self,
322 in_task: InTask,
323 id: CatalogItemId,
324 ) -> Result<String, anyhow::Error> {
325 let sr = Arc::clone(self);
326 async move { sr.read_string(id).await }
327 .run_in_task_if(in_task, || "secrets_reader_read".to_string())
328 .await
329 }
330}
331
332#[derive(Debug, Clone)]
337pub struct ConnectionContext {
338 pub environment_id: String,
345 pub librdkafka_log_level: tracing::Level,
347 pub aws_external_id_prefix: Option<AwsExternalIdPrefix>,
349 pub aws_connection_role_arn: Option<String>,
352 pub secrets_reader: Arc<dyn SecretsReader>,
354 pub cloud_resource_reader: Option<Arc<dyn CloudResourceReader>>,
356 pub ssh_tunnel_manager: SshTunnelManager,
358}
359
360impl ConnectionContext {
361 pub fn from_cli_args(
369 environment_id: String,
370 startup_log_level: &CloneableEnvFilter,
371 aws_external_id_prefix: Option<AwsExternalIdPrefix>,
372 aws_connection_role_arn: Option<String>,
373 secrets_reader: Arc<dyn SecretsReader>,
374 cloud_resource_reader: Option<Arc<dyn CloudResourceReader>>,
375 ) -> ConnectionContext {
376 ConnectionContext {
377 environment_id,
378 librdkafka_log_level: mz_ore::tracing::crate_level(
379 &startup_log_level.clone().into(),
380 "librdkafka",
381 ),
382 aws_external_id_prefix,
383 aws_connection_role_arn,
384 secrets_reader,
385 cloud_resource_reader,
386 ssh_tunnel_manager: SshTunnelManager::default(),
387 }
388 }
389
390 pub fn for_tests(secrets_reader: Arc<dyn SecretsReader>) -> ConnectionContext {
392 ConnectionContext {
393 environment_id: "test-environment-id".into(),
394 librdkafka_log_level: tracing::Level::INFO,
395 aws_external_id_prefix: Some(
396 AwsExternalIdPrefix::new_from_cli_argument_or_environment_variable(
397 "test-aws-external-id-prefix",
398 )
399 .expect("infallible"),
400 ),
401 aws_connection_role_arn: Some(
402 "arn:aws:iam::123456789000:role/MaterializeConnection".into(),
403 ),
404 secrets_reader,
405 cloud_resource_reader: None,
406 ssh_tunnel_manager: SshTunnelManager::default(),
407 }
408 }
409}
410
411#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
412pub enum Connection<C: ConnectionAccess = InlinedConnection> {
413 Kafka(KafkaConnection<C>),
414 Csr(CsrConnection<C>),
415 GlueSchemaRegistry(GlueSchemaRegistryConnection<C>),
416 Postgres(PostgresConnection<C>),
417 Ssh(SshConnection),
418 Aws(AwsConnection),
419 AwsPrivatelink(AwsPrivatelinkConnection),
420 Gcp(gcp::GcpConnection),
421 MySql(MySqlConnection<C>),
422 SqlServer(SqlServerConnectionDetails<C>),
423 IcebergCatalog(IcebergCatalogConnection<C>),
424}
425
426impl<R: ConnectionResolver> IntoInlineConnection<Connection, R>
427 for Connection<ReferencedConnection>
428{
429 fn into_inline_connection(self, r: R) -> Connection {
430 match self {
431 Connection::Kafka(kafka) => Connection::Kafka(kafka.into_inline_connection(r)),
432 Connection::Csr(csr) => Connection::Csr(csr.into_inline_connection(r)),
433 Connection::GlueSchemaRegistry(glue) => {
434 Connection::GlueSchemaRegistry(glue.into_inline_connection(r))
435 }
436 Connection::Postgres(pg) => Connection::Postgres(pg.into_inline_connection(r)),
437 Connection::Ssh(ssh) => Connection::Ssh(ssh),
438 Connection::Aws(aws) => Connection::Aws(aws),
439 Connection::AwsPrivatelink(awspl) => Connection::AwsPrivatelink(awspl),
440 Connection::Gcp(gcp) => Connection::Gcp(gcp),
441 Connection::MySql(mysql) => Connection::MySql(mysql.into_inline_connection(r)),
442 Connection::SqlServer(sql_server) => {
443 Connection::SqlServer(sql_server.into_inline_connection(r))
444 }
445 Connection::IcebergCatalog(iceberg) => {
446 Connection::IcebergCatalog(iceberg.into_inline_connection(r))
447 }
448 }
449 }
450}
451
452impl<C: ConnectionAccess> Connection<C> {
453 pub fn validate_by_default(&self) -> bool {
455 match self {
456 Connection::Kafka(conn) => conn.validate_by_default(),
457 Connection::Csr(conn) => conn.validate_by_default(),
458 Connection::GlueSchemaRegistry(conn) => conn.validate_by_default(),
459 Connection::Postgres(conn) => conn.validate_by_default(),
460 Connection::Ssh(conn) => conn.validate_by_default(),
461 Connection::Aws(conn) => conn.validate_by_default(),
462 Connection::AwsPrivatelink(conn) => conn.validate_by_default(),
463 Connection::Gcp(conn) => conn.validate_by_default(),
464 Connection::MySql(conn) => conn.validate_by_default(),
465 Connection::SqlServer(conn) => conn.validate_by_default(),
466 Connection::IcebergCatalog(conn) => conn.validate_by_default(),
467 }
468 }
469}
470
471impl Connection<InlinedConnection> {
472 pub async fn validate(
474 &self,
475 id: CatalogItemId,
476 storage_configuration: &StorageConfiguration,
477 ) -> Result<(), ConnectionValidationError> {
478 match self {
479 Connection::Kafka(conn) => conn.validate(id, storage_configuration).await?,
480 Connection::Csr(conn) => conn.validate(id, storage_configuration).await?,
481 Connection::GlueSchemaRegistry(conn) => {
482 conn.validate(id, storage_configuration).await?
483 }
484 Connection::Postgres(conn) => {
485 conn.validate(id, storage_configuration).await?;
486 }
487 Connection::Ssh(conn) => conn.validate(id, storage_configuration).await?,
488 Connection::Aws(conn) => conn.validate(id, storage_configuration).await?,
489 Connection::AwsPrivatelink(conn) => conn.validate(id, storage_configuration).await?,
490 Connection::Gcp(conn) => conn.validate(id, storage_configuration).await?,
491 Connection::MySql(conn) => {
492 conn.validate(id, storage_configuration).await?;
493 }
494 Connection::SqlServer(conn) => {
495 conn.validate(id, storage_configuration).await?;
496 }
497 Connection::IcebergCatalog(conn) => conn.validate(id, storage_configuration).await?,
498 }
499 Ok(())
500 }
501
502 pub fn unwrap_kafka(self) -> <InlinedConnection as ConnectionAccess>::Kafka {
503 match self {
504 Self::Kafka(conn) => conn,
505 o => unreachable!("{o:?} is not a Kafka connection"),
506 }
507 }
508
509 pub fn unwrap_pg(self) -> <InlinedConnection as ConnectionAccess>::Pg {
510 match self {
511 Self::Postgres(conn) => conn,
512 o => unreachable!("{o:?} is not a Postgres connection"),
513 }
514 }
515
516 pub fn unwrap_mysql(self) -> <InlinedConnection as ConnectionAccess>::MySql {
517 match self {
518 Self::MySql(conn) => conn,
519 o => unreachable!("{o:?} is not a MySQL connection"),
520 }
521 }
522
523 pub fn unwrap_sql_server(self) -> <InlinedConnection as ConnectionAccess>::SqlServer {
524 match self {
525 Self::SqlServer(conn) => conn,
526 o => unreachable!("{o:?} is not a SQL Server connection"),
527 }
528 }
529
530 pub fn unwrap_aws(self) -> <InlinedConnection as ConnectionAccess>::Aws {
531 match self {
532 Self::Aws(conn) => conn,
533 o => unreachable!("{o:?} is not an AWS connection"),
534 }
535 }
536
537 pub fn unwrap_gcp(self) -> <InlinedConnection as ConnectionAccess>::Gcp {
538 match self {
539 Self::Gcp(conn) => conn,
540 o => unreachable!("{o:?} is not a GCP connection"),
541 }
542 }
543
544 pub fn unwrap_ssh(self) -> <InlinedConnection as ConnectionAccess>::Ssh {
545 match self {
546 Self::Ssh(conn) => conn,
547 o => unreachable!("{o:?} is not an SSH connection"),
548 }
549 }
550
551 pub fn unwrap_csr(self) -> <InlinedConnection as ConnectionAccess>::Csr {
552 match self {
553 Self::Csr(conn) => conn,
554 o => unreachable!("{o:?} is not a Kafka connection"),
555 }
556 }
557
558 pub fn unwrap_glue_schema_registry(
559 self,
560 ) -> <InlinedConnection as ConnectionAccess>::GlueSchemaRegistry {
561 match self {
562 Self::GlueSchemaRegistry(conn) => conn,
563 o => unreachable!("{o:?} is not an AWS Glue Schema Registry connection"),
564 }
565 }
566
567 pub fn unwrap_iceberg_catalog(self) -> <InlinedConnection as ConnectionAccess>::IcebergCatalog {
568 match self {
569 Self::IcebergCatalog(conn) => conn,
570 o => unreachable!("{o:?} is not an Iceberg catalog connection"),
571 }
572 }
573}
574
575#[derive(thiserror::Error, Debug)]
577pub enum ConnectionValidationError {
578 #[error(transparent)]
579 Postgres(#[from] PostgresConnectionValidationError),
580 #[error(transparent)]
581 MySql(#[from] MySqlConnectionValidationError),
582 #[error(transparent)]
583 SqlServer(#[from] SqlServerConnectionValidationError),
584 #[error(transparent)]
585 Aws(#[from] AwsConnectionValidationError),
586 #[error(transparent)]
587 Gcp(#[from] gcp::GcpConnectionValidationError),
588 #[error(transparent)]
589 AwsPrivatelinkServiceName(#[from] InvalidAwsPrivatelinkServiceName),
590 #[error("{}", .0.display_with_causes())]
591 Other(#[from] anyhow::Error),
592}
593
594impl ConnectionValidationError {
595 pub fn detail(&self) -> Option<String> {
597 match self {
598 ConnectionValidationError::Postgres(e) => e.detail(),
599 ConnectionValidationError::MySql(e) => e.detail(),
600 ConnectionValidationError::SqlServer(e) => e.detail(),
601 ConnectionValidationError::Aws(e) => e.detail(),
602 ConnectionValidationError::Gcp(e) => e.detail(),
603 ConnectionValidationError::AwsPrivatelinkServiceName(_) => None,
604 ConnectionValidationError::Other(_) => None,
605 }
606 }
607
608 pub fn hint(&self) -> Option<String> {
610 match self {
611 ConnectionValidationError::Postgres(e) => e.hint(),
612 ConnectionValidationError::MySql(e) => e.hint(),
613 ConnectionValidationError::SqlServer(e) => e.hint(),
614 ConnectionValidationError::Aws(e) => e.hint(),
615 ConnectionValidationError::Gcp(e) => e.hint(),
616 ConnectionValidationError::AwsPrivatelinkServiceName(e) => Some(e.hint()),
617 ConnectionValidationError::Other(_) => None,
618 }
619 }
620}
621
622impl<C: ConnectionAccess> AlterCompatible for Connection<C> {
623 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
624 match (self, other) {
625 (Self::Aws(s), Self::Aws(o)) => s.alter_compatible(id, o),
626 (Self::AwsPrivatelink(s), Self::AwsPrivatelink(o)) => s.alter_compatible(id, o),
627 (Self::Gcp(s), Self::Gcp(o)) => s.alter_compatible(id, o),
628 (Self::Ssh(s), Self::Ssh(o)) => s.alter_compatible(id, o),
629 (Self::Csr(s), Self::Csr(o)) => s.alter_compatible(id, o),
630 (Self::Kafka(s), Self::Kafka(o)) => s.alter_compatible(id, o),
631 (Self::Postgres(s), Self::Postgres(o)) => s.alter_compatible(id, o),
632 (Self::MySql(s), Self::MySql(o)) => s.alter_compatible(id, o),
633 _ => {
634 tracing::warn!(
635 "Connection incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
636 self,
637 other
638 );
639 Err(AlterError { id })
640 }
641 }
642 }
643}
644
645#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
647pub enum IcebergCatalogAuth<C: ConnectionAccess = InlinedConnection> {
648 OAuth {
650 credential: StringOrSecret,
652 scope: Option<String>,
654 server_url: Option<String>,
661 },
662 Gcp(GcpConnectionReference<C>),
663}
664
665#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
666pub struct RestIcebergCatalog<C: ConnectionAccess = InlinedConnection> {
667 pub auth: IcebergCatalogAuth<C>,
668 pub warehouse: Option<String>,
670 pub access_delegation: Option<IcebergAccessDelegation>,
677 pub storage_provider: IcebergStorageProvider,
682}
683
684#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
687pub enum IcebergAccessDelegation {
688 VendedCredentials,
690}
691
692impl IcebergAccessDelegation {
693 pub fn as_header_value(&self) -> &'static str {
695 match self {
696 IcebergAccessDelegation::VendedCredentials => "vended-credentials",
697 }
698 }
699}
700
701#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
702pub struct S3TablesRestIcebergCatalog<C: ConnectionAccess = InlinedConnection> {
703 pub aws_connection: AwsConnectionReference<C>,
705 pub warehouse: String,
707}
708
709impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogAuth, R>
710 for IcebergCatalogAuth<ReferencedConnection>
711{
712 fn into_inline_connection(self, r: R) -> IcebergCatalogAuth {
713 match self {
714 IcebergCatalogAuth::Gcp(x) => IcebergCatalogAuth::Gcp(x.into_inline_connection(&r)),
715 IcebergCatalogAuth::OAuth {
716 credential,
717 scope,
718 server_url,
719 } => IcebergCatalogAuth::OAuth {
720 credential,
721 scope,
722 server_url,
723 },
724 }
725 }
726}
727
728impl<R: ConnectionResolver> IntoInlineConnection<RestIcebergCatalog, R>
729 for RestIcebergCatalog<ReferencedConnection>
730{
731 fn into_inline_connection(self, r: R) -> RestIcebergCatalog {
732 RestIcebergCatalog {
733 auth: self.auth.into_inline_connection(&r),
734 warehouse: self.warehouse,
735 access_delegation: self.access_delegation,
736 storage_provider: self.storage_provider,
737 }
738 }
739}
740
741impl<R: ConnectionResolver> IntoInlineConnection<S3TablesRestIcebergCatalog, R>
742 for S3TablesRestIcebergCatalog<ReferencedConnection>
743{
744 fn into_inline_connection(self, r: R) -> S3TablesRestIcebergCatalog {
745 S3TablesRestIcebergCatalog {
746 aws_connection: self.aws_connection.into_inline_connection(&r),
747 warehouse: self.warehouse,
748 }
749 }
750}
751
752#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
753pub enum IcebergCatalogType {
754 Rest,
755 S3TablesRest,
756}
757
758#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
765pub enum IcebergStorageProvider {
766 S3,
767 Gcs,
768 Adls,
769}
770
771impl IcebergStorageProvider {
772 pub fn as_str(&self) -> &'static str {
774 match self {
775 IcebergStorageProvider::S3 => "s3",
776 IcebergStorageProvider::Gcs => "gcs",
777 IcebergStorageProvider::Adls => "adls",
778 }
779 }
780}
781
782impl Default for IcebergStorageProvider {
783 fn default() -> Self {
784 IcebergStorageProvider::S3
785 }
786}
787
788#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
789pub enum IcebergCatalogImpl<C: ConnectionAccess = InlinedConnection> {
790 Rest(RestIcebergCatalog<C>),
791 S3TablesRest(S3TablesRestIcebergCatalog<C>),
792}
793
794impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogImpl, R>
795 for IcebergCatalogImpl<ReferencedConnection>
796{
797 fn into_inline_connection(self, r: R) -> IcebergCatalogImpl {
798 match self {
799 IcebergCatalogImpl::Rest(rest) => {
800 IcebergCatalogImpl::Rest(rest.into_inline_connection(r))
801 }
802 IcebergCatalogImpl::S3TablesRest(s3tables) => {
803 IcebergCatalogImpl::S3TablesRest(s3tables.into_inline_connection(r))
804 }
805 }
806 }
807}
808
809#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
810pub struct IcebergCatalogConnection<C: ConnectionAccess = InlinedConnection> {
811 pub catalog: IcebergCatalogImpl<C>,
813 pub uri: reqwest_0_12::Url,
815}
816
817impl AlterCompatible for IcebergCatalogConnection {
818 fn alter_compatible(&self, id: GlobalId, _other: &Self) -> Result<(), AlterError> {
819 Err(AlterError { id })
820 }
821}
822
823impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogConnection, R>
824 for IcebergCatalogConnection<ReferencedConnection>
825{
826 fn into_inline_connection(self, r: R) -> IcebergCatalogConnection {
827 IcebergCatalogConnection {
828 catalog: self.catalog.into_inline_connection(&r),
829 uri: self.uri,
830 }
831 }
832}
833
834impl<C: ConnectionAccess> IcebergCatalogConnection<C> {
835 fn validate_by_default(&self) -> bool {
836 true
837 }
838}
839
840impl IcebergCatalogConnection<InlinedConnection> {
841 pub async fn connect(
848 &self,
849 storage_configuration: &StorageConfiguration,
850 in_task: InTask,
851 table: Option<&TableIdent>,
852 ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
853 match self.catalog {
854 IcebergCatalogImpl::S3TablesRest(ref s3tables) => {
855 self.connect_s3tables(s3tables, storage_configuration, in_task)
858 .await
859 }
860 IcebergCatalogImpl::Rest(ref rest) => {
861 self.connect_rest(rest, storage_configuration, in_task, table)
862 .await
863 }
864 }
865 }
866
867 pub fn catalog_type(&self) -> IcebergCatalogType {
868 match self.catalog {
869 IcebergCatalogImpl::S3TablesRest(_) => IcebergCatalogType::S3TablesRest,
870 IcebergCatalogImpl::Rest(_) => IcebergCatalogType::Rest,
871 }
872 }
873
874 pub fn s3tables_catalog(&self) -> Option<&S3TablesRestIcebergCatalog> {
875 match &self.catalog {
876 IcebergCatalogImpl::S3TablesRest(s3tables) => Some(s3tables),
877 IcebergCatalogImpl::Rest(_) => None,
878 }
879 }
880
881 pub fn rest_catalog(&self) -> Option<&RestIcebergCatalog> {
882 match &self.catalog {
883 IcebergCatalogImpl::Rest(rest) => Some(rest),
884 IcebergCatalogImpl::S3TablesRest(_) => None,
885 }
886 }
887
888 async fn connect_s3tables(
889 &self,
890 s3tables: &S3TablesRestIcebergCatalog,
891 storage_configuration: &StorageConfiguration,
892 in_task: InTask,
893 ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
894 let secret_reader = &storage_configuration.connection_context.secrets_reader;
895 let aws_ref = &s3tables.aws_connection;
896
897 let aws_region = aws_ref
898 .connection
899 .region
900 .clone()
901 .unwrap_or_else(|| "us-east-1".to_string());
902
903 let mut props = vec![
904 (S3_REGION.to_string(), aws_region.clone()),
905 (S3_DISABLE_EC2_METADATA.to_string(), "true".to_string()),
906 (
907 REST_CATALOG_PROP_WAREHOUSE.to_string(),
908 s3tables.warehouse.clone(),
909 ),
910 (REST_CATALOG_PROP_URI.to_string(), self.uri.to_string()),
911 ];
912
913 let aws_auth = aws_ref.connection.auth.clone();
914
915 if let AwsAuth::Credentials(creds) = &aws_auth {
916 props.push((
917 S3_ACCESS_KEY_ID.to_string(),
918 creds
919 .access_key_id
920 .get_string(in_task, secret_reader)
921 .await?,
922 ));
923 props.push((
924 S3_SECRET_ACCESS_KEY.to_string(),
925 secret_reader.read_string(creds.secret_access_key).await?,
926 ));
927 }
928
929 let credentials_provider = match &aws_auth {
938 AwsAuth::AssumeRole(assume_role) => {
943 aws_ref.connection.validate_endpoint(
944 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
945 )?;
946 assume_role
947 .prefetch_credentials(
948 &storage_configuration.connection_context,
949 aws_ref.connection_id,
950 storage_configuration.config_set(),
951 format!("aws-connection-{}", aws_ref.connection_id),
952 )
953 .await
954 .with_context(|| {
955 format!(
956 "failed to initialize AssumeRole credentials for S3 Tables Iceberg \
957 catalog (catalog uri: {}, warehouse: {})",
958 self.uri, s3tables.warehouse
959 )
960 })?
961 }
962 AwsAuth::Credentials(_) => {
963 let aws_config = aws_ref
964 .connection
965 .load_sdk_config(
966 &storage_configuration.connection_context,
967 aws_ref.connection_id,
968 in_task,
969 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
970 )
971 .await
972 .with_context(|| {
973 format!(
974 "failed to load AWS SDK config for S3 Tables Iceberg catalog \
975 (connection id: {}, auth method: {}, catalog uri: {}, warehouse: {})",
976 aws_ref.connection_id,
977 aws_ref.connection.auth_method(),
978 self.uri,
979 s3tables.warehouse
980 )
981 })?;
982 aws_config
983 .credentials_provider()
984 .ok_or_else(|| anyhow!("aws_config missing credentials provider"))?
985 }
986 };
987
988 let authenticator = Arc::new(Sigv4Authenticator {
989 provider: credentials_provider.clone(),
990 region: aws_region.clone(),
991 signing_name: "s3tables".to_string(),
992 });
993
994 let customized_credential_load = if matches!(aws_auth, AwsAuth::AssumeRole(_)) {
997 Some(CustomAwsCredentialLoader::new(AwsSdkCredentialLoader::new(
998 credentials_provider,
999 )))
1000 } else {
1001 None
1002 };
1003
1004 let storage_factory = Arc::new(OpenDalStorageFactory::S3 {
1005 customized_credential_load,
1006 });
1007
1008 let catalog = RestCatalogBuilder::default()
1009 .with_storage_factory(storage_factory)
1010 .with_authenticator(authenticator)
1011 .load("IcebergCatalog", props.into_iter().collect())
1012 .await
1013 .with_context(|| {
1014 format!(
1015 "failed to create S3 Tables Iceberg catalog \
1016 (connection id: {}, catalog uri: {}, warehouse: {})",
1017 aws_ref.connection_id, self.uri, s3tables.warehouse
1018 )
1019 })?;
1020
1021 Ok(Arc::new(catalog))
1022 }
1023
1024 fn catalog_headers(props: &BTreeMap<String, String>) -> Result<HeaderMap, anyhow::Error> {
1031 props
1032 .iter()
1033 .filter_map(|(k, v)| {
1034 k.strip_prefix(REST_CATALOG_HEADER_PROP_PREFIX)
1035 .map(|name| (name, v))
1036 })
1037 .map(|(name, value)| {
1038 let name = HeaderName::try_from(name)
1039 .with_context(|| format!("invalid Iceberg catalog header name: {name}"))?;
1040 let value = HeaderValue::try_from(value)
1041 .with_context(|| format!("invalid Iceberg catalog header value for {name}"))?;
1042 Ok((name, value))
1043 })
1044 .collect()
1045 }
1046
1047 async fn vended_credential_endpoint(
1055 &self,
1056 rest: &RestIcebergCatalog,
1057 client: &reqwest_0_12::Client,
1058 token: &Arc<dyn TokenProvider>,
1059 headers: &HeaderMap,
1060 table: Option<&TableIdent>,
1061 ) -> Result<Option<Url>, anyhow::Error> {
1062 match (&rest.access_delegation, table) {
1063 (Some(IcebergAccessDelegation::VendedCredentials), Some(table)) => Ok(Some(
1064 iceberg_credentials::table_credentials_endpoint(
1065 &self.uri,
1066 client,
1067 token,
1068 headers,
1069 rest.warehouse.as_deref(),
1070 table,
1071 )
1072 .await?,
1073 )),
1074 _ => Ok(None),
1075 }
1076 }
1077
1078 fn gcs_storage_factory(
1080 endpoint: Option<Url>,
1081 client: &reqwest_0_12::Client,
1082 token: &Arc<dyn TokenProvider>,
1083 headers: &HeaderMap,
1084 ) -> OpenDalStorageFactory {
1085 OpenDalStorageFactory::Gcs {
1086 customized_credential_load: endpoint.map(|endpoint| {
1087 CustomGcsCredentialLoader::new(iceberg_credentials::VendedCredentialLoader::new(
1088 client.clone(),
1089 endpoint,
1090 Arc::clone(token),
1091 headers.clone(),
1092 ))
1093 }),
1094 }
1095 }
1096
1097 async fn connect_rest(
1098 &self,
1099 rest: &RestIcebergCatalog,
1100 storage_configuration: &StorageConfiguration,
1101 in_task: InTask,
1102 table: Option<&TableIdent>,
1103 ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
1104 let mut props = BTreeMap::from([(
1105 REST_CATALOG_PROP_URI.to_string(),
1106 self.uri.to_string().clone(),
1107 )]);
1108
1109 if let Some(warehouse) = &rest.warehouse {
1110 props.insert(REST_CATALOG_PROP_WAREHOUSE.to_string(), warehouse.clone());
1111 }
1112
1113 let client = reqwest_0_12::Client::new();
1116
1117 let (storage_factory, custom_authenticator) = match &rest.auth {
1121 IcebergCatalogAuth::OAuth {
1122 credential,
1123 scope,
1124 server_url,
1125 } => {
1126 let credential = credential
1127 .get_string(
1128 in_task,
1129 &storage_configuration.connection_context.secrets_reader,
1130 )
1131 .await
1132 .map_err(|e| anyhow!("failed to read Iceberg catalog credential: {e}"))?;
1133
1134 if let Some(server_url) = server_url {
1135 let url = Url::parse(server_url).with_context(|| {
1145 format!("invalid OAUTH2 SERVER URL for Iceberg catalog: {server_url}")
1146 })?;
1147 let host = url.host_str().ok_or_else(|| {
1148 anyhow!("OAUTH2 SERVER URL for Iceberg catalog has no host: {server_url}")
1149 })?;
1150 resolve_address(
1151 host,
1152 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1153 )
1154 .await
1155 .with_context(|| {
1156 format!("OAUTH2 SERVER URL for Iceberg catalog is not resolvable to an external address: {server_url}")
1157 })?;
1158
1159 props.insert(
1160 REST_CATALOG_PROP_OAUTH2_SERVER_URI.to_string(),
1161 server_url.clone(),
1162 );
1163 }
1164
1165 let token_endpoint = match server_url {
1168 Some(server_url) => server_url.clone(),
1169 None => format!(
1171 "{}/v1/oauth/tokens",
1172 self.uri.as_str().trim_end_matches('/')
1173 ),
1174 };
1175 let (client_id, client_secret) = match credential.split_once(':') {
1176 Some((client_id, client_secret)) => {
1177 (Some(client_id.to_string()), client_secret.to_string())
1178 }
1179 None => (None, credential),
1180 };
1181 let oauth_params = BTreeMap::from([(
1182 OAUTH2_PARAM_SCOPE.to_string(),
1183 scope.clone().unwrap_or_else(|| "catalog".to_string()),
1185 )]);
1186 let token: Arc<dyn TokenProvider> = Arc::new(OAuth2TokenProvider::new(
1187 client.clone(),
1188 client_id,
1189 client_secret,
1190 token_endpoint,
1191 HeaderMap::new(),
1194 oauth_params.into_iter().collect(),
1195 ));
1196
1197 let headers = Self::catalog_headers(&props)?;
1198 let endpoint = self
1199 .vended_credential_endpoint(rest, &client, &token, &headers, table)
1200 .await?;
1201
1202 (
1203 match rest.storage_provider {
1206 IcebergStorageProvider::S3 => OpenDalStorageFactory::S3 {
1213 customized_credential_load: endpoint.map(|endpoint| {
1214 CustomAwsCredentialLoader::new(
1215 iceberg_credentials::VendedCredentialLoader::new(
1216 client.clone(),
1217 endpoint,
1218 Arc::clone(&token),
1219 headers.clone(),
1220 ),
1221 )
1222 }),
1223 },
1224 IcebergStorageProvider::Gcs => {
1225 Self::gcs_storage_factory(endpoint, &client, &token, &headers)
1226 }
1227 IcebergStorageProvider::Adls => OpenDalStorageFactory::Azdls {
1228 customized_credential_load: endpoint.map(|endpoint| {
1229 CustomAzdlsCredentialLoader::new(
1230 iceberg_credentials::VendedCredentialLoader::new(
1231 client.clone(),
1232 endpoint,
1233 Arc::clone(&token),
1234 headers.clone(),
1235 ),
1236 )
1237 }),
1238 },
1239 },
1240 Some(iceberg_catalog_rest::BearerTokenAuthenticator::new(token)),
1244 )
1245 }
1246 IcebergCatalogAuth::Gcp(gcp_connection_reference) => {
1247 let (creds_json, service_account) = gcp_connection_reference
1248 .connection
1249 .read_credentials(storage_configuration)
1250 .await
1251 .map_err(|e| anyhow!("failed to parse GCP service account JSON: {e}"))?;
1252
1253 props.insert(
1254 GCS_CREDENTIALS_JSON.to_owned(),
1255 base64::engine::general_purpose::STANDARD.encode(creds_json),
1256 );
1257 props.insert(GCS_DISABLE_VM_METADATA.to_owned(), "true".to_owned());
1259 props.insert(GCS_DISABLE_CONFIG_LOAD.to_owned(), "true".to_owned());
1260 if let Some(project_id) = service_account.project_id() {
1261 props.insert(GCS_USER_PROJECT.to_owned(), project_id.to_owned());
1262 props.insert(
1263 "header.x-goog-user-project".to_owned(),
1264 project_id.to_owned(),
1265 );
1266 }
1267
1268 let token: Arc<dyn TokenProvider> = Arc::new(GcpTokenProvider { service_account });
1271 let headers = Self::catalog_headers(&props)?;
1272 let endpoint = self
1273 .vended_credential_endpoint(rest, &client, &token, &headers, table)
1274 .await?;
1275
1276 (
1277 Self::gcs_storage_factory(endpoint, &client, &token, &headers),
1285 Some(iceberg_catalog_rest::BearerTokenAuthenticator::new(token)),
1286 )
1287 }
1288 };
1289
1290 if let Some(delegation) = &rest.access_delegation {
1294 props.insert(
1295 REST_CATALOG_PROP_ACCESS_DELEGATION.to_string(),
1296 delegation.as_header_value().to_string(),
1297 );
1298 }
1299
1300 let mut catalog = RestCatalogBuilder::default()
1301 .with_storage_factory(Arc::new(storage_factory))
1302 .with_client(client);
1303 if let Some(auth) = custom_authenticator {
1304 catalog = catalog.with_authenticator(Arc::new(auth));
1305 }
1306 let catalog = catalog
1307 .load("IcebergCatalog", props.into_iter().collect())
1308 .await
1309 .map_err(|e| anyhow!("failed to create Iceberg catalog: {e}"))?;
1310 Ok(Arc::new(catalog))
1311 }
1312
1313 async fn validate(
1314 &self,
1315 _id: CatalogItemId,
1316 storage_configuration: &StorageConfiguration,
1317 ) -> Result<(), ConnectionValidationError> {
1318 let catalog = self
1320 .connect(storage_configuration, InTask::No, None)
1321 .await
1322 .map_err(|e| {
1323 ConnectionValidationError::Other(anyhow!("failed to connect to catalog: {e}"))
1324 })?;
1325
1326 catalog.list_namespaces(None).await.map_err(|e| {
1328 ConnectionValidationError::Other(anyhow!("failed to list namespaces: {e}"))
1329 })?;
1330
1331 Ok(())
1332 }
1333}
1334
1335#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1336pub struct AwsPrivatelinkConnection {
1337 pub service_name: String,
1338 pub availability_zones: Vec<String>,
1339}
1340
1341impl AlterCompatible for AwsPrivatelinkConnection {
1342 fn alter_compatible(&self, _id: GlobalId, _other: &Self) -> Result<(), AlterError> {
1343 Ok(())
1345 }
1346}
1347
1348#[derive(Clone, Debug, Eq, PartialEq)]
1350pub struct InvalidAwsPrivatelinkServiceName {
1351 pub name: String,
1352}
1353
1354impl fmt::Display for InvalidAwsPrivatelinkServiceName {
1355 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1356 write!(
1357 f,
1358 "invalid AWS PrivateLink service name {}",
1359 self.name.quoted()
1360 )
1361 }
1362}
1363
1364impl std::error::Error for InvalidAwsPrivatelinkServiceName {}
1365
1366impl InvalidAwsPrivatelinkServiceName {
1367 pub fn hint(&self) -> String {
1369 "SERVICE NAME must name an AWS VPC endpoint service, for example \
1370 `com.amazonaws.vpce.us-east-1.vpce-svc-0e123abc123198abc`. Endpoint service names are \
1371 listed in the AWS console under VPC > Endpoint services."
1372 .into()
1373 }
1374}
1375
1376impl AwsPrivatelinkConnection {
1377 pub fn check_service_name(service_name: &str) -> Result<(), InvalidAwsPrivatelinkServiceName> {
1384 if service_name.starts_with("com.amazonaws.") {
1385 return Ok(());
1386 }
1387 Err(InvalidAwsPrivatelinkServiceName {
1388 name: service_name.to_string(),
1389 })
1390 }
1391}
1392
1393#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1394pub struct KafkaTlsConfig {
1395 pub identity: Option<TlsIdentity>,
1396 pub root_cert: Option<StringOrSecret>,
1397}
1398
1399#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1400pub struct KafkaSaslConfig<C: ConnectionAccess = InlinedConnection> {
1401 pub mechanism: String,
1402 pub username: StringOrSecret,
1403 pub password: Option<CatalogItemId>,
1404 pub aws: Option<AwsConnectionReference<C>>,
1405}
1406
1407impl<R: ConnectionResolver> IntoInlineConnection<KafkaSaslConfig, R>
1408 for KafkaSaslConfig<ReferencedConnection>
1409{
1410 fn into_inline_connection(self, r: R) -> KafkaSaslConfig {
1411 KafkaSaslConfig {
1412 mechanism: self.mechanism,
1413 username: self.username,
1414 password: self.password,
1415 aws: self.aws.map(|aws| aws.into_inline_connection(&r)),
1416 }
1417 }
1418}
1419
1420#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1422pub struct KafkaBroker<C: ConnectionAccess = InlinedConnection> {
1423 pub address: String,
1425 pub tunnel: Tunnel<C>,
1427}
1428
1429impl<R: ConnectionResolver> IntoInlineConnection<KafkaBroker, R>
1430 for KafkaBroker<ReferencedConnection>
1431{
1432 fn into_inline_connection(self, r: R) -> KafkaBroker {
1433 let KafkaBroker { address, tunnel } = self;
1434 KafkaBroker {
1435 address,
1436 tunnel: tunnel.into_inline_connection(r),
1437 }
1438 }
1439}
1440
1441#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, Default)]
1442pub struct KafkaTopicOptions {
1443 pub replication_factor: Option<NonNeg<i32>>,
1446 pub partition_count: Option<NonNeg<i32>>,
1449 pub topic_config: BTreeMap<String, String>,
1451}
1452
1453#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1454pub struct KafkaConnection<C: ConnectionAccess = InlinedConnection> {
1455 pub brokers: Vec<KafkaBroker<C>>,
1456 pub default_tunnel: Tunnel<C>,
1460 pub progress_topic: Option<String>,
1461 pub progress_topic_options: KafkaTopicOptions,
1462 pub options: BTreeMap<String, StringOrSecret>,
1463 pub tls: Option<KafkaTlsConfig>,
1464 pub sasl: Option<KafkaSaslConfig<C>>,
1465}
1466
1467impl<R: ConnectionResolver> IntoInlineConnection<KafkaConnection, R>
1468 for KafkaConnection<ReferencedConnection>
1469{
1470 fn into_inline_connection(self, r: R) -> KafkaConnection {
1471 let KafkaConnection {
1472 brokers,
1473 progress_topic,
1474 progress_topic_options,
1475 default_tunnel,
1476 options,
1477 tls,
1478 sasl,
1479 } = self;
1480
1481 let brokers = brokers
1482 .into_iter()
1483 .map(|broker| broker.into_inline_connection(&r))
1484 .collect();
1485
1486 KafkaConnection {
1487 brokers,
1488 progress_topic,
1489 progress_topic_options,
1490 default_tunnel: default_tunnel.into_inline_connection(&r),
1491 options,
1492 tls,
1493 sasl: sasl.map(|sasl| sasl.into_inline_connection(&r)),
1494 }
1495 }
1496}
1497
1498impl<C: ConnectionAccess> KafkaConnection<C> {
1499 pub fn progress_topic(
1509 &self,
1510 connection_context: &ConnectionContext,
1511 connection_id: CatalogItemId,
1512 ) -> Cow<'_, str> {
1513 if let Some(progress_topic) = &self.progress_topic {
1514 Cow::Borrowed(progress_topic)
1515 } else {
1516 Cow::Owned(format!(
1517 "_materialize-progress-{}-{}",
1518 connection_context.environment_id, connection_id,
1519 ))
1520 }
1521 }
1522
1523 fn validate_by_default(&self) -> bool {
1524 true
1525 }
1526}
1527
1528impl KafkaConnection {
1529 pub fn id_base(
1540 connection_context: &ConnectionContext,
1541 connection_id: CatalogItemId,
1542 object_id: GlobalId,
1543 ) -> String {
1544 format!(
1545 "materialize-{}-{}-{}",
1546 connection_context.environment_id, connection_id, object_id,
1547 )
1548 }
1549
1550 pub fn enrich_client_id(&self, configs: &ConfigSet, client_id: &mut String) {
1553 #[derive(Debug, Deserialize)]
1554 struct EnrichmentRule {
1555 #[serde(deserialize_with = "deserialize_regex")]
1556 pattern: Regex,
1557 payload: String,
1558 }
1559
1560 fn deserialize_regex<'de, D>(deserializer: D) -> Result<Regex, D::Error>
1561 where
1562 D: Deserializer<'de>,
1563 {
1564 let buf = String::deserialize(deserializer)?;
1565 Regex::new(&buf).map_err(serde::de::Error::custom)
1566 }
1567
1568 let rules = KAFKA_CLIENT_ID_ENRICHMENT_RULES.get(configs);
1569 let rules = match serde_json::from_value::<Vec<EnrichmentRule>>(rules) {
1570 Ok(rules) => rules,
1571 Err(e) => {
1572 warn!(%e, "failed to decode kafka_client_id_enrichment_rules");
1573 return;
1574 }
1575 };
1576
1577 debug!(?self.brokers, "evaluating client ID enrichment rules");
1582 for rule in rules {
1583 let is_match = self
1584 .brokers
1585 .iter()
1586 .any(|b| rule.pattern.is_match(&b.address));
1587 debug!(?rule, is_match, "evaluated client ID enrichment rule");
1588 if is_match {
1589 client_id.push('-');
1590 client_id.push_str(&rule.payload);
1591 }
1592 }
1593 }
1594
1595 pub async fn create_with_context<C, T>(
1597 &self,
1598 storage_configuration: &StorageConfiguration,
1599 context: C,
1600 extra_options: &BTreeMap<&str, String>,
1601 in_task: InTask,
1602 ) -> Result<T, ContextCreationError>
1603 where
1604 C: ClientContext,
1605 T: FromClientConfigAndContext<TunnelingClientContext<C>>,
1606 {
1607 let mut options = self.options.clone();
1608
1609 options.insert("allow.auto.create.topics".into(), "false".into());
1614
1615 let brokers = match &self.default_tunnel {
1616 Tunnel::AwsPrivatelink(t) => {
1617 assert!(&self.brokers.is_empty());
1618
1619 let algo = KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM
1620 .get(storage_configuration.config_set());
1621 options.insert("ssl.endpoint.identification.algorithm".into(), algo.into());
1622
1623 format!(
1626 "{}:{}",
1627 vpc_endpoint_host(
1628 t.connection_id,
1629 None, ),
1631 t.port.unwrap_or(9092)
1632 )
1633 }
1634 Tunnel::AwsPrivatelinks(_pl) => {
1635 let algo = KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM
1636 .get(storage_configuration.config_set());
1637 options.insert("ssl.endpoint.identification.algorithm".into(), algo.into());
1638
1639 if self.brokers.is_empty() {
1640 return Err(ContextCreationError::Other(anyhow::anyhow!(
1641 "at least one static broker is required when using BROKER or BROKERS"
1642 )));
1643 }
1644 self.brokers.iter().map(|b| &b.address).join(",")
1645 }
1646 _ => self.brokers.iter().map(|b| &b.address).join(","),
1647 };
1648 options.insert("bootstrap.servers".into(), brokers.clone().into());
1649 let security_protocol = match (self.tls.is_some(), self.sasl.is_some()) {
1650 (false, false) => "PLAINTEXT",
1651 (true, false) => "SSL",
1652 (false, true) => "SASL_PLAINTEXT",
1653 (true, true) => "SASL_SSL",
1654 };
1655 info!(
1656 "kafka: create_with_context bootstrap.servers={brokers}, security_protocol={security_protocol}"
1657 );
1658 options.insert("security.protocol".into(), security_protocol.into());
1659 if let Some(tls) = &self.tls {
1660 if let Some(root_cert) = &tls.root_cert {
1661 options.insert("ssl.ca.pem".into(), root_cert.clone());
1662 }
1663 if let Some(identity) = &tls.identity {
1664 options.insert("ssl.key.pem".into(), StringOrSecret::Secret(identity.key));
1665 options.insert("ssl.certificate.pem".into(), identity.cert.clone());
1666 }
1667 }
1668 if let Some(sasl) = &self.sasl {
1669 options.insert("sasl.mechanisms".into(), (&sasl.mechanism).into());
1670 options.insert("sasl.username".into(), sasl.username.clone());
1671 if let Some(password) = sasl.password {
1672 options.insert("sasl.password".into(), StringOrSecret::Secret(password));
1673 }
1674 }
1675
1676 options.insert(
1677 "retry.backoff.ms".into(),
1678 KAFKA_RETRY_BACKOFF
1679 .get(storage_configuration.config_set())
1680 .as_millis()
1681 .into(),
1682 );
1683 options.insert(
1684 "retry.backoff.max.ms".into(),
1685 KAFKA_RETRY_BACKOFF_MAX
1686 .get(storage_configuration.config_set())
1687 .as_millis()
1688 .into(),
1689 );
1690 options.insert(
1691 "reconnect.backoff.ms".into(),
1692 KAFKA_RECONNECT_BACKOFF
1693 .get(storage_configuration.config_set())
1694 .as_millis()
1695 .into(),
1696 );
1697 options.insert(
1698 "reconnect.backoff.max.ms".into(),
1699 KAFKA_RECONNECT_BACKOFF_MAX
1700 .get(storage_configuration.config_set())
1701 .as_millis()
1702 .into(),
1703 );
1704
1705 let mut config = mz_kafka_util::client::create_new_client_config(
1706 storage_configuration
1707 .connection_context
1708 .librdkafka_log_level,
1709 storage_configuration.parameters.kafka_timeout_config,
1710 );
1711 for (k, v) in options {
1712 config.set(
1713 k,
1714 v.get_string(
1715 in_task,
1716 &storage_configuration.connection_context.secrets_reader,
1717 )
1718 .await
1719 .context("reading kafka secret")?,
1720 );
1721 }
1722 for (k, v) in extra_options {
1723 config.set(*k, v);
1724 }
1725
1726 let aws_config = match self.sasl.as_ref().and_then(|sasl| sasl.aws.as_ref()) {
1727 None => None,
1728 Some(aws) => Some(
1729 aws.connection
1730 .load_sdk_config(
1731 &storage_configuration.connection_context,
1732 aws.connection_id,
1733 in_task,
1734 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1735 )
1736 .await?,
1737 ),
1738 };
1739
1740 let mut context = TunnelingClientContext::new(
1741 context,
1742 Handle::current(),
1743 storage_configuration
1744 .connection_context
1745 .ssh_tunnel_manager
1746 .clone(),
1747 storage_configuration.parameters.ssh_timeout_config,
1748 aws_config,
1749 in_task,
1750 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1751 );
1752
1753 match &self.default_tunnel {
1754 Tunnel::Direct => {
1755 }
1757 Tunnel::AwsPrivatelink(pl) => {
1758 context.set_default_tunnel(TunnelConfig::StaticHost(
1759 KafkaConnection::from_default_aws_privatelink(pl).host,
1761 ));
1762 }
1763 Tunnel::AwsPrivatelinks(pl) => {
1764 context.set_default_tunnel(TunnelConfig::Rules(
1765 KafkaConnection::from_aws_privatelinks(pl),
1766 ));
1767 }
1768 Tunnel::Ssh(ssh_tunnel) => {
1769 let secret = storage_configuration
1770 .connection_context
1771 .secrets_reader
1772 .read_in_task_if(in_task, ssh_tunnel.connection_id)
1773 .await?;
1774 let key_pair = SshKeyPair::from_bytes(&secret)?;
1775
1776 let resolved = resolve_address(
1778 &ssh_tunnel.connection.host,
1779 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1780 )
1781 .await?;
1782 context.set_default_tunnel(TunnelConfig::Ssh(SshTunnelConfig {
1783 host: resolved
1784 .iter()
1785 .map(|a| a.to_string())
1786 .collect::<BTreeSet<_>>(),
1787 port: ssh_tunnel.connection.port,
1788 user: ssh_tunnel.connection.user.clone(),
1789 key_pair,
1790 }));
1791 }
1792 }
1793 info!(
1794 "kafka: tunnel config set to {}",
1795 match &self.default_tunnel {
1796 Tunnel::Direct => "Direct".to_string(),
1797 Tunnel::AwsPrivatelink(_) => "AwsPrivatelink (static host)".to_string(),
1798 Tunnel::AwsPrivatelinks(pl) =>
1799 format!("AwsPrivatelinks ({} rules)", pl.rules.len()),
1800 Tunnel::Ssh(_) => "Ssh".to_string(),
1801 }
1802 );
1803
1804 for broker in &self.brokers {
1807 let mut addr_parts = broker.address.splitn(2, ':');
1808 let addr = BrokerAddr {
1809 host: addr_parts
1810 .next()
1811 .context("BROKER is not address:port")?
1812 .into(),
1813 port: addr_parts
1814 .next()
1815 .unwrap_or("9092")
1816 .parse()
1817 .context("parsing BROKER port")?,
1818 };
1819 match &broker.tunnel {
1820 Tunnel::Direct => {
1821 }
1831 Tunnel::AwsPrivatelink(aws_privatelink) => {
1832 context.add_broker_rewrite(
1833 addr,
1834 KafkaConnection::from_aws_privatelink(aws_privatelink),
1835 );
1836 }
1837 Tunnel::AwsPrivatelinks(_) => unreachable!(
1838 "Individually predefined brokers do not use rule-based PrivateLinks routing."
1839 ),
1840 Tunnel::Ssh(ssh_tunnel) => {
1841 let ssh_host_resolved = resolve_address(
1843 &ssh_tunnel.connection.host,
1844 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1845 )
1846 .await?;
1847 context
1848 .add_ssh_tunnel(
1849 addr,
1850 SshTunnelConfig {
1851 host: ssh_host_resolved
1852 .iter()
1853 .map(|a| a.to_string())
1854 .collect::<BTreeSet<_>>(),
1855 port: ssh_tunnel.connection.port,
1856 user: ssh_tunnel.connection.user.clone(),
1857 key_pair: SshKeyPair::from_bytes(
1858 &storage_configuration
1859 .connection_context
1860 .secrets_reader
1861 .read_in_task_if(in_task, ssh_tunnel.connection_id)
1862 .await?,
1863 )?,
1864 },
1865 )
1866 .await
1867 .map_err(ContextCreationError::Ssh)?;
1868 }
1869 }
1870 }
1871
1872 Ok(mz_kafka_util::client::create_with_context(
1873 &config, context,
1874 )?)
1875 }
1876
1877 async fn validate(
1878 &self,
1879 _id: CatalogItemId,
1880 storage_configuration: &StorageConfiguration,
1881 ) -> Result<(), anyhow::Error> {
1882 let (context, error_rx) = MzClientContext::with_errors();
1883 let consumer: BaseConsumer<_> = self
1884 .create_with_context(
1885 storage_configuration,
1886 context,
1887 &BTreeMap::new(),
1888 InTask::No,
1890 )
1891 .await?;
1892 let consumer = Arc::new(consumer);
1893
1894 let timeout = storage_configuration
1895 .parameters
1896 .kafka_timeout_config
1897 .fetch_metadata_timeout;
1898
1899 info!("kafka: starting connection validation via fetch_metadata (timeout={timeout:?})");
1910 let result = mz_ore::task::spawn_blocking(|| "kafka_get_metadata", {
1911 let consumer = Arc::clone(&consumer);
1912 move || consumer.fetch_metadata(None, timeout)
1913 })
1914 .await;
1915 info!(
1916 "kafka: connection validation result: {}",
1917 if result.is_ok() { "success" } else { "failed" },
1918 );
1919 match result {
1920 Ok(_) => Ok(()),
1921 Err(err) => {
1926 let main_err = error_rx.try_iter().reduce(|cur, new| match cur {
1930 MzKafkaError::Internal(_) => new,
1931 _ => cur,
1932 });
1933
1934 drop(consumer);
1938
1939 match main_err {
1940 Some(err) => Err(err.into()),
1941 None => Err(err.into()),
1942 }
1943 }
1944 }
1945 }
1946
1947 fn from_default_aws_privatelink(pl: &AwsPrivatelink) -> BrokerRewrite {
1949 BrokerRewrite {
1950 host: vpc_endpoint_host(
1951 pl.connection_id,
1952 None, ),
1954 port: pl.port,
1955 }
1956 }
1957
1958 fn from_aws_privatelink(pl: &AwsPrivatelink) -> BrokerRewrite {
1960 BrokerRewrite {
1961 host: vpc_endpoint_host(pl.connection_id, pl.availability_zone.as_deref()),
1962 port: pl.port,
1963 }
1964 }
1965
1966 fn from_aws_privatelink_rule(
1967 AwsPrivatelinkRule { pattern, to }: &AwsPrivatelinkRule,
1968 ) -> (mz_kafka_util::client::ConnectionRulePattern, BrokerRewrite) {
1969 (
1970 mz_kafka_util::client::ConnectionRulePattern {
1971 prefix_wildcard: pattern.prefix_wildcard,
1972 literal_match: pattern.literal_match.clone(),
1973 suffix_wildcard: pattern.suffix_wildcard,
1974 },
1975 KafkaConnection::from_aws_privatelink(to),
1976 )
1977 }
1978
1979 fn from_aws_privatelinks(pl: &AwsPrivatelinks) -> HostMappingRules {
1980 HostMappingRules {
1981 rules: pl
1982 .rules
1983 .iter()
1984 .map(KafkaConnection::from_aws_privatelink_rule)
1985 .collect_vec(),
1986 }
1987 }
1988}
1989
1990impl<C: ConnectionAccess> AlterCompatible for KafkaConnection<C> {
1991 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
1992 let KafkaConnection {
1993 brokers: _,
1994 default_tunnel: _,
1995 progress_topic,
1996 progress_topic_options,
1997 options: _,
1998 tls: _,
1999 sasl: _,
2000 } = self;
2001
2002 let compatibility_checks = [
2003 (progress_topic == &other.progress_topic, "progress_topic"),
2004 (
2005 progress_topic_options == &other.progress_topic_options,
2006 "progress_topic_options",
2007 ),
2008 ];
2009
2010 for (compatible, field) in compatibility_checks {
2011 if !compatible {
2012 tracing::warn!(
2013 "KafkaConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2014 self,
2015 other
2016 );
2017
2018 return Err(AlterError { id });
2019 }
2020 }
2021
2022 Ok(())
2023 }
2024}
2025
2026#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2028pub struct CsrConnection<C: ConnectionAccess = InlinedConnection> {
2029 pub url: Url,
2031 pub tls_root_cert: Option<StringOrSecret>,
2033 pub tls_identity: Option<TlsIdentity>,
2036 pub http_auth: Option<CsrConnectionHttpAuth>,
2038 pub tunnel: Tunnel<C>,
2040}
2041
2042impl<R: ConnectionResolver> IntoInlineConnection<CsrConnection, R>
2043 for CsrConnection<ReferencedConnection>
2044{
2045 fn into_inline_connection(self, r: R) -> CsrConnection {
2046 let CsrConnection {
2047 url,
2048 tls_root_cert,
2049 tls_identity,
2050 http_auth,
2051 tunnel,
2052 } = self;
2053 CsrConnection {
2054 url,
2055 tls_root_cert,
2056 tls_identity,
2057 http_auth,
2058 tunnel: tunnel.into_inline_connection(r),
2059 }
2060 }
2061}
2062
2063impl<C: ConnectionAccess> CsrConnection<C> {
2064 fn validate_by_default(&self) -> bool {
2065 true
2066 }
2067}
2068
2069impl CsrConnection {
2070 pub async fn connect(
2072 &self,
2073 storage_configuration: &StorageConfiguration,
2074 in_task: InTask,
2075 ) -> Result<mz_ccsr::Client, CsrConnectError> {
2076 let mut client_config = mz_ccsr::ClientConfig::new(self.url.clone());
2077 if let Some(root_cert) = &self.tls_root_cert {
2078 let root_cert = root_cert
2079 .get_string(
2080 in_task,
2081 &storage_configuration.connection_context.secrets_reader,
2082 )
2083 .await?;
2084 let root_cert = Certificate::from_pem(root_cert.as_bytes())?;
2085 client_config = client_config.add_root_certificate(root_cert);
2086 }
2087
2088 if let Some(tls_identity) = &self.tls_identity {
2089 let key = &storage_configuration
2090 .connection_context
2091 .secrets_reader
2092 .read_string_in_task_if(in_task, tls_identity.key)
2093 .await?;
2094 let cert = tls_identity
2095 .cert
2096 .get_string(
2097 in_task,
2098 &storage_configuration.connection_context.secrets_reader,
2099 )
2100 .await?;
2101 let ident = Identity::from_pem(key.as_bytes(), cert.as_bytes())?;
2102 client_config = client_config.identity(ident);
2103 }
2104
2105 if let Some(http_auth) = &self.http_auth {
2106 let username = http_auth
2107 .username
2108 .get_string(
2109 in_task,
2110 &storage_configuration.connection_context.secrets_reader,
2111 )
2112 .await?;
2113 let password = match http_auth.password {
2114 None => None,
2115 Some(password) => Some(
2116 storage_configuration
2117 .connection_context
2118 .secrets_reader
2119 .read_string_in_task_if(in_task, password)
2120 .await?,
2121 ),
2122 };
2123 client_config = client_config.auth(username, password);
2124 }
2125
2126 let host = self
2128 .url
2129 .host_str()
2130 .ok_or_else(|| anyhow!("url missing host"))?;
2131 match &self.tunnel {
2132 Tunnel::Direct => {
2133 let resolved = resolve_address(
2135 host,
2136 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2137 )
2138 .await?;
2139 client_config = client_config.resolve_to_addrs(
2140 host,
2141 &resolved
2142 .iter()
2143 .map(|addr| SocketAddr::new(*addr, 0))
2144 .collect::<Vec<_>>(),
2145 )
2146 }
2147 Tunnel::Ssh(ssh_tunnel) => {
2148 let ssh_tunnel = ssh_tunnel
2149 .connect(
2150 storage_configuration,
2151 host,
2152 self.url.port_or_known_default().unwrap_or(80),
2155 in_task,
2156 )
2157 .await
2158 .map_err(CsrConnectError::Ssh)?;
2159
2160 client_config = client_config
2166 .resolve_to_addrs(host, &[SocketAddr::new(ssh_tunnel.local_addr().ip(), 0)])
2173 .dynamic_url({
2184 let remote_url = self.url.clone();
2185 move || {
2186 let mut url = remote_url.clone();
2187 url.set_port(Some(ssh_tunnel.local_addr().port()))
2188 .expect("cannot fail");
2189 url
2190 }
2191 });
2192 }
2193 Tunnel::AwsPrivatelink(connection) => {
2194 assert_none!(connection.port);
2195
2196 let privatelink_host = mz_cloud_resources::vpc_endpoint_host(
2197 connection.connection_id,
2198 connection.availability_zone.as_deref(),
2199 );
2200 let addrs: Vec<_> = net::lookup_host((privatelink_host, 0))
2201 .await
2202 .context("resolving PrivateLink host")?
2203 .collect();
2204 client_config = client_config.resolve_to_addrs(host, &addrs)
2205 }
2206 Tunnel::AwsPrivatelinks(_) => {
2207 unreachable!("MATCHING broker rules are only available for Kafka connections.");
2208 }
2209 }
2210
2211 Ok(client_config.build()?)
2212 }
2213
2214 async fn validate(
2215 &self,
2216 _id: CatalogItemId,
2217 storage_configuration: &StorageConfiguration,
2218 ) -> Result<(), anyhow::Error> {
2219 let client = self
2220 .connect(
2221 storage_configuration,
2222 InTask::No,
2224 )
2225 .await?;
2226 client.list_subjects().await?;
2227 Ok(())
2228 }
2229}
2230
2231impl<C: ConnectionAccess> AlterCompatible for CsrConnection<C> {
2232 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2233 let CsrConnection {
2234 tunnel,
2235 url: _,
2237 tls_root_cert: _,
2238 tls_identity: _,
2239 http_auth: _,
2240 } = self;
2241
2242 let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2243
2244 for (compatible, field) in compatibility_checks {
2245 if !compatible {
2246 tracing::warn!(
2247 "CsrConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2248 self,
2249 other
2250 );
2251
2252 return Err(AlterError { id });
2253 }
2254 }
2255 Ok(())
2256 }
2257}
2258
2259#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2264pub struct GlueSchemaRegistryConnection<C: ConnectionAccess = InlinedConnection> {
2265 pub aws_connection: AwsConnectionReference<C>,
2268 pub registry_name: String,
2270}
2271
2272impl<R: ConnectionResolver> IntoInlineConnection<GlueSchemaRegistryConnection, R>
2273 for GlueSchemaRegistryConnection<ReferencedConnection>
2274{
2275 fn into_inline_connection(self, r: R) -> GlueSchemaRegistryConnection {
2276 let GlueSchemaRegistryConnection {
2277 aws_connection,
2278 registry_name,
2279 } = self;
2280 GlueSchemaRegistryConnection {
2281 aws_connection: aws_connection.into_inline_connection(&r),
2282 registry_name,
2283 }
2284 }
2285}
2286
2287impl<C: ConnectionAccess> GlueSchemaRegistryConnection<C> {
2288 fn validate_by_default(&self) -> bool {
2289 true
2293 }
2294}
2295
2296impl GlueSchemaRegistryConnection {
2297 async fn validate(
2298 &self,
2299 _id: CatalogItemId,
2300 storage_configuration: &StorageConfiguration,
2301 ) -> Result<(), anyhow::Error> {
2302 let enforce_external_addresses =
2303 crate::dyncfgs::ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set());
2304 let sdk_config = self
2305 .aws_connection
2306 .connection
2307 .load_sdk_config(
2308 &storage_configuration.connection_context,
2309 self.aws_connection.connection_id,
2310 InTask::No,
2312 enforce_external_addresses,
2313 )
2314 .await?;
2315 let client = mz_aws_glue_schema_registry::ClientConfig::new(sdk_config).build();
2316 match client.get_registry(&self.registry_name).await {
2317 Ok(_) => Ok(()),
2318 Err(mz_aws_glue_schema_registry::GetRegistryError::NotFound) => Err(anyhow!(
2319 "AWS Glue Schema Registry {:?} does not exist in the configured account/region",
2320 self.registry_name
2321 )),
2322 Err(err) => Err(anyhow::Error::new(err).context(format!(
2323 "failed to validate AWS Glue Schema Registry connection (registry={:?})",
2324 self.registry_name
2325 ))),
2326 }
2327 }
2328}
2329
2330impl<C: ConnectionAccess> AlterCompatible for GlueSchemaRegistryConnection<C> {
2331 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2332 let GlueSchemaRegistryConnection {
2333 registry_name,
2334 aws_connection: _,
2337 } = self;
2338
2339 let compatibility_checks = [(registry_name == &other.registry_name, "registry_name")];
2340
2341 for (compatible, field) in compatibility_checks {
2342 if !compatible {
2343 tracing::warn!(
2344 "GlueSchemaRegistryConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2345 self,
2346 other
2347 );
2348
2349 return Err(AlterError { id });
2350 }
2351 }
2352 Ok(())
2353 }
2354}
2355
2356#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2358pub struct TlsIdentity {
2359 pub cert: StringOrSecret,
2361 pub key: CatalogItemId,
2364}
2365
2366#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2368pub struct CsrConnectionHttpAuth {
2369 pub username: StringOrSecret,
2371 pub password: Option<CatalogItemId>,
2373}
2374
2375#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2377pub struct PostgresConnection<C: ConnectionAccess = InlinedConnection> {
2378 pub host: String,
2380 pub port: u16,
2382 pub database: String,
2384 pub user: StringOrSecret,
2386 pub password: Option<CatalogItemId>,
2388 pub tunnel: Tunnel<C>,
2390 pub tls_mode: SslMode,
2392 pub tls_root_cert: Option<StringOrSecret>,
2395 pub tls_identity: Option<TlsIdentity>,
2397}
2398
2399impl<R: ConnectionResolver> IntoInlineConnection<PostgresConnection, R>
2400 for PostgresConnection<ReferencedConnection>
2401{
2402 fn into_inline_connection(self, r: R) -> PostgresConnection {
2403 let PostgresConnection {
2404 host,
2405 port,
2406 database,
2407 user,
2408 password,
2409 tunnel,
2410 tls_mode,
2411 tls_root_cert,
2412 tls_identity,
2413 } = self;
2414
2415 PostgresConnection {
2416 host,
2417 port,
2418 database,
2419 user,
2420 password,
2421 tunnel: tunnel.into_inline_connection(r),
2422 tls_mode,
2423 tls_root_cert,
2424 tls_identity,
2425 }
2426 }
2427}
2428
2429impl<C: ConnectionAccess> PostgresConnection<C> {
2430 fn validate_by_default(&self) -> bool {
2431 true
2432 }
2433}
2434
2435impl PostgresConnection<InlinedConnection> {
2436 pub async fn config(
2437 &self,
2438 secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2439 storage_configuration: &StorageConfiguration,
2440 in_task: InTask,
2441 ) -> Result<mz_postgres_util::Config, anyhow::Error> {
2442 let params = &storage_configuration.parameters;
2443
2444 let mut config = tokio_postgres::Config::new();
2445 config
2446 .host(&self.host)
2447 .port(self.port)
2448 .dbname(&self.database)
2449 .user(&self.user.get_string(in_task, secrets_reader).await?)
2450 .ssl_mode(self.tls_mode)
2451 .application_name("materialize");
2452 if let Some(password) = self.password {
2453 let password = secrets_reader
2454 .read_string_in_task_if(in_task, password)
2455 .await?;
2456 config.password(password);
2457 }
2458 if let Some(tls_root_cert) = &self.tls_root_cert {
2459 let tls_root_cert = tls_root_cert.get_string(in_task, secrets_reader).await?;
2460 config.ssl_root_cert(tls_root_cert.as_bytes());
2461 }
2462 if let Some(tls_identity) = &self.tls_identity {
2463 let cert = tls_identity
2464 .cert
2465 .get_string(in_task, secrets_reader)
2466 .await?;
2467 let key = secrets_reader
2468 .read_string_in_task_if(in_task, tls_identity.key)
2469 .await?;
2470 config.ssl_cert(cert.as_bytes()).ssl_key(key.as_bytes());
2471 }
2472
2473 if let Some(connect_timeout) = params.pg_source_connect_timeout {
2474 config.connect_timeout(connect_timeout);
2475 }
2476 if let Some(keepalives_retries) = params.pg_source_tcp_keepalives_retries {
2477 config.keepalives_retries(keepalives_retries);
2478 }
2479 if let Some(keepalives_idle) = params.pg_source_tcp_keepalives_idle {
2480 config.keepalives_idle(keepalives_idle);
2481 }
2482 if let Some(keepalives_interval) = params.pg_source_tcp_keepalives_interval {
2483 config.keepalives_interval(keepalives_interval);
2484 }
2485 if let Some(tcp_user_timeout) = params.pg_source_tcp_user_timeout {
2486 config.tcp_user_timeout(tcp_user_timeout);
2487 }
2488
2489 let mut options = vec![];
2490 if let Some(wal_sender_timeout) = params.pg_source_wal_sender_timeout {
2491 options.push(format!(
2492 "--wal_sender_timeout={}",
2493 wal_sender_timeout.as_millis()
2494 ));
2495 };
2496 if params.pg_source_tcp_configure_server {
2497 if let Some(keepalives_retries) = params.pg_source_tcp_keepalives_retries {
2498 options.push(format!("--tcp_keepalives_count={}", keepalives_retries));
2499 }
2500 if let Some(keepalives_idle) = params.pg_source_tcp_keepalives_idle {
2501 options.push(format!(
2502 "--tcp_keepalives_idle={}",
2503 keepalives_idle.as_secs()
2504 ));
2505 }
2506 if let Some(keepalives_interval) = params.pg_source_tcp_keepalives_interval {
2507 options.push(format!(
2508 "--tcp_keepalives_interval={}",
2509 keepalives_interval.as_secs()
2510 ));
2511 }
2512 if let Some(tcp_user_timeout) = params.pg_source_tcp_user_timeout {
2513 options.push(format!(
2514 "--tcp_user_timeout={}",
2515 tcp_user_timeout.as_millis()
2516 ));
2517 }
2518 }
2519 config.options(options.join(" ").as_str());
2520
2521 let tunnel = match &self.tunnel {
2522 Tunnel::Direct => {
2523 let resolved = resolve_address(
2525 &self.host,
2526 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2527 )
2528 .await?;
2529 mz_postgres_util::TunnelConfig::Direct {
2530 resolved_ips: Some(resolved),
2531 }
2532 }
2533 Tunnel::Ssh(SshTunnel {
2534 connection_id,
2535 connection,
2536 }) => {
2537 let secret = secrets_reader
2538 .read_in_task_if(in_task, *connection_id)
2539 .await?;
2540 let key_pair = SshKeyPair::from_bytes(&secret)?;
2541 let resolved = resolve_address(
2543 &connection.host,
2544 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2545 )
2546 .await?;
2547 mz_postgres_util::TunnelConfig::Ssh {
2548 config: SshTunnelConfig {
2549 host: resolved
2550 .iter()
2551 .map(|a| a.to_string())
2552 .collect::<BTreeSet<_>>(),
2553 port: connection.port,
2554 user: connection.user.clone(),
2555 key_pair,
2556 },
2557 }
2558 }
2559 Tunnel::AwsPrivatelink(connection) => {
2560 assert_none!(connection.port);
2561 mz_postgres_util::TunnelConfig::AwsPrivatelink {
2562 connection_id: connection.connection_id,
2563 }
2564 }
2565 Tunnel::AwsPrivatelinks(_) => {
2566 unreachable!("MATCHING broker rules are only available for Kafka connections.");
2567 }
2568 };
2569
2570 Ok(mz_postgres_util::Config::new(
2571 config,
2572 tunnel,
2573 params.ssh_timeout_config,
2574 in_task,
2575 )?)
2576 }
2577
2578 pub async fn validate(
2579 &self,
2580 _id: CatalogItemId,
2581 storage_configuration: &StorageConfiguration,
2582 ) -> Result<mz_postgres_util::Client, anyhow::Error> {
2583 let config = self
2584 .config(
2585 &storage_configuration.connection_context.secrets_reader,
2586 storage_configuration,
2587 InTask::No,
2589 )
2590 .await?;
2591 let client = config
2592 .connect(
2593 "connection validation",
2594 &storage_configuration.connection_context.ssh_tunnel_manager,
2595 )
2596 .await?;
2597
2598 let wal_level = mz_postgres_util::get_wal_level(&client).await?;
2599
2600 if wal_level < mz_postgres_util::replication::WalLevel::Logical {
2601 Err(PostgresConnectionValidationError::InsufficientWalLevel { wal_level })?;
2602 }
2603
2604 let max_wal_senders = mz_postgres_util::get_max_wal_senders(&client).await?;
2605
2606 if max_wal_senders < 1 {
2607 Err(PostgresConnectionValidationError::ReplicationDisabled)?;
2608 }
2609
2610 let available_replication_slots =
2611 mz_postgres_util::available_replication_slots(&client).await?;
2612
2613 if available_replication_slots < 2 {
2615 Err(
2616 PostgresConnectionValidationError::InsufficientReplicationSlotsAvailable {
2617 count: 2,
2618 },
2619 )?;
2620 }
2621
2622 Ok(client)
2623 }
2624}
2625
2626#[derive(Debug, Clone, thiserror::Error)]
2627pub enum PostgresConnectionValidationError {
2628 #[error("PostgreSQL server has insufficient number of replication slots available")]
2629 InsufficientReplicationSlotsAvailable { count: usize },
2630 #[error("server must have wal_level >= logical, but has {wal_level}")]
2631 InsufficientWalLevel {
2632 wal_level: mz_postgres_util::replication::WalLevel,
2633 },
2634 #[error("replication disabled on server")]
2635 ReplicationDisabled,
2636}
2637
2638impl PostgresConnectionValidationError {
2639 pub fn detail(&self) -> Option<String> {
2640 match self {
2641 Self::InsufficientReplicationSlotsAvailable { count } => Some(format!(
2642 "executing this statement requires {} replication slot{}",
2643 count,
2644 if *count == 1 { "" } else { "s" }
2645 )),
2646 _ => None,
2647 }
2648 }
2649
2650 pub fn hint(&self) -> Option<String> {
2651 match self {
2652 Self::InsufficientReplicationSlotsAvailable { .. } => Some(
2653 "you might be able to wait for other sources to finish snapshotting and try again"
2654 .into(),
2655 ),
2656 Self::ReplicationDisabled => Some("set max_wal_senders to a value > 0".into()),
2657 Self::InsufficientWalLevel { .. } => None,
2658 }
2659 }
2660}
2661
2662impl<C: ConnectionAccess> AlterCompatible for PostgresConnection<C> {
2663 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2664 let PostgresConnection {
2665 tunnel,
2666 host: _,
2668 port: _,
2669 database: _,
2670 user: _,
2671 password: _,
2672 tls_mode: _,
2673 tls_root_cert: _,
2674 tls_identity: _,
2675 } = self;
2676
2677 let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2678
2679 for (compatible, field) in compatibility_checks {
2680 if !compatible {
2681 tracing::warn!(
2682 "PostgresConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2683 self,
2684 other
2685 );
2686
2687 return Err(AlterError { id });
2688 }
2689 }
2690 Ok(())
2691 }
2692}
2693
2694#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2696pub enum Tunnel<C: ConnectionAccess = InlinedConnection> {
2697 Direct,
2699 Ssh(SshTunnel<C>),
2701 AwsPrivatelink(AwsPrivatelink),
2703 AwsPrivatelinks(AwsPrivatelinks),
2704}
2705
2706impl<R: ConnectionResolver> IntoInlineConnection<Tunnel, R> for Tunnel<ReferencedConnection> {
2707 fn into_inline_connection(self, r: R) -> Tunnel {
2708 match self {
2709 Tunnel::Direct => Tunnel::Direct,
2710 Tunnel::Ssh(ssh) => Tunnel::Ssh(ssh.into_inline_connection(r)),
2711 Tunnel::AwsPrivatelink(awspl) => Tunnel::AwsPrivatelink(awspl),
2712 Tunnel::AwsPrivatelinks(x) => Tunnel::AwsPrivatelinks(x),
2713 }
2714 }
2715}
2716
2717impl<C: ConnectionAccess> AlterCompatible for Tunnel<C> {
2718 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2719 let compatible = match (self, other) {
2720 (Self::Ssh(s), Self::Ssh(o)) => s.alter_compatible(id, o).is_ok(),
2721 (s, o) => s == o,
2722 };
2723
2724 if !compatible {
2725 tracing::warn!(
2726 "Tunnel incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
2727 self,
2728 other
2729 );
2730
2731 return Err(AlterError { id });
2732 }
2733
2734 Ok(())
2735 }
2736}
2737
2738#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2742pub enum MySqlSslMode {
2743 Disabled,
2744 Required,
2745 VerifyCa,
2746 VerifyIdentity,
2747}
2748
2749#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2751pub struct MySqlConnection<C: ConnectionAccess = InlinedConnection> {
2752 pub host: String,
2754 pub port: u16,
2756 pub user: StringOrSecret,
2758 pub password: Option<CatalogItemId>,
2760 pub tunnel: Tunnel<C>,
2762 pub tls_mode: MySqlSslMode,
2764 pub tls_root_cert: Option<StringOrSecret>,
2767 pub tls_identity: Option<TlsIdentity>,
2769 pub aws_connection: Option<AwsConnectionReference<C>>,
2772}
2773
2774impl<R: ConnectionResolver> IntoInlineConnection<MySqlConnection, R>
2775 for MySqlConnection<ReferencedConnection>
2776{
2777 fn into_inline_connection(self, r: R) -> MySqlConnection {
2778 let MySqlConnection {
2779 host,
2780 port,
2781 user,
2782 password,
2783 tunnel,
2784 tls_mode,
2785 tls_root_cert,
2786 tls_identity,
2787 aws_connection,
2788 } = self;
2789
2790 MySqlConnection {
2791 host,
2792 port,
2793 user,
2794 password,
2795 tunnel: tunnel.into_inline_connection(&r),
2796 tls_mode,
2797 tls_root_cert,
2798 tls_identity,
2799 aws_connection: aws_connection.map(|aws| aws.into_inline_connection(&r)),
2800 }
2801 }
2802}
2803
2804impl<C: ConnectionAccess> MySqlConnection<C> {
2805 fn validate_by_default(&self) -> bool {
2806 true
2807 }
2808}
2809
2810impl MySqlConnection<InlinedConnection> {
2811 pub async fn config(
2812 &self,
2813 secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2814 storage_configuration: &StorageConfiguration,
2815 in_task: InTask,
2816 ) -> Result<mz_mysql_util::Config, anyhow::Error> {
2817 let mut opts = mysql_async::OptsBuilder::default()
2819 .ip_or_hostname(&self.host)
2820 .tcp_port(self.port)
2821 .user(Some(&self.user.get_string(in_task, secrets_reader).await?));
2822
2823 if let Some(password) = self.password {
2824 let password = secrets_reader
2825 .read_string_in_task_if(in_task, password)
2826 .await?;
2827 opts = opts.pass(Some(password));
2828 }
2829
2830 let mut ssl_opts = match self.tls_mode {
2835 MySqlSslMode::Disabled => None,
2836 MySqlSslMode::Required => Some(
2837 mysql_async::SslOpts::default()
2838 .with_danger_accept_invalid_certs(true)
2839 .with_danger_skip_domain_validation(true),
2840 ),
2841 MySqlSslMode::VerifyCa => {
2842 Some(mysql_async::SslOpts::default().with_danger_skip_domain_validation(true))
2843 }
2844 MySqlSslMode::VerifyIdentity => Some(mysql_async::SslOpts::default()),
2845 };
2846
2847 if matches!(
2848 self.tls_mode,
2849 MySqlSslMode::VerifyCa | MySqlSslMode::VerifyIdentity
2850 ) {
2851 if let Some(tls_root_cert) = &self.tls_root_cert {
2852 let tls_root_cert = tls_root_cert.get_string(in_task, secrets_reader).await?;
2853 ssl_opts = ssl_opts.map(|opts| {
2854 opts.with_root_certs(vec![tls_root_cert.as_bytes().to_vec().into()])
2855 });
2856 }
2857 }
2858
2859 if let Some(identity) = &self.tls_identity {
2860 let key = secrets_reader
2861 .read_string_in_task_if(in_task, identity.key)
2862 .await?;
2863 let cert = identity.cert.get_string(in_task, secrets_reader).await?;
2864 let (der, pass) =
2865 mz_tls_util::pkcs12der_from_pem(key.as_bytes(), cert.as_bytes())?.into_parts();
2866
2867 ssl_opts = ssl_opts.map(|opts| {
2869 opts.with_client_identity(Some(
2870 mysql_async::ClientIdentity::new(der.into()).with_password(pass),
2871 ))
2872 });
2873 }
2874
2875 opts = opts.ssl_opts(ssl_opts);
2876
2877 let tunnel = match &self.tunnel {
2878 Tunnel::Direct => {
2879 let resolved = resolve_address(
2881 &self.host,
2882 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2883 )
2884 .await?;
2885 mz_mysql_util::TunnelConfig::Direct {
2886 resolved_ips: Some(resolved),
2887 }
2888 }
2889 Tunnel::Ssh(SshTunnel {
2890 connection_id,
2891 connection,
2892 }) => {
2893 let secret = secrets_reader
2894 .read_in_task_if(in_task, *connection_id)
2895 .await?;
2896 let key_pair = SshKeyPair::from_bytes(&secret)?;
2897 let resolved = resolve_address(
2899 &connection.host,
2900 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2901 )
2902 .await?;
2903 mz_mysql_util::TunnelConfig::Ssh {
2904 config: SshTunnelConfig {
2905 host: resolved
2906 .iter()
2907 .map(|a| a.to_string())
2908 .collect::<BTreeSet<_>>(),
2909 port: connection.port,
2910 user: connection.user.clone(),
2911 key_pair,
2912 },
2913 }
2914 }
2915 Tunnel::AwsPrivatelink(connection) => {
2916 assert_none!(connection.port);
2917 mz_mysql_util::TunnelConfig::AwsPrivatelink {
2918 connection_id: connection.connection_id,
2919 }
2920 }
2921 Tunnel::AwsPrivatelinks(_) => {
2922 unreachable!("MATCHING broker rules are only available for Kafka connections.");
2923 }
2924 };
2925
2926 let aws_config = match self.aws_connection.as_ref() {
2927 None => None,
2928 Some(aws_ref) => Some(
2929 aws_ref
2930 .connection
2931 .load_sdk_config(
2932 &storage_configuration.connection_context,
2933 aws_ref.connection_id,
2934 in_task,
2935 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2936 )
2937 .await?,
2938 ),
2939 };
2940
2941 Ok(mz_mysql_util::Config::new(
2942 opts,
2943 tunnel,
2944 storage_configuration.parameters.ssh_timeout_config,
2945 in_task,
2946 storage_configuration
2947 .parameters
2948 .mysql_source_timeouts
2949 .clone(),
2950 aws_config,
2951 )?)
2952 }
2953
2954 pub async fn validate(
2955 &self,
2956 _id: CatalogItemId,
2957 storage_configuration: &StorageConfiguration,
2958 ) -> Result<MySqlConn, MySqlConnectionValidationError> {
2959 let config = self
2960 .config(
2961 &storage_configuration.connection_context.secrets_reader,
2962 storage_configuration,
2963 InTask::No,
2965 )
2966 .await?;
2967 let mut conn = config
2968 .connect(
2969 "connection validation",
2970 &storage_configuration.connection_context.ssh_tunnel_manager,
2971 )
2972 .await?;
2973
2974 let mut setting_errors = vec![];
2976 let gtid_res = mz_mysql_util::ensure_gtid_consistency(&mut conn).await;
2977 let binlog_res = mz_mysql_util::ensure_full_row_binlog_format(&mut conn).await;
2978 let order_res = mz_mysql_util::ensure_replication_commit_order(&mut conn).await;
2979 for res in [gtid_res, binlog_res, order_res] {
2980 match res {
2981 Err(MySqlError::InvalidSystemSetting {
2982 setting,
2983 expected,
2984 actual,
2985 }) => {
2986 setting_errors.push((setting, expected, actual));
2987 }
2988 Err(err) => Err(err)?,
2989 Ok(()) => {}
2990 }
2991 }
2992 if !setting_errors.is_empty() {
2993 Err(MySqlConnectionValidationError::ReplicationSettingsError(
2994 setting_errors,
2995 ))?;
2996 }
2997
2998 Ok(conn)
2999 }
3000}
3001
3002#[derive(Debug, thiserror::Error)]
3003pub enum MySqlConnectionValidationError {
3004 #[error("Invalid MySQL system replication settings")]
3005 ReplicationSettingsError(Vec<(String, String, String)>),
3006 #[error(transparent)]
3007 Client(#[from] MySqlError),
3008 #[error("{}", .0.display_with_causes())]
3009 Other(#[from] anyhow::Error),
3010}
3011
3012impl MySqlConnectionValidationError {
3013 pub fn detail(&self) -> Option<String> {
3014 match self {
3015 Self::ReplicationSettingsError(settings) => Some(format!(
3016 "Invalid MySQL system replication settings: {}",
3017 itertools::join(
3018 settings.iter().map(|(setting, expected, actual)| format!(
3019 "{}: expected {}, got {}",
3020 setting, expected, actual
3021 )),
3022 "; "
3023 )
3024 )),
3025 _ => None,
3026 }
3027 }
3028
3029 pub fn hint(&self) -> Option<String> {
3030 match self {
3031 Self::ReplicationSettingsError(_) => {
3032 Some("Set the necessary MySQL database system settings.".into())
3033 }
3034 _ => None,
3035 }
3036 }
3037}
3038
3039impl<C: ConnectionAccess> AlterCompatible for MySqlConnection<C> {
3040 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3041 let MySqlConnection {
3042 tunnel,
3043 host: _,
3045 port: _,
3046 user: _,
3047 password: _,
3048 tls_mode: _,
3049 tls_root_cert: _,
3050 tls_identity: _,
3051 aws_connection: _,
3052 } = self;
3053
3054 let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
3055
3056 for (compatible, field) in compatibility_checks {
3057 if !compatible {
3058 tracing::warn!(
3059 "MySqlConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3060 self,
3061 other
3062 );
3063
3064 return Err(AlterError { id });
3065 }
3066 }
3067 Ok(())
3068 }
3069}
3070
3071#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3078pub struct SqlServerConnectionDetails<C: ConnectionAccess = InlinedConnection> {
3079 pub host: String,
3081 pub port: u16,
3083 pub database: String,
3085 pub user: StringOrSecret,
3087 pub password: CatalogItemId,
3089 pub tunnel: Tunnel<C>,
3091 pub encryption: mz_sql_server_util::config::EncryptionLevel,
3093 pub certificate_validation_policy: mz_sql_server_util::config::CertificateValidationPolicy,
3095 pub tls_root_cert: Option<StringOrSecret>,
3097}
3098
3099impl<C: ConnectionAccess> SqlServerConnectionDetails<C> {
3100 fn validate_by_default(&self) -> bool {
3101 true
3102 }
3103}
3104
3105impl SqlServerConnectionDetails<InlinedConnection> {
3106 pub async fn validate(
3108 &self,
3109 _id: CatalogItemId,
3110 storage_configuration: &StorageConfiguration,
3111 ) -> Result<mz_sql_server_util::Client, anyhow::Error> {
3112 let config = self
3113 .resolve_config(
3114 &storage_configuration.connection_context.secrets_reader,
3115 storage_configuration,
3116 InTask::No,
3117 )
3118 .await?;
3119 tracing::debug!(?config, "Validating SQL Server connection");
3120
3121 let mut client = mz_sql_server_util::Client::connect(config).await?;
3122
3123 let mut replication_errors = vec![];
3128 for error in [
3129 mz_sql_server_util::inspect::ensure_database_cdc_enabled(&mut client).await,
3130 mz_sql_server_util::inspect::ensure_snapshot_isolation_enabled(&mut client).await,
3131 mz_sql_server_util::inspect::ensure_sql_server_agent_running(&mut client).await,
3132 ] {
3133 match error {
3134 Err(mz_sql_server_util::SqlServerError::InvalidSystemSetting {
3135 name,
3136 expected,
3137 actual,
3138 }) => replication_errors.push((name, expected, actual)),
3139 Err(other) => Err(other)?,
3140 Ok(()) => (),
3141 }
3142 }
3143 if !replication_errors.is_empty() {
3144 Err(SqlServerConnectionValidationError::ReplicationSettingsError(replication_errors))?;
3145 }
3146
3147 Ok(client)
3148 }
3149
3150 pub async fn resolve_config(
3160 &self,
3161 secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
3162 storage_configuration: &StorageConfiguration,
3163 in_task: InTask,
3164 ) -> Result<mz_sql_server_util::Config, anyhow::Error> {
3165 let dyncfg = storage_configuration.config_set();
3166 let mut inner_config = tiberius::Config::new();
3167
3168 inner_config.host(&self.host);
3170 inner_config.port(self.port);
3171 inner_config.database(self.database.clone());
3172 inner_config.encryption(self.encryption.into());
3173 match self.certificate_validation_policy {
3174 mz_sql_server_util::config::CertificateValidationPolicy::TrustAll => {
3175 inner_config.trust_cert()
3176 }
3177 mz_sql_server_util::config::CertificateValidationPolicy::VerifyCA => {
3178 inner_config.trust_cert_ca_pem(
3179 self.tls_root_cert
3180 .as_ref()
3181 .unwrap()
3182 .get_string(in_task, secrets_reader)
3183 .await
3184 .context("ca certificate")?,
3185 );
3186 }
3187 mz_sql_server_util::config::CertificateValidationPolicy::VerifySystem => (), }
3189
3190 inner_config.application_name("materialize");
3191
3192 let user = self
3194 .user
3195 .get_string(in_task, secrets_reader)
3196 .await
3197 .context("username")?;
3198 let password = secrets_reader
3199 .read_string_in_task_if(in_task, self.password)
3200 .await
3201 .context("password")?;
3202 inner_config.authentication(tiberius::AuthMethod::sql_server(user, password));
3205
3206 let enforce_external_addresses = ENFORCE_EXTERNAL_ADDRESSES.get(dyncfg);
3209
3210 let tunnel = match &self.tunnel {
3211 Tunnel::Direct => {
3212 let resolved_addresses: Vec<SocketAddr> =
3213 resolve_address(&self.host, enforce_external_addresses)
3214 .await?
3215 .into_iter()
3216 .map(|ip| SocketAddr::new(ip, self.port))
3217 .collect();
3218 mz_sql_server_util::config::TunnelConfig::Direct {
3219 resolved_addresses: resolved_addresses.into_boxed_slice(),
3220 }
3221 }
3222 Tunnel::Ssh(SshTunnel {
3223 connection_id,
3224 connection: ssh_connection,
3225 }) => {
3226 let secret = secrets_reader
3227 .read_in_task_if(in_task, *connection_id)
3228 .await
3229 .context("ssh secret")?;
3230 let key_pair = SshKeyPair::from_bytes(&secret).context("ssh key pair")?;
3231 let addresses = resolve_address(&ssh_connection.host, enforce_external_addresses)
3234 .await
3235 .context("ssh tunnel")?;
3236
3237 let config = SshTunnelConfig {
3238 host: addresses.into_iter().map(|a| a.to_string()).collect(),
3239 port: ssh_connection.port,
3240 user: ssh_connection.user.clone(),
3241 key_pair,
3242 };
3243 mz_sql_server_util::config::TunnelConfig::Ssh {
3244 config,
3245 manager: storage_configuration
3246 .connection_context
3247 .ssh_tunnel_manager
3248 .clone(),
3249 timeout: storage_configuration.parameters.ssh_timeout_config.clone(),
3250 host: self.host.clone(),
3251 port: self.port,
3252 }
3253 }
3254 Tunnel::AwsPrivatelink(private_link_connection) => {
3255 assert_none!(private_link_connection.port);
3256 mz_sql_server_util::config::TunnelConfig::AwsPrivatelink {
3257 connection_id: private_link_connection.connection_id,
3258 port: self.port,
3259 }
3260 }
3261 Tunnel::AwsPrivatelinks(_) => {
3262 unreachable!("MATCHING broker rules are only available for Kafka connections.");
3263 }
3264 };
3265
3266 Ok(mz_sql_server_util::Config::new(
3267 inner_config,
3268 tunnel,
3269 in_task,
3270 ))
3271 }
3272}
3273
3274#[derive(Debug, Clone, thiserror::Error)]
3275pub enum SqlServerConnectionValidationError {
3276 #[error("Invalid SQL Server system replication settings")]
3277 ReplicationSettingsError(Vec<(String, String, String)>),
3278}
3279
3280impl SqlServerConnectionValidationError {
3281 pub fn detail(&self) -> Option<String> {
3282 match self {
3283 Self::ReplicationSettingsError(settings) => Some(format!(
3284 "Invalid SQL Server system replication settings: {}",
3285 itertools::join(
3286 settings.iter().map(|(setting, expected, actual)| format!(
3287 "{}: expected {}, got {}",
3288 setting, expected, actual
3289 )),
3290 "; "
3291 )
3292 )),
3293 }
3294 }
3295
3296 pub fn hint(&self) -> Option<String> {
3297 match self {
3298 _ => None,
3299 }
3300 }
3301}
3302
3303impl<R: ConnectionResolver> IntoInlineConnection<SqlServerConnectionDetails, R>
3304 for SqlServerConnectionDetails<ReferencedConnection>
3305{
3306 fn into_inline_connection(self, r: R) -> SqlServerConnectionDetails {
3307 let SqlServerConnectionDetails {
3308 host,
3309 port,
3310 database,
3311 user,
3312 password,
3313 tunnel,
3314 encryption,
3315 certificate_validation_policy,
3316 tls_root_cert,
3317 } = self;
3318
3319 SqlServerConnectionDetails {
3320 host,
3321 port,
3322 database,
3323 user,
3324 password,
3325 tunnel: tunnel.into_inline_connection(&r),
3326 encryption,
3327 certificate_validation_policy,
3328 tls_root_cert,
3329 }
3330 }
3331}
3332
3333impl<C: ConnectionAccess> AlterCompatible for SqlServerConnectionDetails<C> {
3334 fn alter_compatible(
3335 &self,
3336 id: mz_repr::GlobalId,
3337 other: &Self,
3338 ) -> Result<(), crate::controller::AlterError> {
3339 let SqlServerConnectionDetails {
3340 tunnel,
3341 host: _,
3343 port: _,
3344 database: _,
3345 user: _,
3346 password: _,
3347 encryption: _,
3348 certificate_validation_policy: _,
3349 tls_root_cert: _,
3350 } = self;
3351
3352 let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
3353
3354 for (compatible, field) in compatibility_checks {
3355 if !compatible {
3356 tracing::warn!(
3357 "SqlServerConnectionDetails incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3358 self,
3359 other
3360 );
3361
3362 return Err(AlterError { id });
3363 }
3364 }
3365 Ok(())
3366 }
3367}
3368
3369#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3371pub struct SshConnection {
3372 pub host: String,
3373 pub port: u16,
3374 pub user: String,
3375}
3376
3377use self::inline::{
3378 ConnectionAccess, ConnectionResolver, InlinedConnection, IntoInlineConnection,
3379 ReferencedConnection,
3380};
3381
3382impl AlterCompatible for SshConnection {
3383 fn alter_compatible(&self, _id: GlobalId, _other: &Self) -> Result<(), AlterError> {
3384 Ok(())
3386 }
3387}
3388
3389#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3391pub struct AwsPrivatelink {
3392 pub connection_id: CatalogItemId,
3394 pub availability_zone: Option<String>,
3396 pub port: Option<u16>,
3399}
3400
3401impl AlterCompatible for AwsPrivatelink {
3402 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3403 let AwsPrivatelink {
3404 connection_id,
3405 availability_zone: _,
3406 port: _,
3407 } = self;
3408
3409 let compatibility_checks = [(connection_id == &other.connection_id, "connection_id")];
3410
3411 for (compatible, field) in compatibility_checks {
3412 if !compatible {
3413 tracing::warn!(
3414 "AwsPrivatelink incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3415 self,
3416 other
3417 );
3418
3419 return Err(AlterError { id });
3420 }
3421 }
3422
3423 Ok(())
3424 }
3425}
3426
3427#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3428pub struct AwsPrivatelinks {
3429 pub rules: Vec<AwsPrivatelinkRule>,
3433}
3434
3435#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3436pub struct AwsPrivatelinkRule {
3437 pub pattern: ConnectionRulePattern,
3439 pub to: AwsPrivatelink,
3441}
3442
3443#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3445pub struct SshTunnel<C: ConnectionAccess = InlinedConnection> {
3446 pub connection_id: CatalogItemId,
3448 pub connection: C::Ssh,
3450}
3451
3452impl<R: ConnectionResolver> IntoInlineConnection<SshTunnel, R> for SshTunnel<ReferencedConnection> {
3453 fn into_inline_connection(self, r: R) -> SshTunnel {
3454 let SshTunnel {
3455 connection,
3456 connection_id,
3457 } = self;
3458
3459 SshTunnel {
3460 connection: r.resolve_connection(connection).unwrap_ssh(),
3461 connection_id,
3462 }
3463 }
3464}
3465
3466impl SshTunnel<InlinedConnection> {
3467 async fn connect(
3470 &self,
3471 storage_configuration: &StorageConfiguration,
3472 remote_host: &str,
3473 remote_port: u16,
3474 in_task: InTask,
3475 ) -> Result<ManagedSshTunnelHandle, anyhow::Error> {
3476 let resolved = resolve_address(
3478 &self.connection.host,
3479 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3480 )
3481 .await?;
3482 storage_configuration
3483 .connection_context
3484 .ssh_tunnel_manager
3485 .connect(
3486 SshTunnelConfig {
3487 host: resolved
3488 .iter()
3489 .map(|a| a.to_string())
3490 .collect::<BTreeSet<_>>(),
3491 port: self.connection.port,
3492 user: self.connection.user.clone(),
3493 key_pair: SshKeyPair::from_bytes(
3494 &storage_configuration
3495 .connection_context
3496 .secrets_reader
3497 .read_in_task_if(in_task, self.connection_id)
3498 .await?,
3499 )?,
3500 },
3501 remote_host,
3502 remote_port,
3503 storage_configuration.parameters.ssh_timeout_config,
3504 in_task,
3505 )
3506 .await
3507 }
3508}
3509
3510impl<C: ConnectionAccess> AlterCompatible for SshTunnel<C> {
3511 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3512 let SshTunnel {
3513 connection_id,
3514 connection,
3515 } = self;
3516
3517 let compatibility_checks = [
3518 (connection_id == &other.connection_id, "connection_id"),
3519 (
3520 connection.alter_compatible(id, &other.connection).is_ok(),
3521 "connection",
3522 ),
3523 ];
3524
3525 for (compatible, field) in compatibility_checks {
3526 if !compatible {
3527 tracing::warn!(
3528 "SshTunnel incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3529 self,
3530 other
3531 );
3532
3533 return Err(AlterError { id });
3534 }
3535 }
3536
3537 Ok(())
3538 }
3539}
3540
3541impl SshConnection {
3542 #[allow(clippy::unused_async)]
3543 async fn validate(
3544 &self,
3545 id: CatalogItemId,
3546 storage_configuration: &StorageConfiguration,
3547 ) -> Result<(), anyhow::Error> {
3548 let secret = storage_configuration
3549 .connection_context
3550 .secrets_reader
3551 .read_in_task_if(
3552 InTask::No,
3554 id,
3555 )
3556 .await?;
3557 let key_pair = SshKeyPair::from_bytes(&secret)?;
3558
3559 let resolved = resolve_address(
3561 &self.host,
3562 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3563 )
3564 .await?;
3565
3566 let config = SshTunnelConfig {
3567 host: resolved
3568 .iter()
3569 .map(|a| a.to_string())
3570 .collect::<BTreeSet<_>>(),
3571 port: self.port,
3572 user: self.user.clone(),
3573 key_pair,
3574 };
3575 config
3578 .validate(storage_configuration.parameters.ssh_timeout_config)
3579 .await
3580 }
3581
3582 fn validate_by_default(&self) -> bool {
3583 false
3584 }
3585}
3586
3587impl AwsPrivatelinkConnection {
3588 #[allow(clippy::unused_async)]
3589 async fn validate(
3590 &self,
3591 id: CatalogItemId,
3592 storage_configuration: &StorageConfiguration,
3593 ) -> Result<(), ConnectionValidationError> {
3594 Self::check_service_name(&self.service_name)?;
3597
3598 let Some(ref cloud_resource_reader) = storage_configuration
3599 .connection_context
3600 .cloud_resource_reader
3601 else {
3602 return Err(anyhow!("AWS PrivateLink connections are unsupported").into());
3603 };
3604
3605 let status = cloud_resource_reader.read(id).await?;
3607
3608 let availability = status
3609 .conditions
3610 .as_ref()
3611 .and_then(|conditions| conditions.iter().find(|c| c.type_ == "Available"));
3612
3613 match availability {
3614 Some(condition) if condition.status == "True" => Ok(()),
3615 Some(condition) => Err(anyhow!("{}", condition.message).into()),
3616 None => Err(anyhow!("Endpoint availability is unknown").into()),
3617 }
3618 }
3619
3620 fn validate_by_default(&self) -> bool {
3621 false
3622 }
3623}
3624
3625#[cfg(test)]
3626mod tests {
3627 use super::*;
3628
3629 #[mz_ore::test]
3630 fn test_catalog_headers() {
3631 let props = BTreeMap::from_iter(
3632 [
3633 (REST_CATALOG_PROP_URI, "https://catalog.example"),
3634 (REST_CATALOG_PROP_WAREHOUSE, "wh"),
3635 ("header.x-goog-user-project", "some-project"),
3636 (REST_CATALOG_PROP_ACCESS_DELEGATION, "vended-credentials"),
3637 ]
3638 .map(|(k, v)| (k.to_string(), v.to_string())),
3639 );
3640
3641 let headers = IcebergCatalogConnection::catalog_headers(&props).expect("valid headers");
3644 assert_eq!(headers.len(), 2);
3645 assert_eq!(headers["x-goog-user-project"], "some-project");
3646 assert_eq!(headers["x-iceberg-access-delegation"], "vended-credentials");
3647
3648 let props = BTreeMap::from([("header.bad name".to_string(), "v".to_string())]);
3651 assert!(IcebergCatalogConnection::catalog_headers(&props).is_err());
3652 }
3653
3654 #[mz_ore::test]
3655 fn test_check_service_name() {
3656 for name in [
3659 "com.amazonaws.vpce.us-east-1.vpce-svc-0e123abc123198abc",
3660 "com.amazonaws.vpce.test.vpce-svc-e2e-test",
3661 "com.amazonaws.us-east-1.s3",
3662 "com.amazonaws.anything",
3663 ] {
3664 assert_eq!(
3665 AwsPrivatelinkConnection::check_service_name(name),
3666 Ok(()),
3667 "expected {name} to be accepted"
3668 );
3669 }
3670
3671 for name in [
3672 "",
3673 "com.amazonaws",
3674 "vpce-svc-0e123abc123198abc",
3675 "my-db-lb-0123456789abcdef.elb.eu-central-1.amazonaws.com",
3676 "db.internal.example.org",
3677 ] {
3678 let err = AwsPrivatelinkConnection::check_service_name(name)
3679 .expect_err("service name should be rejected");
3680 assert_eq!(err.name, name);
3681 }
3682 }
3683}