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 (Self::SqlServer(s), Self::SqlServer(o)) => s.alter_compatible(id, o),
634 (Self::GlueSchemaRegistry(s), Self::GlueSchemaRegistry(o)) => s.alter_compatible(id, o),
635 (Self::IcebergCatalog(s), Self::IcebergCatalog(o)) => s.alter_compatible(id, o),
636 _ => {
637 tracing::warn!(
638 "Connection incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
639 self,
640 other
641 );
642 Err(AlterError { id })
643 }
644 }
645 }
646}
647
648#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
650pub enum IcebergCatalogAuth<C: ConnectionAccess = InlinedConnection> {
651 OAuth {
653 credential: StringOrSecret,
655 scope: Option<String>,
657 server_url: Option<String>,
664 },
665 Gcp(GcpConnectionReference<C>),
666}
667
668#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
669pub struct RestIcebergCatalog<C: ConnectionAccess = InlinedConnection> {
670 pub auth: IcebergCatalogAuth<C>,
671 pub warehouse: Option<String>,
673 pub access_delegation: Option<IcebergAccessDelegation>,
680 pub storage_provider: IcebergStorageProvider,
685}
686
687#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
690pub enum IcebergAccessDelegation {
691 VendedCredentials,
693}
694
695impl IcebergAccessDelegation {
696 pub fn as_header_value(&self) -> &'static str {
698 match self {
699 IcebergAccessDelegation::VendedCredentials => "vended-credentials",
700 }
701 }
702}
703
704impl<C: ConnectionAccess> AlterCompatible for RestIcebergCatalog<C> {
705 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
706 if (self.warehouse != other.warehouse) || (self.storage_provider != other.storage_provider)
709 {
710 tracing::warn!(
711 "RestIcebergCatalog incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
712 self,
713 other
714 );
715 Err(AlterError { id })
716 } else {
717 Ok(())
718 }
719 }
720}
721
722#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
723pub struct S3TablesRestIcebergCatalog<C: ConnectionAccess = InlinedConnection> {
724 pub aws_connection: AwsConnectionReference<C>,
726 pub warehouse: String,
728}
729
730impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogAuth, R>
731 for IcebergCatalogAuth<ReferencedConnection>
732{
733 fn into_inline_connection(self, r: R) -> IcebergCatalogAuth {
734 match self {
735 IcebergCatalogAuth::Gcp(x) => IcebergCatalogAuth::Gcp(x.into_inline_connection(&r)),
736 IcebergCatalogAuth::OAuth {
737 credential,
738 scope,
739 server_url,
740 } => IcebergCatalogAuth::OAuth {
741 credential,
742 scope,
743 server_url,
744 },
745 }
746 }
747}
748
749impl<R: ConnectionResolver> IntoInlineConnection<RestIcebergCatalog, R>
750 for RestIcebergCatalog<ReferencedConnection>
751{
752 fn into_inline_connection(self, r: R) -> RestIcebergCatalog {
753 RestIcebergCatalog {
754 auth: self.auth.into_inline_connection(&r),
755 warehouse: self.warehouse,
756 access_delegation: self.access_delegation,
757 storage_provider: self.storage_provider,
758 }
759 }
760}
761
762impl<R: ConnectionResolver> IntoInlineConnection<S3TablesRestIcebergCatalog, R>
763 for S3TablesRestIcebergCatalog<ReferencedConnection>
764{
765 fn into_inline_connection(self, r: R) -> S3TablesRestIcebergCatalog {
766 S3TablesRestIcebergCatalog {
767 aws_connection: self.aws_connection.into_inline_connection(&r),
768 warehouse: self.warehouse,
769 }
770 }
771}
772
773impl<C: ConnectionAccess> AlterCompatible for S3TablesRestIcebergCatalog<C> {
774 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
775 if self.aws_connection.connection_id != other.aws_connection.connection_id
776 || self.warehouse != other.warehouse
777 {
778 tracing::warn!(
779 "S3TablesRestIcebergCatalog incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
780 self,
781 other
782 );
783 Err(AlterError { id })
784 } else {
785 self.aws_connection
786 .connection
787 .alter_compatible(id, &other.aws_connection.connection)?;
788 Ok(())
789 }
790 }
791}
792
793#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
794pub enum IcebergCatalogType {
795 Rest,
796 S3TablesRest,
797}
798
799#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
806pub enum IcebergStorageProvider {
807 S3,
808 Gcs,
809 Adls,
810}
811
812impl IcebergStorageProvider {
813 pub fn as_str(&self) -> &'static str {
815 match self {
816 IcebergStorageProvider::S3 => "s3",
817 IcebergStorageProvider::Gcs => "gcs",
818 IcebergStorageProvider::Adls => "adls",
819 }
820 }
821}
822
823impl Default for IcebergStorageProvider {
824 fn default() -> Self {
825 IcebergStorageProvider::S3
826 }
827}
828
829#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
830pub enum IcebergCatalogImpl<C: ConnectionAccess = InlinedConnection> {
831 Rest(RestIcebergCatalog<C>),
832 S3TablesRest(S3TablesRestIcebergCatalog<C>),
833}
834
835impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogImpl, R>
836 for IcebergCatalogImpl<ReferencedConnection>
837{
838 fn into_inline_connection(self, r: R) -> IcebergCatalogImpl {
839 match self {
840 IcebergCatalogImpl::Rest(rest) => {
841 IcebergCatalogImpl::Rest(rest.into_inline_connection(r))
842 }
843 IcebergCatalogImpl::S3TablesRest(s3tables) => {
844 IcebergCatalogImpl::S3TablesRest(s3tables.into_inline_connection(r))
845 }
846 }
847 }
848}
849
850impl<C: ConnectionAccess> AlterCompatible for IcebergCatalogImpl<C> {
851 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
852 match (self, other) {
853 (IcebergCatalogImpl::Rest(rest), IcebergCatalogImpl::Rest(other_rest)) => {
854 rest.alter_compatible(id, other_rest)
855 }
856 (
857 IcebergCatalogImpl::S3TablesRest(s3tables),
858 IcebergCatalogImpl::S3TablesRest(other_s3tables),
859 ) => s3tables.alter_compatible(id, other_s3tables),
860 _ => Err(AlterError { id }),
861 }
862 }
863}
864
865#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
866pub struct IcebergCatalogConnection<C: ConnectionAccess = InlinedConnection> {
867 pub catalog: IcebergCatalogImpl<C>,
869 pub uri: reqwest_0_12::Url,
871}
872
873impl<C: ConnectionAccess> AlterCompatible for IcebergCatalogConnection<C> {
874 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
875 let compatibility_checks = [
876 (
877 self.catalog.alter_compatible(id, &other.catalog).is_ok(),
878 "catalog",
879 ),
880 (self.uri == other.uri, "uri"),
881 ];
882 for (is_ok, field) in compatibility_checks {
883 if !is_ok {
884 tracing::warn!(
885 "IcebergCatalogConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
886 self,
887 other
888 );
889 return Err(AlterError { id });
890 }
891 }
892 Ok(())
893 }
894}
895
896impl<R: ConnectionResolver> IntoInlineConnection<IcebergCatalogConnection, R>
897 for IcebergCatalogConnection<ReferencedConnection>
898{
899 fn into_inline_connection(self, r: R) -> IcebergCatalogConnection {
900 IcebergCatalogConnection {
901 catalog: self.catalog.into_inline_connection(&r),
902 uri: self.uri,
903 }
904 }
905}
906
907impl<C: ConnectionAccess> IcebergCatalogConnection<C> {
908 fn validate_by_default(&self) -> bool {
909 true
910 }
911}
912
913impl IcebergCatalogConnection<InlinedConnection> {
914 pub async fn connect(
921 &self,
922 storage_configuration: &StorageConfiguration,
923 in_task: InTask,
924 table: Option<&TableIdent>,
925 ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
926 match self.catalog {
927 IcebergCatalogImpl::S3TablesRest(ref s3tables) => {
928 self.connect_s3tables(s3tables, storage_configuration, in_task)
931 .await
932 }
933 IcebergCatalogImpl::Rest(ref rest) => {
934 self.connect_rest(rest, storage_configuration, in_task, table)
935 .await
936 }
937 }
938 }
939
940 pub fn catalog_type(&self) -> IcebergCatalogType {
941 match self.catalog {
942 IcebergCatalogImpl::S3TablesRest(_) => IcebergCatalogType::S3TablesRest,
943 IcebergCatalogImpl::Rest(_) => IcebergCatalogType::Rest,
944 }
945 }
946
947 pub fn s3tables_catalog(&self) -> Option<&S3TablesRestIcebergCatalog> {
948 match &self.catalog {
949 IcebergCatalogImpl::S3TablesRest(s3tables) => Some(s3tables),
950 IcebergCatalogImpl::Rest(_) => None,
951 }
952 }
953
954 pub fn rest_catalog(&self) -> Option<&RestIcebergCatalog> {
955 match &self.catalog {
956 IcebergCatalogImpl::Rest(rest) => Some(rest),
957 IcebergCatalogImpl::S3TablesRest(_) => None,
958 }
959 }
960
961 async fn connect_s3tables(
962 &self,
963 s3tables: &S3TablesRestIcebergCatalog,
964 storage_configuration: &StorageConfiguration,
965 in_task: InTask,
966 ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
967 let secret_reader = &storage_configuration.connection_context.secrets_reader;
968 let aws_ref = &s3tables.aws_connection;
969
970 let aws_region = aws_ref
971 .connection
972 .region
973 .clone()
974 .unwrap_or_else(|| "us-east-1".to_string());
975
976 let mut props = vec![
977 (S3_REGION.to_string(), aws_region.clone()),
978 (S3_DISABLE_EC2_METADATA.to_string(), "true".to_string()),
979 (
980 REST_CATALOG_PROP_WAREHOUSE.to_string(),
981 s3tables.warehouse.clone(),
982 ),
983 (REST_CATALOG_PROP_URI.to_string(), self.uri.to_string()),
984 ];
985
986 let aws_auth = aws_ref.connection.auth.clone();
987
988 if let AwsAuth::Credentials(creds) = &aws_auth {
989 props.push((
990 S3_ACCESS_KEY_ID.to_string(),
991 creds
992 .access_key_id
993 .get_string(in_task, secret_reader)
994 .await?,
995 ));
996 props.push((
997 S3_SECRET_ACCESS_KEY.to_string(),
998 secret_reader.read_string(creds.secret_access_key).await?,
999 ));
1000 }
1001
1002 let credentials_provider = match &aws_auth {
1011 AwsAuth::AssumeRole(assume_role) => {
1016 aws_ref.connection.validate_endpoint(
1017 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1018 )?;
1019 assume_role
1020 .prefetch_credentials(
1021 &storage_configuration.connection_context,
1022 aws_ref.connection_id,
1023 storage_configuration.config_set(),
1024 format!("aws-connection-{}", aws_ref.connection_id),
1025 )
1026 .await
1027 .with_context(|| {
1028 format!(
1029 "failed to initialize AssumeRole credentials for S3 Tables Iceberg \
1030 catalog (catalog uri: {}, warehouse: {})",
1031 self.uri, s3tables.warehouse
1032 )
1033 })?
1034 }
1035 AwsAuth::Credentials(_) => {
1036 let aws_config = aws_ref
1037 .connection
1038 .load_sdk_config(
1039 &storage_configuration.connection_context,
1040 aws_ref.connection_id,
1041 in_task,
1042 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1043 )
1044 .await
1045 .with_context(|| {
1046 format!(
1047 "failed to load AWS SDK config for S3 Tables Iceberg catalog \
1048 (connection id: {}, auth method: {}, catalog uri: {}, warehouse: {})",
1049 aws_ref.connection_id,
1050 aws_ref.connection.auth_method(),
1051 self.uri,
1052 s3tables.warehouse
1053 )
1054 })?;
1055 aws_config
1056 .credentials_provider()
1057 .ok_or_else(|| anyhow!("aws_config missing credentials provider"))?
1058 }
1059 };
1060
1061 let authenticator = Arc::new(Sigv4Authenticator {
1062 provider: credentials_provider.clone(),
1063 region: aws_region.clone(),
1064 signing_name: "s3tables".to_string(),
1065 });
1066
1067 let customized_credential_load = if matches!(aws_auth, AwsAuth::AssumeRole(_)) {
1070 Some(CustomAwsCredentialLoader::new(AwsSdkCredentialLoader::new(
1071 credentials_provider,
1072 )))
1073 } else {
1074 None
1075 };
1076
1077 let storage_factory = Arc::new(OpenDalStorageFactory::S3 {
1078 customized_credential_load,
1079 });
1080
1081 let catalog = RestCatalogBuilder::default()
1082 .with_storage_factory(storage_factory)
1083 .with_authenticator(authenticator)
1084 .load("IcebergCatalog", props.into_iter().collect())
1085 .await
1086 .with_context(|| {
1087 format!(
1088 "failed to create S3 Tables Iceberg catalog \
1089 (connection id: {}, catalog uri: {}, warehouse: {})",
1090 aws_ref.connection_id, self.uri, s3tables.warehouse
1091 )
1092 })?;
1093
1094 Ok(Arc::new(catalog))
1095 }
1096
1097 fn catalog_headers(props: &BTreeMap<String, String>) -> Result<HeaderMap, anyhow::Error> {
1104 props
1105 .iter()
1106 .filter_map(|(k, v)| {
1107 k.strip_prefix(REST_CATALOG_HEADER_PROP_PREFIX)
1108 .map(|name| (name, v))
1109 })
1110 .map(|(name, value)| {
1111 let name = HeaderName::try_from(name)
1112 .with_context(|| format!("invalid Iceberg catalog header name: {name}"))?;
1113 let value = HeaderValue::try_from(value)
1114 .with_context(|| format!("invalid Iceberg catalog header value for {name}"))?;
1115 Ok((name, value))
1116 })
1117 .collect()
1118 }
1119
1120 async fn vended_credential_endpoint(
1128 &self,
1129 rest: &RestIcebergCatalog,
1130 client: &reqwest_0_12::Client,
1131 token: &Arc<dyn TokenProvider>,
1132 headers: &HeaderMap,
1133 table: Option<&TableIdent>,
1134 ) -> Result<Option<Url>, anyhow::Error> {
1135 match (&rest.access_delegation, table) {
1136 (Some(IcebergAccessDelegation::VendedCredentials), Some(table)) => Ok(Some(
1137 iceberg_credentials::table_credentials_endpoint(
1138 &self.uri,
1139 client,
1140 token,
1141 headers,
1142 rest.warehouse.as_deref(),
1143 table,
1144 )
1145 .await?,
1146 )),
1147 _ => Ok(None),
1148 }
1149 }
1150
1151 fn gcs_storage_factory(
1153 endpoint: Option<Url>,
1154 client: &reqwest_0_12::Client,
1155 token: &Arc<dyn TokenProvider>,
1156 headers: &HeaderMap,
1157 ) -> OpenDalStorageFactory {
1158 OpenDalStorageFactory::Gcs {
1159 customized_credential_load: endpoint.map(|endpoint| {
1160 CustomGcsCredentialLoader::new(iceberg_credentials::VendedCredentialLoader::new(
1161 client.clone(),
1162 endpoint,
1163 Arc::clone(token),
1164 headers.clone(),
1165 ))
1166 }),
1167 }
1168 }
1169
1170 async fn connect_rest(
1171 &self,
1172 rest: &RestIcebergCatalog,
1173 storage_configuration: &StorageConfiguration,
1174 in_task: InTask,
1175 table: Option<&TableIdent>,
1176 ) -> Result<Arc<dyn Catalog>, anyhow::Error> {
1177 let mut props = BTreeMap::from([(
1178 REST_CATALOG_PROP_URI.to_string(),
1179 self.uri.to_string().clone(),
1180 )]);
1181
1182 if let Some(warehouse) = &rest.warehouse {
1183 props.insert(REST_CATALOG_PROP_WAREHOUSE.to_string(), warehouse.clone());
1184 }
1185
1186 let client = reqwest_0_12::Client::new();
1189
1190 let (storage_factory, custom_authenticator) = match &rest.auth {
1194 IcebergCatalogAuth::OAuth {
1195 credential,
1196 scope,
1197 server_url,
1198 } => {
1199 let credential = credential
1200 .get_string(
1201 in_task,
1202 &storage_configuration.connection_context.secrets_reader,
1203 )
1204 .await
1205 .map_err(|e| anyhow!("failed to read Iceberg catalog credential: {e}"))?;
1206
1207 if let Some(server_url) = server_url {
1208 let url = Url::parse(server_url).with_context(|| {
1218 format!("invalid OAUTH2 SERVER URL for Iceberg catalog: {server_url}")
1219 })?;
1220 let host = url.host_str().ok_or_else(|| {
1221 anyhow!("OAUTH2 SERVER URL for Iceberg catalog has no host: {server_url}")
1222 })?;
1223 resolve_address(
1224 host,
1225 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1226 )
1227 .await
1228 .with_context(|| {
1229 format!("OAUTH2 SERVER URL for Iceberg catalog is not resolvable to an external address: {server_url}")
1230 })?;
1231
1232 props.insert(
1233 REST_CATALOG_PROP_OAUTH2_SERVER_URI.to_string(),
1234 server_url.clone(),
1235 );
1236 }
1237
1238 let token_endpoint = match server_url {
1241 Some(server_url) => server_url.clone(),
1242 None => format!(
1244 "{}/v1/oauth/tokens",
1245 self.uri.as_str().trim_end_matches('/')
1246 ),
1247 };
1248 let (client_id, client_secret) = match credential.split_once(':') {
1249 Some((client_id, client_secret)) => {
1250 (Some(client_id.to_string()), client_secret.to_string())
1251 }
1252 None => (None, credential),
1253 };
1254 let oauth_params = BTreeMap::from([(
1255 OAUTH2_PARAM_SCOPE.to_string(),
1256 scope.clone().unwrap_or_else(|| "catalog".to_string()),
1258 )]);
1259 let token: Arc<dyn TokenProvider> = Arc::new(OAuth2TokenProvider::new(
1260 client.clone(),
1261 client_id,
1262 client_secret,
1263 token_endpoint,
1264 HeaderMap::new(),
1267 oauth_params.into_iter().collect(),
1268 ));
1269
1270 let headers = Self::catalog_headers(&props)?;
1271 let endpoint = self
1272 .vended_credential_endpoint(rest, &client, &token, &headers, table)
1273 .await?;
1274
1275 (
1276 match rest.storage_provider {
1279 IcebergStorageProvider::S3 => OpenDalStorageFactory::S3 {
1286 customized_credential_load: endpoint.map(|endpoint| {
1287 CustomAwsCredentialLoader::new(
1288 iceberg_credentials::VendedCredentialLoader::new(
1289 client.clone(),
1290 endpoint,
1291 Arc::clone(&token),
1292 headers.clone(),
1293 ),
1294 )
1295 }),
1296 },
1297 IcebergStorageProvider::Gcs => {
1298 Self::gcs_storage_factory(endpoint, &client, &token, &headers)
1299 }
1300 IcebergStorageProvider::Adls => OpenDalStorageFactory::Azdls {
1301 customized_credential_load: endpoint.map(|endpoint| {
1302 CustomAzdlsCredentialLoader::new(
1303 iceberg_credentials::VendedCredentialLoader::new(
1304 client.clone(),
1305 endpoint,
1306 Arc::clone(&token),
1307 headers.clone(),
1308 ),
1309 )
1310 }),
1311 },
1312 },
1313 Some(iceberg_catalog_rest::BearerTokenAuthenticator::new(token)),
1317 )
1318 }
1319 IcebergCatalogAuth::Gcp(gcp_connection_reference) => {
1320 let (creds_json, service_account) = gcp_connection_reference
1321 .connection
1322 .read_credentials(storage_configuration)
1323 .await
1324 .map_err(|e| anyhow!("failed to parse GCP service account JSON: {e}"))?;
1325
1326 props.insert(
1327 GCS_CREDENTIALS_JSON.to_owned(),
1328 base64::engine::general_purpose::STANDARD.encode(creds_json),
1329 );
1330 props.insert(GCS_DISABLE_VM_METADATA.to_owned(), "true".to_owned());
1332 props.insert(GCS_DISABLE_CONFIG_LOAD.to_owned(), "true".to_owned());
1333 if let Some(project_id) = service_account.project_id() {
1334 props.insert(GCS_USER_PROJECT.to_owned(), project_id.to_owned());
1335 props.insert(
1336 "header.x-goog-user-project".to_owned(),
1337 project_id.to_owned(),
1338 );
1339 }
1340
1341 let token: Arc<dyn TokenProvider> = Arc::new(GcpTokenProvider { service_account });
1344 let headers = Self::catalog_headers(&props)?;
1345 let endpoint = self
1346 .vended_credential_endpoint(rest, &client, &token, &headers, table)
1347 .await?;
1348
1349 (
1350 Self::gcs_storage_factory(endpoint, &client, &token, &headers),
1358 Some(iceberg_catalog_rest::BearerTokenAuthenticator::new(token)),
1359 )
1360 }
1361 };
1362
1363 if let Some(delegation) = &rest.access_delegation {
1367 props.insert(
1368 REST_CATALOG_PROP_ACCESS_DELEGATION.to_string(),
1369 delegation.as_header_value().to_string(),
1370 );
1371 }
1372
1373 let mut catalog = RestCatalogBuilder::default()
1374 .with_storage_factory(Arc::new(storage_factory))
1375 .with_client(client);
1376 if let Some(auth) = custom_authenticator {
1377 catalog = catalog.with_authenticator(Arc::new(auth));
1378 }
1379 let catalog = catalog
1380 .load("IcebergCatalog", props.into_iter().collect())
1381 .await
1382 .map_err(|e| anyhow!("failed to create Iceberg catalog: {e}"))?;
1383 Ok(Arc::new(catalog))
1384 }
1385
1386 async fn validate(
1387 &self,
1388 _id: CatalogItemId,
1389 storage_configuration: &StorageConfiguration,
1390 ) -> Result<(), ConnectionValidationError> {
1391 let catalog = self
1393 .connect(storage_configuration, InTask::No, None)
1394 .await
1395 .map_err(|e| {
1396 ConnectionValidationError::Other(anyhow!("failed to connect to catalog: {e}"))
1397 })?;
1398
1399 catalog.list_namespaces(None).await.map_err(|e| {
1401 ConnectionValidationError::Other(anyhow!("failed to list namespaces: {e}"))
1402 })?;
1403
1404 Ok(())
1405 }
1406}
1407
1408#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1409pub struct AwsPrivatelinkConnection {
1410 pub service_name: String,
1411 pub availability_zones: Vec<String>,
1412}
1413
1414impl AlterCompatible for AwsPrivatelinkConnection {
1415 fn alter_compatible(&self, _id: GlobalId, _other: &Self) -> Result<(), AlterError> {
1416 Ok(())
1418 }
1419}
1420
1421#[derive(Clone, Debug, Eq, PartialEq)]
1423pub struct InvalidAwsPrivatelinkServiceName {
1424 pub name: String,
1425}
1426
1427impl fmt::Display for InvalidAwsPrivatelinkServiceName {
1428 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1429 write!(
1430 f,
1431 "invalid AWS PrivateLink service name {}",
1432 self.name.quoted()
1433 )
1434 }
1435}
1436
1437impl std::error::Error for InvalidAwsPrivatelinkServiceName {}
1438
1439impl InvalidAwsPrivatelinkServiceName {
1440 pub fn hint(&self) -> String {
1442 "SERVICE NAME must name an AWS VPC endpoint service, for example \
1443 `com.amazonaws.vpce.us-east-1.vpce-svc-0e123abc123198abc`. Endpoint service names are \
1444 listed in the AWS console under VPC > Endpoint services."
1445 .into()
1446 }
1447}
1448
1449impl AwsPrivatelinkConnection {
1450 pub fn check_service_name(service_name: &str) -> Result<(), InvalidAwsPrivatelinkServiceName> {
1457 if service_name.starts_with("com.amazonaws.") {
1458 return Ok(());
1459 }
1460 Err(InvalidAwsPrivatelinkServiceName {
1461 name: service_name.to_string(),
1462 })
1463 }
1464}
1465
1466#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1467pub struct KafkaTlsConfig {
1468 pub identity: Option<TlsIdentity>,
1469 pub root_cert: Option<StringOrSecret>,
1470}
1471
1472#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1473pub struct KafkaSaslConfig<C: ConnectionAccess = InlinedConnection> {
1474 pub mechanism: String,
1475 pub username: StringOrSecret,
1476 pub password: Option<CatalogItemId>,
1477 pub aws: Option<AwsConnectionReference<C>>,
1478}
1479
1480impl<R: ConnectionResolver> IntoInlineConnection<KafkaSaslConfig, R>
1481 for KafkaSaslConfig<ReferencedConnection>
1482{
1483 fn into_inline_connection(self, r: R) -> KafkaSaslConfig {
1484 KafkaSaslConfig {
1485 mechanism: self.mechanism,
1486 username: self.username,
1487 password: self.password,
1488 aws: self.aws.map(|aws| aws.into_inline_connection(&r)),
1489 }
1490 }
1491}
1492
1493#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1495pub struct KafkaBroker<C: ConnectionAccess = InlinedConnection> {
1496 pub address: String,
1498 pub tunnel: Tunnel<C>,
1500}
1501
1502impl<R: ConnectionResolver> IntoInlineConnection<KafkaBroker, R>
1503 for KafkaBroker<ReferencedConnection>
1504{
1505 fn into_inline_connection(self, r: R) -> KafkaBroker {
1506 let KafkaBroker { address, tunnel } = self;
1507 KafkaBroker {
1508 address,
1509 tunnel: tunnel.into_inline_connection(r),
1510 }
1511 }
1512}
1513
1514#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize, Default)]
1515pub struct KafkaTopicOptions {
1516 pub replication_factor: Option<NonNeg<i32>>,
1519 pub partition_count: Option<NonNeg<i32>>,
1522 pub topic_config: BTreeMap<String, String>,
1524}
1525
1526#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
1527pub struct KafkaConnection<C: ConnectionAccess = InlinedConnection> {
1528 pub brokers: Vec<KafkaBroker<C>>,
1529 pub default_tunnel: Tunnel<C>,
1533 pub progress_topic: Option<String>,
1534 pub progress_topic_options: KafkaTopicOptions,
1535 pub options: BTreeMap<String, StringOrSecret>,
1536 pub tls: Option<KafkaTlsConfig>,
1537 pub sasl: Option<KafkaSaslConfig<C>>,
1538}
1539
1540impl<R: ConnectionResolver> IntoInlineConnection<KafkaConnection, R>
1541 for KafkaConnection<ReferencedConnection>
1542{
1543 fn into_inline_connection(self, r: R) -> KafkaConnection {
1544 let KafkaConnection {
1545 brokers,
1546 progress_topic,
1547 progress_topic_options,
1548 default_tunnel,
1549 options,
1550 tls,
1551 sasl,
1552 } = self;
1553
1554 let brokers = brokers
1555 .into_iter()
1556 .map(|broker| broker.into_inline_connection(&r))
1557 .collect();
1558
1559 KafkaConnection {
1560 brokers,
1561 progress_topic,
1562 progress_topic_options,
1563 default_tunnel: default_tunnel.into_inline_connection(&r),
1564 options,
1565 tls,
1566 sasl: sasl.map(|sasl| sasl.into_inline_connection(&r)),
1567 }
1568 }
1569}
1570
1571impl<C: ConnectionAccess> KafkaConnection<C> {
1572 pub fn progress_topic(
1582 &self,
1583 connection_context: &ConnectionContext,
1584 connection_id: CatalogItemId,
1585 ) -> Cow<'_, str> {
1586 if let Some(progress_topic) = &self.progress_topic {
1587 Cow::Borrowed(progress_topic)
1588 } else {
1589 Cow::Owned(format!(
1590 "_materialize-progress-{}-{}",
1591 connection_context.environment_id, connection_id,
1592 ))
1593 }
1594 }
1595
1596 fn validate_by_default(&self) -> bool {
1597 true
1598 }
1599}
1600
1601impl KafkaConnection {
1602 pub fn id_base(
1613 connection_context: &ConnectionContext,
1614 connection_id: CatalogItemId,
1615 object_id: GlobalId,
1616 ) -> String {
1617 format!(
1618 "materialize-{}-{}-{}",
1619 connection_context.environment_id, connection_id, object_id,
1620 )
1621 }
1622
1623 pub fn enrich_client_id(&self, configs: &ConfigSet, client_id: &mut String) {
1626 #[derive(Debug, Deserialize)]
1627 struct EnrichmentRule {
1628 #[serde(deserialize_with = "deserialize_regex")]
1629 pattern: Regex,
1630 payload: String,
1631 }
1632
1633 fn deserialize_regex<'de, D>(deserializer: D) -> Result<Regex, D::Error>
1634 where
1635 D: Deserializer<'de>,
1636 {
1637 let buf = String::deserialize(deserializer)?;
1638 Regex::new(&buf).map_err(serde::de::Error::custom)
1639 }
1640
1641 let rules = KAFKA_CLIENT_ID_ENRICHMENT_RULES.get(configs);
1642 let rules = match serde_json::from_value::<Vec<EnrichmentRule>>(rules) {
1643 Ok(rules) => rules,
1644 Err(e) => {
1645 warn!(%e, "failed to decode kafka_client_id_enrichment_rules");
1646 return;
1647 }
1648 };
1649
1650 debug!(?self.brokers, "evaluating client ID enrichment rules");
1655 for rule in rules {
1656 let is_match = self
1657 .brokers
1658 .iter()
1659 .any(|b| rule.pattern.is_match(&b.address));
1660 debug!(?rule, is_match, "evaluated client ID enrichment rule");
1661 if is_match {
1662 client_id.push('-');
1663 client_id.push_str(&rule.payload);
1664 }
1665 }
1666 }
1667
1668 pub async fn create_with_context<C, T>(
1670 &self,
1671 storage_configuration: &StorageConfiguration,
1672 context: C,
1673 extra_options: &BTreeMap<&str, String>,
1674 in_task: InTask,
1675 ) -> Result<T, ContextCreationError>
1676 where
1677 C: ClientContext,
1678 T: FromClientConfigAndContext<TunnelingClientContext<C>>,
1679 {
1680 let mut options = self.options.clone();
1681
1682 options.insert("allow.auto.create.topics".into(), "false".into());
1687
1688 let brokers = match &self.default_tunnel {
1689 Tunnel::AwsPrivatelink(t) => {
1690 assert!(&self.brokers.is_empty());
1691
1692 let algo = KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM
1693 .get(storage_configuration.config_set());
1694 options.insert("ssl.endpoint.identification.algorithm".into(), algo.into());
1695
1696 format!(
1699 "{}:{}",
1700 vpc_endpoint_host(
1701 t.connection_id,
1702 None, ),
1704 t.port.unwrap_or(9092)
1705 )
1706 }
1707 Tunnel::AwsPrivatelinks(_pl) => {
1708 let algo = KAFKA_DEFAULT_AWS_PRIVATELINK_ENDPOINT_IDENTIFICATION_ALGORITHM
1709 .get(storage_configuration.config_set());
1710 options.insert("ssl.endpoint.identification.algorithm".into(), algo.into());
1711
1712 if self.brokers.is_empty() {
1713 return Err(ContextCreationError::Other(anyhow::anyhow!(
1714 "at least one static broker is required when using BROKER or BROKERS"
1715 )));
1716 }
1717 self.brokers.iter().map(|b| &b.address).join(",")
1718 }
1719 _ => self.brokers.iter().map(|b| &b.address).join(","),
1720 };
1721 options.insert("bootstrap.servers".into(), brokers.clone().into());
1722 let security_protocol = match (self.tls.is_some(), self.sasl.is_some()) {
1723 (false, false) => "PLAINTEXT",
1724 (true, false) => "SSL",
1725 (false, true) => "SASL_PLAINTEXT",
1726 (true, true) => "SASL_SSL",
1727 };
1728 info!(
1729 "kafka: create_with_context bootstrap.servers={brokers}, security_protocol={security_protocol}"
1730 );
1731 options.insert("security.protocol".into(), security_protocol.into());
1732 if let Some(tls) = &self.tls {
1733 if let Some(root_cert) = &tls.root_cert {
1734 options.insert("ssl.ca.pem".into(), root_cert.clone());
1735 }
1736 if let Some(identity) = &tls.identity {
1737 options.insert("ssl.key.pem".into(), StringOrSecret::Secret(identity.key));
1738 options.insert("ssl.certificate.pem".into(), identity.cert.clone());
1739 }
1740 }
1741 if let Some(sasl) = &self.sasl {
1742 options.insert("sasl.mechanisms".into(), (&sasl.mechanism).into());
1743 options.insert("sasl.username".into(), sasl.username.clone());
1744 if let Some(password) = sasl.password {
1745 options.insert("sasl.password".into(), StringOrSecret::Secret(password));
1746 }
1747 }
1748
1749 options.insert(
1750 "retry.backoff.ms".into(),
1751 KAFKA_RETRY_BACKOFF
1752 .get(storage_configuration.config_set())
1753 .as_millis()
1754 .into(),
1755 );
1756 options.insert(
1757 "retry.backoff.max.ms".into(),
1758 KAFKA_RETRY_BACKOFF_MAX
1759 .get(storage_configuration.config_set())
1760 .as_millis()
1761 .into(),
1762 );
1763 options.insert(
1764 "reconnect.backoff.ms".into(),
1765 KAFKA_RECONNECT_BACKOFF
1766 .get(storage_configuration.config_set())
1767 .as_millis()
1768 .into(),
1769 );
1770 options.insert(
1771 "reconnect.backoff.max.ms".into(),
1772 KAFKA_RECONNECT_BACKOFF_MAX
1773 .get(storage_configuration.config_set())
1774 .as_millis()
1775 .into(),
1776 );
1777
1778 let mut config = mz_kafka_util::client::create_new_client_config(
1779 storage_configuration
1780 .connection_context
1781 .librdkafka_log_level,
1782 storage_configuration.parameters.kafka_timeout_config,
1783 );
1784 for (k, v) in options {
1785 config.set(
1786 k,
1787 v.get_string(
1788 in_task,
1789 &storage_configuration.connection_context.secrets_reader,
1790 )
1791 .await
1792 .context("reading kafka secret")?,
1793 );
1794 }
1795 for (k, v) in extra_options {
1796 config.set(*k, v);
1797 }
1798
1799 let aws_config = match self.sasl.as_ref().and_then(|sasl| sasl.aws.as_ref()) {
1800 None => None,
1801 Some(aws) => Some(
1802 aws.connection
1803 .load_sdk_config(
1804 &storage_configuration.connection_context,
1805 aws.connection_id,
1806 in_task,
1807 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1808 )
1809 .await?,
1810 ),
1811 };
1812
1813 let mut context = TunnelingClientContext::new(
1814 context,
1815 Handle::current(),
1816 storage_configuration
1817 .connection_context
1818 .ssh_tunnel_manager
1819 .clone(),
1820 storage_configuration.parameters.ssh_timeout_config,
1821 aws_config,
1822 in_task,
1823 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1824 );
1825
1826 match &self.default_tunnel {
1827 Tunnel::Direct => {
1828 }
1830 Tunnel::AwsPrivatelink(pl) => {
1831 context.set_default_tunnel(TunnelConfig::StaticHost(
1832 KafkaConnection::from_default_aws_privatelink(pl).host,
1834 ));
1835 }
1836 Tunnel::AwsPrivatelinks(pl) => {
1837 context.set_default_tunnel(TunnelConfig::Rules(
1838 KafkaConnection::from_aws_privatelinks(pl),
1839 ));
1840 }
1841 Tunnel::Ssh(ssh_tunnel) => {
1842 let secret = storage_configuration
1843 .connection_context
1844 .secrets_reader
1845 .read_in_task_if(in_task, ssh_tunnel.connection_id)
1846 .await?;
1847 let key_pair = SshKeyPair::from_bytes(&secret)?;
1848
1849 let resolved = resolve_address(
1851 &ssh_tunnel.connection.host,
1852 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1853 )
1854 .await?;
1855 context.set_default_tunnel(TunnelConfig::Ssh(SshTunnelConfig {
1856 host: resolved
1857 .iter()
1858 .map(|a| a.to_string())
1859 .collect::<BTreeSet<_>>(),
1860 port: ssh_tunnel.connection.port,
1861 user: ssh_tunnel.connection.user.clone(),
1862 key_pair,
1863 }));
1864 }
1865 }
1866 info!(
1867 "kafka: tunnel config set to {}",
1868 match &self.default_tunnel {
1869 Tunnel::Direct => "Direct".to_string(),
1870 Tunnel::AwsPrivatelink(_) => "AwsPrivatelink (static host)".to_string(),
1871 Tunnel::AwsPrivatelinks(pl) =>
1872 format!("AwsPrivatelinks ({} rules)", pl.rules.len()),
1873 Tunnel::Ssh(_) => "Ssh".to_string(),
1874 }
1875 );
1876
1877 for broker in &self.brokers {
1880 let mut addr_parts = broker.address.splitn(2, ':');
1881 let addr = BrokerAddr {
1882 host: addr_parts
1883 .next()
1884 .context("BROKER is not address:port")?
1885 .into(),
1886 port: addr_parts
1887 .next()
1888 .unwrap_or("9092")
1889 .parse()
1890 .context("parsing BROKER port")?,
1891 };
1892 match &broker.tunnel {
1893 Tunnel::Direct => {
1894 }
1904 Tunnel::AwsPrivatelink(aws_privatelink) => {
1905 context.add_broker_rewrite(
1906 addr,
1907 KafkaConnection::from_aws_privatelink(aws_privatelink),
1908 );
1909 }
1910 Tunnel::AwsPrivatelinks(_) => unreachable!(
1911 "Individually predefined brokers do not use rule-based PrivateLinks routing."
1912 ),
1913 Tunnel::Ssh(ssh_tunnel) => {
1914 let ssh_host_resolved = resolve_address(
1916 &ssh_tunnel.connection.host,
1917 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
1918 )
1919 .await?;
1920 context
1921 .add_ssh_tunnel(
1922 addr,
1923 SshTunnelConfig {
1924 host: ssh_host_resolved
1925 .iter()
1926 .map(|a| a.to_string())
1927 .collect::<BTreeSet<_>>(),
1928 port: ssh_tunnel.connection.port,
1929 user: ssh_tunnel.connection.user.clone(),
1930 key_pair: SshKeyPair::from_bytes(
1931 &storage_configuration
1932 .connection_context
1933 .secrets_reader
1934 .read_in_task_if(in_task, ssh_tunnel.connection_id)
1935 .await?,
1936 )?,
1937 },
1938 )
1939 .await
1940 .map_err(ContextCreationError::Ssh)?;
1941 }
1942 }
1943 }
1944
1945 Ok(mz_kafka_util::client::create_with_context(
1946 &config, context,
1947 )?)
1948 }
1949
1950 async fn validate(
1951 &self,
1952 _id: CatalogItemId,
1953 storage_configuration: &StorageConfiguration,
1954 ) -> Result<(), anyhow::Error> {
1955 let (context, error_rx) = MzClientContext::with_errors();
1956 let consumer: BaseConsumer<_> = self
1957 .create_with_context(
1958 storage_configuration,
1959 context,
1960 &BTreeMap::new(),
1961 InTask::No,
1963 )
1964 .await?;
1965 let consumer = Arc::new(consumer);
1966
1967 let timeout = storage_configuration
1968 .parameters
1969 .kafka_timeout_config
1970 .fetch_metadata_timeout;
1971
1972 info!("kafka: starting connection validation via fetch_metadata (timeout={timeout:?})");
1983 let result = mz_ore::task::spawn_blocking(|| "kafka_get_metadata", {
1984 let consumer = Arc::clone(&consumer);
1985 move || consumer.fetch_metadata(None, timeout)
1986 })
1987 .await;
1988 info!(
1989 "kafka: connection validation result: {}",
1990 if result.is_ok() { "success" } else { "failed" },
1991 );
1992 match result {
1993 Ok(_) => Ok(()),
1994 Err(err) => {
1999 let main_err = error_rx.try_iter().reduce(|cur, new| match cur {
2003 MzKafkaError::Internal(_) => new,
2004 _ => cur,
2005 });
2006
2007 drop(consumer);
2011
2012 match main_err {
2013 Some(err) => Err(err.into()),
2014 None => Err(err.into()),
2015 }
2016 }
2017 }
2018 }
2019
2020 fn from_default_aws_privatelink(pl: &AwsPrivatelink) -> BrokerRewrite {
2022 BrokerRewrite {
2023 host: vpc_endpoint_host(
2024 pl.connection_id,
2025 None, ),
2027 port: pl.port,
2028 }
2029 }
2030
2031 fn from_aws_privatelink(pl: &AwsPrivatelink) -> BrokerRewrite {
2033 BrokerRewrite {
2034 host: vpc_endpoint_host(pl.connection_id, pl.availability_zone.as_deref()),
2035 port: pl.port,
2036 }
2037 }
2038
2039 fn from_aws_privatelink_rule(
2040 AwsPrivatelinkRule { pattern, to }: &AwsPrivatelinkRule,
2041 ) -> (mz_kafka_util::client::ConnectionRulePattern, BrokerRewrite) {
2042 (
2043 mz_kafka_util::client::ConnectionRulePattern {
2044 prefix_wildcard: pattern.prefix_wildcard,
2045 literal_match: pattern.literal_match.clone(),
2046 suffix_wildcard: pattern.suffix_wildcard,
2047 },
2048 KafkaConnection::from_aws_privatelink(to),
2049 )
2050 }
2051
2052 fn from_aws_privatelinks(pl: &AwsPrivatelinks) -> HostMappingRules {
2053 HostMappingRules {
2054 rules: pl
2055 .rules
2056 .iter()
2057 .map(KafkaConnection::from_aws_privatelink_rule)
2058 .collect_vec(),
2059 }
2060 }
2061}
2062
2063impl<C: ConnectionAccess> AlterCompatible for KafkaConnection<C> {
2064 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2065 let KafkaConnection {
2066 brokers: _,
2067 default_tunnel: _,
2068 progress_topic,
2069 progress_topic_options,
2070 options: _,
2071 tls: _,
2072 sasl: _,
2073 } = self;
2074
2075 let compatibility_checks = [
2076 (progress_topic == &other.progress_topic, "progress_topic"),
2077 (
2078 progress_topic_options == &other.progress_topic_options,
2079 "progress_topic_options",
2080 ),
2081 ];
2082
2083 for (compatible, field) in compatibility_checks {
2084 if !compatible {
2085 tracing::warn!(
2086 "KafkaConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2087 self,
2088 other
2089 );
2090
2091 return Err(AlterError { id });
2092 }
2093 }
2094
2095 Ok(())
2096 }
2097}
2098
2099#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2101pub struct CsrConnection<C: ConnectionAccess = InlinedConnection> {
2102 pub url: Url,
2104 pub tls_root_cert: Option<StringOrSecret>,
2106 pub tls_identity: Option<TlsIdentity>,
2109 pub http_auth: Option<CsrConnectionHttpAuth>,
2111 pub tunnel: Tunnel<C>,
2113}
2114
2115impl<R: ConnectionResolver> IntoInlineConnection<CsrConnection, R>
2116 for CsrConnection<ReferencedConnection>
2117{
2118 fn into_inline_connection(self, r: R) -> CsrConnection {
2119 let CsrConnection {
2120 url,
2121 tls_root_cert,
2122 tls_identity,
2123 http_auth,
2124 tunnel,
2125 } = self;
2126 CsrConnection {
2127 url,
2128 tls_root_cert,
2129 tls_identity,
2130 http_auth,
2131 tunnel: tunnel.into_inline_connection(r),
2132 }
2133 }
2134}
2135
2136impl<C: ConnectionAccess> CsrConnection<C> {
2137 fn validate_by_default(&self) -> bool {
2138 true
2139 }
2140}
2141
2142impl CsrConnection {
2143 pub async fn connect(
2145 &self,
2146 storage_configuration: &StorageConfiguration,
2147 in_task: InTask,
2148 ) -> Result<mz_ccsr::Client, CsrConnectError> {
2149 let mut client_config = mz_ccsr::ClientConfig::new(self.url.clone());
2150 if let Some(root_cert) = &self.tls_root_cert {
2151 let root_cert = root_cert
2152 .get_string(
2153 in_task,
2154 &storage_configuration.connection_context.secrets_reader,
2155 )
2156 .await?;
2157 let root_cert = Certificate::from_pem(root_cert.as_bytes())?;
2158 client_config = client_config.add_root_certificate(root_cert);
2159 }
2160
2161 if let Some(tls_identity) = &self.tls_identity {
2162 let key = &storage_configuration
2163 .connection_context
2164 .secrets_reader
2165 .read_string_in_task_if(in_task, tls_identity.key)
2166 .await?;
2167 let cert = tls_identity
2168 .cert
2169 .get_string(
2170 in_task,
2171 &storage_configuration.connection_context.secrets_reader,
2172 )
2173 .await?;
2174 let ident = Identity::from_pem(key.as_bytes(), cert.as_bytes())?;
2175 client_config = client_config.identity(ident);
2176 }
2177
2178 if let Some(http_auth) = &self.http_auth {
2179 let username = http_auth
2180 .username
2181 .get_string(
2182 in_task,
2183 &storage_configuration.connection_context.secrets_reader,
2184 )
2185 .await?;
2186 let password = match http_auth.password {
2187 None => None,
2188 Some(password) => Some(
2189 storage_configuration
2190 .connection_context
2191 .secrets_reader
2192 .read_string_in_task_if(in_task, password)
2193 .await?,
2194 ),
2195 };
2196 client_config = client_config.auth(username, password);
2197 }
2198
2199 let host = self
2201 .url
2202 .host_str()
2203 .ok_or_else(|| anyhow!("url missing host"))?;
2204 match &self.tunnel {
2205 Tunnel::Direct => {
2206 let resolved = resolve_address(
2208 host,
2209 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2210 )
2211 .await?;
2212 client_config = client_config.resolve_to_addrs(
2213 host,
2214 &resolved
2215 .iter()
2216 .map(|addr| SocketAddr::new(*addr, 0))
2217 .collect::<Vec<_>>(),
2218 )
2219 }
2220 Tunnel::Ssh(ssh_tunnel) => {
2221 let ssh_tunnel = ssh_tunnel
2222 .connect(
2223 storage_configuration,
2224 host,
2225 self.url.port_or_known_default().unwrap_or(80),
2228 in_task,
2229 )
2230 .await
2231 .map_err(CsrConnectError::Ssh)?;
2232
2233 client_config = client_config
2239 .resolve_to_addrs(host, &[SocketAddr::new(ssh_tunnel.local_addr().ip(), 0)])
2246 .dynamic_url({
2257 let remote_url = self.url.clone();
2258 move || {
2259 let mut url = remote_url.clone();
2260 url.set_port(Some(ssh_tunnel.local_addr().port()))
2261 .expect("cannot fail");
2262 url
2263 }
2264 });
2265 }
2266 Tunnel::AwsPrivatelink(connection) => {
2267 assert_none!(connection.port);
2268
2269 let privatelink_host = mz_cloud_resources::vpc_endpoint_host(
2270 connection.connection_id,
2271 connection.availability_zone.as_deref(),
2272 );
2273 let addrs: Vec<_> = net::lookup_host((privatelink_host, 0))
2274 .await
2275 .context("resolving PrivateLink host")?
2276 .collect();
2277 client_config = client_config.resolve_to_addrs(host, &addrs)
2278 }
2279 Tunnel::AwsPrivatelinks(_) => {
2280 unreachable!("MATCHING broker rules are only available for Kafka connections.");
2281 }
2282 }
2283
2284 Ok(client_config.build()?)
2285 }
2286
2287 async fn validate(
2288 &self,
2289 _id: CatalogItemId,
2290 storage_configuration: &StorageConfiguration,
2291 ) -> Result<(), anyhow::Error> {
2292 let client = self
2293 .connect(
2294 storage_configuration,
2295 InTask::No,
2297 )
2298 .await?;
2299 client.list_subjects().await?;
2300 Ok(())
2301 }
2302}
2303
2304impl<C: ConnectionAccess> AlterCompatible for CsrConnection<C> {
2305 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2306 let CsrConnection {
2307 tunnel,
2308 url: _,
2310 tls_root_cert: _,
2311 tls_identity: _,
2312 http_auth: _,
2313 } = self;
2314
2315 let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2316
2317 for (compatible, field) in compatibility_checks {
2318 if !compatible {
2319 tracing::warn!(
2320 "CsrConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2321 self,
2322 other
2323 );
2324
2325 return Err(AlterError { id });
2326 }
2327 }
2328 Ok(())
2329 }
2330}
2331
2332#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2337pub struct GlueSchemaRegistryConnection<C: ConnectionAccess = InlinedConnection> {
2338 pub aws_connection: AwsConnectionReference<C>,
2341 pub registry_name: String,
2343}
2344
2345impl<R: ConnectionResolver> IntoInlineConnection<GlueSchemaRegistryConnection, R>
2346 for GlueSchemaRegistryConnection<ReferencedConnection>
2347{
2348 fn into_inline_connection(self, r: R) -> GlueSchemaRegistryConnection {
2349 let GlueSchemaRegistryConnection {
2350 aws_connection,
2351 registry_name,
2352 } = self;
2353 GlueSchemaRegistryConnection {
2354 aws_connection: aws_connection.into_inline_connection(&r),
2355 registry_name,
2356 }
2357 }
2358}
2359
2360impl<C: ConnectionAccess> GlueSchemaRegistryConnection<C> {
2361 fn validate_by_default(&self) -> bool {
2362 true
2366 }
2367}
2368
2369impl GlueSchemaRegistryConnection {
2370 async fn validate(
2371 &self,
2372 _id: CatalogItemId,
2373 storage_configuration: &StorageConfiguration,
2374 ) -> Result<(), anyhow::Error> {
2375 let enforce_external_addresses =
2376 crate::dyncfgs::ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set());
2377 let sdk_config = self
2378 .aws_connection
2379 .connection
2380 .load_sdk_config(
2381 &storage_configuration.connection_context,
2382 self.aws_connection.connection_id,
2383 InTask::No,
2385 enforce_external_addresses,
2386 )
2387 .await?;
2388 let client = mz_aws_glue_schema_registry::ClientConfig::new(sdk_config).build();
2389 match client.get_registry(&self.registry_name).await {
2390 Ok(_) => Ok(()),
2391 Err(mz_aws_glue_schema_registry::GetRegistryError::NotFound) => Err(anyhow!(
2392 "AWS Glue Schema Registry {:?} does not exist in the configured account/region",
2393 self.registry_name
2394 )),
2395 Err(err) => Err(anyhow::Error::new(err).context(format!(
2396 "failed to validate AWS Glue Schema Registry connection (registry={:?})",
2397 self.registry_name
2398 ))),
2399 }
2400 }
2401}
2402
2403impl<C: ConnectionAccess> AlterCompatible for GlueSchemaRegistryConnection<C> {
2404 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2405 let GlueSchemaRegistryConnection {
2406 registry_name,
2407 aws_connection: _,
2410 } = self;
2411
2412 let compatibility_checks = [(registry_name == &other.registry_name, "registry_name")];
2413
2414 for (compatible, field) in compatibility_checks {
2415 if !compatible {
2416 tracing::warn!(
2417 "GlueSchemaRegistryConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2418 self,
2419 other
2420 );
2421
2422 return Err(AlterError { id });
2423 }
2424 }
2425 Ok(())
2426 }
2427}
2428
2429#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2431pub struct TlsIdentity {
2432 pub cert: StringOrSecret,
2434 pub key: CatalogItemId,
2437}
2438
2439#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2441pub struct CsrConnectionHttpAuth {
2442 pub username: StringOrSecret,
2444 pub password: Option<CatalogItemId>,
2446}
2447
2448#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2450pub struct PostgresConnection<C: ConnectionAccess = InlinedConnection> {
2451 pub host: String,
2453 pub port: u16,
2455 pub database: String,
2457 pub user: StringOrSecret,
2459 pub password: Option<CatalogItemId>,
2461 pub tunnel: Tunnel<C>,
2463 pub tls_mode: SslMode,
2465 pub tls_root_cert: Option<StringOrSecret>,
2468 pub tls_identity: Option<TlsIdentity>,
2470}
2471
2472impl<R: ConnectionResolver> IntoInlineConnection<PostgresConnection, R>
2473 for PostgresConnection<ReferencedConnection>
2474{
2475 fn into_inline_connection(self, r: R) -> PostgresConnection {
2476 let PostgresConnection {
2477 host,
2478 port,
2479 database,
2480 user,
2481 password,
2482 tunnel,
2483 tls_mode,
2484 tls_root_cert,
2485 tls_identity,
2486 } = self;
2487
2488 PostgresConnection {
2489 host,
2490 port,
2491 database,
2492 user,
2493 password,
2494 tunnel: tunnel.into_inline_connection(r),
2495 tls_mode,
2496 tls_root_cert,
2497 tls_identity,
2498 }
2499 }
2500}
2501
2502impl<C: ConnectionAccess> PostgresConnection<C> {
2503 fn validate_by_default(&self) -> bool {
2504 true
2505 }
2506}
2507
2508impl PostgresConnection<InlinedConnection> {
2509 pub async fn config(
2510 &self,
2511 secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2512 storage_configuration: &StorageConfiguration,
2513 in_task: InTask,
2514 ) -> Result<mz_postgres_util::Config, anyhow::Error> {
2515 let params = &storage_configuration.parameters;
2516
2517 let mut config = tokio_postgres::Config::new();
2518 config
2519 .host(&self.host)
2520 .port(self.port)
2521 .dbname(&self.database)
2522 .user(&self.user.get_string(in_task, secrets_reader).await?)
2523 .ssl_mode(self.tls_mode)
2524 .application_name("materialize");
2525 if let Some(password) = self.password {
2526 let password = secrets_reader
2527 .read_string_in_task_if(in_task, password)
2528 .await?;
2529 config.password(password);
2530 }
2531 if let Some(tls_root_cert) = &self.tls_root_cert {
2532 let tls_root_cert = tls_root_cert.get_string(in_task, secrets_reader).await?;
2533 config.ssl_root_cert(tls_root_cert.as_bytes());
2534 }
2535 if let Some(tls_identity) = &self.tls_identity {
2536 let cert = tls_identity
2537 .cert
2538 .get_string(in_task, secrets_reader)
2539 .await?;
2540 let key = secrets_reader
2541 .read_string_in_task_if(in_task, tls_identity.key)
2542 .await?;
2543 config.ssl_cert(cert.as_bytes()).ssl_key(key.as_bytes());
2544 }
2545
2546 if let Some(connect_timeout) = params.pg_source_connect_timeout {
2547 config.connect_timeout(connect_timeout);
2548 }
2549 if let Some(keepalives_retries) = params.pg_source_tcp_keepalives_retries {
2550 config.keepalives_retries(keepalives_retries);
2551 }
2552 if let Some(keepalives_idle) = params.pg_source_tcp_keepalives_idle {
2553 config.keepalives_idle(keepalives_idle);
2554 }
2555 if let Some(keepalives_interval) = params.pg_source_tcp_keepalives_interval {
2556 config.keepalives_interval(keepalives_interval);
2557 }
2558 if let Some(tcp_user_timeout) = params.pg_source_tcp_user_timeout {
2559 config.tcp_user_timeout(tcp_user_timeout);
2560 }
2561
2562 let mut options = vec![];
2563 if let Some(wal_sender_timeout) = params.pg_source_wal_sender_timeout {
2564 options.push(format!(
2565 "--wal_sender_timeout={}",
2566 wal_sender_timeout.as_millis()
2567 ));
2568 };
2569 if params.pg_source_tcp_configure_server {
2570 if let Some(keepalives_retries) = params.pg_source_tcp_keepalives_retries {
2571 options.push(format!("--tcp_keepalives_count={}", keepalives_retries));
2572 }
2573 if let Some(keepalives_idle) = params.pg_source_tcp_keepalives_idle {
2574 options.push(format!(
2575 "--tcp_keepalives_idle={}",
2576 keepalives_idle.as_secs()
2577 ));
2578 }
2579 if let Some(keepalives_interval) = params.pg_source_tcp_keepalives_interval {
2580 options.push(format!(
2581 "--tcp_keepalives_interval={}",
2582 keepalives_interval.as_secs()
2583 ));
2584 }
2585 if let Some(tcp_user_timeout) = params.pg_source_tcp_user_timeout {
2586 options.push(format!(
2587 "--tcp_user_timeout={}",
2588 tcp_user_timeout.as_millis()
2589 ));
2590 }
2591 }
2592 config.options(options.join(" ").as_str());
2593
2594 let tunnel = match &self.tunnel {
2595 Tunnel::Direct => {
2596 let resolved = resolve_address(
2598 &self.host,
2599 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2600 )
2601 .await?;
2602 mz_postgres_util::TunnelConfig::Direct {
2603 resolved_ips: Some(resolved),
2604 }
2605 }
2606 Tunnel::Ssh(SshTunnel {
2607 connection_id,
2608 connection,
2609 }) => {
2610 let secret = secrets_reader
2611 .read_in_task_if(in_task, *connection_id)
2612 .await?;
2613 let key_pair = SshKeyPair::from_bytes(&secret)?;
2614 let resolved = resolve_address(
2616 &connection.host,
2617 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2618 )
2619 .await?;
2620 mz_postgres_util::TunnelConfig::Ssh {
2621 config: SshTunnelConfig {
2622 host: resolved
2623 .iter()
2624 .map(|a| a.to_string())
2625 .collect::<BTreeSet<_>>(),
2626 port: connection.port,
2627 user: connection.user.clone(),
2628 key_pair,
2629 },
2630 }
2631 }
2632 Tunnel::AwsPrivatelink(connection) => {
2633 assert_none!(connection.port);
2634 mz_postgres_util::TunnelConfig::AwsPrivatelink {
2635 connection_id: connection.connection_id,
2636 }
2637 }
2638 Tunnel::AwsPrivatelinks(_) => {
2639 unreachable!("MATCHING broker rules are only available for Kafka connections.");
2640 }
2641 };
2642
2643 Ok(mz_postgres_util::Config::new(
2644 config,
2645 tunnel,
2646 params.ssh_timeout_config,
2647 in_task,
2648 )?)
2649 }
2650
2651 pub async fn validate(
2652 &self,
2653 _id: CatalogItemId,
2654 storage_configuration: &StorageConfiguration,
2655 ) -> Result<mz_postgres_util::Client, anyhow::Error> {
2656 let config = self
2657 .config(
2658 &storage_configuration.connection_context.secrets_reader,
2659 storage_configuration,
2660 InTask::No,
2662 )
2663 .await?;
2664 let client = config
2665 .connect(
2666 "connection validation",
2667 &storage_configuration.connection_context.ssh_tunnel_manager,
2668 )
2669 .await?;
2670
2671 let wal_level = mz_postgres_util::get_wal_level(&client).await?;
2672
2673 if wal_level < mz_postgres_util::replication::WalLevel::Logical {
2674 Err(PostgresConnectionValidationError::InsufficientWalLevel { wal_level })?;
2675 }
2676
2677 let max_wal_senders = mz_postgres_util::get_max_wal_senders(&client).await?;
2678
2679 if max_wal_senders < 1 {
2680 Err(PostgresConnectionValidationError::ReplicationDisabled)?;
2681 }
2682
2683 let available_replication_slots =
2684 mz_postgres_util::available_replication_slots(&client).await?;
2685
2686 if available_replication_slots < 2 {
2688 Err(
2689 PostgresConnectionValidationError::InsufficientReplicationSlotsAvailable {
2690 count: 2,
2691 },
2692 )?;
2693 }
2694
2695 Ok(client)
2696 }
2697}
2698
2699#[derive(Debug, Clone, thiserror::Error)]
2700pub enum PostgresConnectionValidationError {
2701 #[error("PostgreSQL server has insufficient number of replication slots available")]
2702 InsufficientReplicationSlotsAvailable { count: usize },
2703 #[error("server must have wal_level >= logical, but has {wal_level}")]
2704 InsufficientWalLevel {
2705 wal_level: mz_postgres_util::replication::WalLevel,
2706 },
2707 #[error("replication disabled on server")]
2708 ReplicationDisabled,
2709}
2710
2711impl PostgresConnectionValidationError {
2712 pub fn detail(&self) -> Option<String> {
2713 match self {
2714 Self::InsufficientReplicationSlotsAvailable { count } => Some(format!(
2715 "executing this statement requires {} replication slot{}",
2716 count,
2717 if *count == 1 { "" } else { "s" }
2718 )),
2719 _ => None,
2720 }
2721 }
2722
2723 pub fn hint(&self) -> Option<String> {
2724 match self {
2725 Self::InsufficientReplicationSlotsAvailable { .. } => Some(
2726 "you might be able to wait for other sources to finish snapshotting and try again"
2727 .into(),
2728 ),
2729 Self::ReplicationDisabled => Some("set max_wal_senders to a value > 0".into()),
2730 Self::InsufficientWalLevel { .. } => None,
2731 }
2732 }
2733}
2734
2735impl<C: ConnectionAccess> AlterCompatible for PostgresConnection<C> {
2736 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2737 let PostgresConnection {
2738 tunnel,
2739 host: _,
2741 port: _,
2742 database: _,
2743 user: _,
2744 password: _,
2745 tls_mode: _,
2746 tls_root_cert: _,
2747 tls_identity: _,
2748 } = self;
2749
2750 let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
2751
2752 for (compatible, field) in compatibility_checks {
2753 if !compatible {
2754 tracing::warn!(
2755 "PostgresConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
2756 self,
2757 other
2758 );
2759
2760 return Err(AlterError { id });
2761 }
2762 }
2763 Ok(())
2764 }
2765}
2766
2767#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2769pub enum Tunnel<C: ConnectionAccess = InlinedConnection> {
2770 Direct,
2772 Ssh(SshTunnel<C>),
2774 AwsPrivatelink(AwsPrivatelink),
2776 AwsPrivatelinks(AwsPrivatelinks),
2777}
2778
2779impl<R: ConnectionResolver> IntoInlineConnection<Tunnel, R> for Tunnel<ReferencedConnection> {
2780 fn into_inline_connection(self, r: R) -> Tunnel {
2781 match self {
2782 Tunnel::Direct => Tunnel::Direct,
2783 Tunnel::Ssh(ssh) => Tunnel::Ssh(ssh.into_inline_connection(r)),
2784 Tunnel::AwsPrivatelink(awspl) => Tunnel::AwsPrivatelink(awspl),
2785 Tunnel::AwsPrivatelinks(x) => Tunnel::AwsPrivatelinks(x),
2786 }
2787 }
2788}
2789
2790impl<C: ConnectionAccess> AlterCompatible for Tunnel<C> {
2791 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
2792 let compatible = match (self, other) {
2793 (Self::Ssh(s), Self::Ssh(o)) => s.alter_compatible(id, o).is_ok(),
2794 (s, o) => s == o,
2795 };
2796
2797 if !compatible {
2798 tracing::warn!(
2799 "Tunnel incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
2800 self,
2801 other
2802 );
2803
2804 return Err(AlterError { id });
2805 }
2806
2807 Ok(())
2808 }
2809}
2810
2811#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2815pub enum MySqlSslMode {
2816 Disabled,
2817 Required,
2818 VerifyCa,
2819 VerifyIdentity,
2820}
2821
2822#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
2824pub struct MySqlConnection<C: ConnectionAccess = InlinedConnection> {
2825 pub host: String,
2827 pub port: u16,
2829 pub user: StringOrSecret,
2831 pub password: Option<CatalogItemId>,
2833 pub tunnel: Tunnel<C>,
2835 pub tls_mode: MySqlSslMode,
2837 pub tls_root_cert: Option<StringOrSecret>,
2840 pub tls_identity: Option<TlsIdentity>,
2842 pub aws_connection: Option<AwsConnectionReference<C>>,
2845}
2846
2847impl<R: ConnectionResolver> IntoInlineConnection<MySqlConnection, R>
2848 for MySqlConnection<ReferencedConnection>
2849{
2850 fn into_inline_connection(self, r: R) -> MySqlConnection {
2851 let MySqlConnection {
2852 host,
2853 port,
2854 user,
2855 password,
2856 tunnel,
2857 tls_mode,
2858 tls_root_cert,
2859 tls_identity,
2860 aws_connection,
2861 } = self;
2862
2863 MySqlConnection {
2864 host,
2865 port,
2866 user,
2867 password,
2868 tunnel: tunnel.into_inline_connection(&r),
2869 tls_mode,
2870 tls_root_cert,
2871 tls_identity,
2872 aws_connection: aws_connection.map(|aws| aws.into_inline_connection(&r)),
2873 }
2874 }
2875}
2876
2877impl<C: ConnectionAccess> MySqlConnection<C> {
2878 fn validate_by_default(&self) -> bool {
2879 true
2880 }
2881}
2882
2883impl MySqlConnection<InlinedConnection> {
2884 pub async fn config(
2885 &self,
2886 secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
2887 storage_configuration: &StorageConfiguration,
2888 in_task: InTask,
2889 ) -> Result<mz_mysql_util::Config, anyhow::Error> {
2890 let mut opts = mysql_async::OptsBuilder::default()
2892 .ip_or_hostname(&self.host)
2893 .tcp_port(self.port)
2894 .user(Some(&self.user.get_string(in_task, secrets_reader).await?));
2895
2896 if let Some(password) = self.password {
2897 let password = secrets_reader
2898 .read_string_in_task_if(in_task, password)
2899 .await?;
2900 opts = opts.pass(Some(password));
2901 }
2902
2903 let mut ssl_opts = match self.tls_mode {
2908 MySqlSslMode::Disabled => None,
2909 MySqlSslMode::Required => Some(
2910 mysql_async::SslOpts::default()
2911 .with_danger_accept_invalid_certs(true)
2912 .with_danger_skip_domain_validation(true),
2913 ),
2914 MySqlSslMode::VerifyCa => {
2915 Some(mysql_async::SslOpts::default().with_danger_skip_domain_validation(true))
2916 }
2917 MySqlSslMode::VerifyIdentity => Some(mysql_async::SslOpts::default()),
2918 };
2919
2920 if matches!(
2921 self.tls_mode,
2922 MySqlSslMode::VerifyCa | MySqlSslMode::VerifyIdentity
2923 ) {
2924 if let Some(tls_root_cert) = &self.tls_root_cert {
2925 let tls_root_cert = tls_root_cert.get_string(in_task, secrets_reader).await?;
2926 ssl_opts = ssl_opts.map(|opts| {
2927 opts.with_root_certs(vec![tls_root_cert.as_bytes().to_vec().into()])
2928 });
2929 }
2930 }
2931
2932 if let Some(identity) = &self.tls_identity {
2933 let key = secrets_reader
2934 .read_string_in_task_if(in_task, identity.key)
2935 .await?;
2936 let cert = identity.cert.get_string(in_task, secrets_reader).await?;
2937 let (der, pass) =
2938 mz_tls_util::pkcs12der_from_pem(key.as_bytes(), cert.as_bytes())?.into_parts();
2939
2940 ssl_opts = ssl_opts.map(|opts| {
2942 opts.with_client_identity(Some(
2943 mysql_async::ClientIdentity::new(der.into()).with_password(pass),
2944 ))
2945 });
2946 }
2947
2948 opts = opts.ssl_opts(ssl_opts);
2949
2950 let tunnel = match &self.tunnel {
2951 Tunnel::Direct => {
2952 let resolved = resolve_address(
2954 &self.host,
2955 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2956 )
2957 .await?;
2958 mz_mysql_util::TunnelConfig::Direct {
2959 resolved_ips: Some(resolved),
2960 }
2961 }
2962 Tunnel::Ssh(SshTunnel {
2963 connection_id,
2964 connection,
2965 }) => {
2966 let secret = secrets_reader
2967 .read_in_task_if(in_task, *connection_id)
2968 .await?;
2969 let key_pair = SshKeyPair::from_bytes(&secret)?;
2970 let resolved = resolve_address(
2972 &connection.host,
2973 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
2974 )
2975 .await?;
2976 mz_mysql_util::TunnelConfig::Ssh {
2977 config: SshTunnelConfig {
2978 host: resolved
2979 .iter()
2980 .map(|a| a.to_string())
2981 .collect::<BTreeSet<_>>(),
2982 port: connection.port,
2983 user: connection.user.clone(),
2984 key_pair,
2985 },
2986 }
2987 }
2988 Tunnel::AwsPrivatelink(connection) => {
2989 assert_none!(connection.port);
2990 mz_mysql_util::TunnelConfig::AwsPrivatelink {
2991 connection_id: connection.connection_id,
2992 }
2993 }
2994 Tunnel::AwsPrivatelinks(_) => {
2995 unreachable!("MATCHING broker rules are only available for Kafka connections.");
2996 }
2997 };
2998
2999 let aws_config = match self.aws_connection.as_ref() {
3000 None => None,
3001 Some(aws_ref) => Some(
3002 aws_ref
3003 .connection
3004 .load_sdk_config(
3005 &storage_configuration.connection_context,
3006 aws_ref.connection_id,
3007 in_task,
3008 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3009 )
3010 .await?,
3011 ),
3012 };
3013
3014 Ok(mz_mysql_util::Config::new(
3015 opts,
3016 tunnel,
3017 storage_configuration.parameters.ssh_timeout_config,
3018 in_task,
3019 storage_configuration
3020 .parameters
3021 .mysql_source_timeouts
3022 .clone(),
3023 aws_config,
3024 )?)
3025 }
3026
3027 pub async fn validate(
3028 &self,
3029 _id: CatalogItemId,
3030 storage_configuration: &StorageConfiguration,
3031 ) -> Result<MySqlConn, MySqlConnectionValidationError> {
3032 let config = self
3033 .config(
3034 &storage_configuration.connection_context.secrets_reader,
3035 storage_configuration,
3036 InTask::No,
3038 )
3039 .await?;
3040 let mut conn = config
3041 .connect(
3042 "connection validation",
3043 &storage_configuration.connection_context.ssh_tunnel_manager,
3044 )
3045 .await?;
3046
3047 let mut setting_errors = vec![];
3049 let gtid_res = mz_mysql_util::ensure_gtid_consistency(&mut conn).await;
3050 let binlog_res = mz_mysql_util::ensure_full_row_binlog_format(&mut conn).await;
3051 let order_res = mz_mysql_util::ensure_replication_commit_order(&mut conn).await;
3052 for res in [gtid_res, binlog_res, order_res] {
3053 match res {
3054 Err(MySqlError::InvalidSystemSetting {
3055 setting,
3056 expected,
3057 actual,
3058 }) => {
3059 setting_errors.push((setting, expected, actual));
3060 }
3061 Err(err) => Err(err)?,
3062 Ok(()) => {}
3063 }
3064 }
3065 if !setting_errors.is_empty() {
3066 Err(MySqlConnectionValidationError::ReplicationSettingsError(
3067 setting_errors,
3068 ))?;
3069 }
3070
3071 Ok(conn)
3072 }
3073}
3074
3075#[derive(Debug, thiserror::Error)]
3076pub enum MySqlConnectionValidationError {
3077 #[error("Invalid MySQL system replication settings")]
3078 ReplicationSettingsError(Vec<(String, String, String)>),
3079 #[error(transparent)]
3080 Client(#[from] MySqlError),
3081 #[error("{}", .0.display_with_causes())]
3082 Other(#[from] anyhow::Error),
3083}
3084
3085impl MySqlConnectionValidationError {
3086 pub fn detail(&self) -> Option<String> {
3087 match self {
3088 Self::ReplicationSettingsError(settings) => Some(format!(
3089 "Invalid MySQL system replication settings: {}",
3090 itertools::join(
3091 settings.iter().map(|(setting, expected, actual)| format!(
3092 "{}: expected {}, got {}",
3093 setting, expected, actual
3094 )),
3095 "; "
3096 )
3097 )),
3098 _ => None,
3099 }
3100 }
3101
3102 pub fn hint(&self) -> Option<String> {
3103 match self {
3104 Self::ReplicationSettingsError(_) => {
3105 Some("Set the necessary MySQL database system settings.".into())
3106 }
3107 _ => None,
3108 }
3109 }
3110}
3111
3112impl<C: ConnectionAccess> AlterCompatible for MySqlConnection<C> {
3113 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3114 let MySqlConnection {
3115 tunnel,
3116 host: _,
3118 port: _,
3119 user: _,
3120 password: _,
3121 tls_mode: _,
3122 tls_root_cert: _,
3123 tls_identity: _,
3124 aws_connection: _,
3125 } = self;
3126
3127 let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
3128
3129 for (compatible, field) in compatibility_checks {
3130 if !compatible {
3131 tracing::warn!(
3132 "MySqlConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3133 self,
3134 other
3135 );
3136
3137 return Err(AlterError { id });
3138 }
3139 }
3140 Ok(())
3141 }
3142}
3143
3144#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3151pub struct SqlServerConnectionDetails<C: ConnectionAccess = InlinedConnection> {
3152 pub host: String,
3154 pub port: u16,
3156 pub database: String,
3158 pub user: StringOrSecret,
3160 pub password: CatalogItemId,
3162 pub tunnel: Tunnel<C>,
3164 pub encryption: mz_sql_server_util::config::EncryptionLevel,
3166 pub certificate_validation_policy: mz_sql_server_util::config::CertificateValidationPolicy,
3168 pub tls_root_cert: Option<StringOrSecret>,
3170}
3171
3172impl<C: ConnectionAccess> SqlServerConnectionDetails<C> {
3173 fn validate_by_default(&self) -> bool {
3174 true
3175 }
3176}
3177
3178impl SqlServerConnectionDetails<InlinedConnection> {
3179 pub async fn validate(
3181 &self,
3182 _id: CatalogItemId,
3183 storage_configuration: &StorageConfiguration,
3184 ) -> Result<mz_sql_server_util::Client, anyhow::Error> {
3185 let config = self
3186 .resolve_config(
3187 &storage_configuration.connection_context.secrets_reader,
3188 storage_configuration,
3189 InTask::No,
3190 )
3191 .await?;
3192 tracing::debug!(?config, "Validating SQL Server connection");
3193
3194 let mut client = mz_sql_server_util::Client::connect(config).await?;
3195
3196 let mut replication_errors = vec![];
3201 for error in [
3202 mz_sql_server_util::inspect::ensure_database_cdc_enabled(&mut client).await,
3203 mz_sql_server_util::inspect::ensure_snapshot_isolation_enabled(&mut client).await,
3204 mz_sql_server_util::inspect::ensure_sql_server_agent_running(&mut client).await,
3205 ] {
3206 match error {
3207 Err(mz_sql_server_util::SqlServerError::InvalidSystemSetting {
3208 name,
3209 expected,
3210 actual,
3211 }) => replication_errors.push((name, expected, actual)),
3212 Err(other) => Err(other)?,
3213 Ok(()) => (),
3214 }
3215 }
3216 if !replication_errors.is_empty() {
3217 Err(SqlServerConnectionValidationError::ReplicationSettingsError(replication_errors))?;
3218 }
3219
3220 Ok(client)
3221 }
3222
3223 pub async fn resolve_config(
3233 &self,
3234 secrets_reader: &Arc<dyn mz_secrets::SecretsReader>,
3235 storage_configuration: &StorageConfiguration,
3236 in_task: InTask,
3237 ) -> Result<mz_sql_server_util::Config, anyhow::Error> {
3238 let dyncfg = storage_configuration.config_set();
3239 let mut inner_config = tiberius::Config::new();
3240
3241 inner_config.host(&self.host);
3243 inner_config.port(self.port);
3244 inner_config.database(self.database.clone());
3245 inner_config.encryption(self.encryption.into());
3246 match self.certificate_validation_policy {
3247 mz_sql_server_util::config::CertificateValidationPolicy::TrustAll => {
3248 inner_config.trust_cert()
3249 }
3250 mz_sql_server_util::config::CertificateValidationPolicy::VerifyCA => {
3251 inner_config.trust_cert_ca_pem(
3252 self.tls_root_cert
3253 .as_ref()
3254 .unwrap()
3255 .get_string(in_task, secrets_reader)
3256 .await
3257 .context("ca certificate")?,
3258 );
3259 }
3260 mz_sql_server_util::config::CertificateValidationPolicy::VerifySystem => (), }
3262
3263 inner_config.application_name("materialize");
3264
3265 let user = self
3267 .user
3268 .get_string(in_task, secrets_reader)
3269 .await
3270 .context("username")?;
3271 let password = secrets_reader
3272 .read_string_in_task_if(in_task, self.password)
3273 .await
3274 .context("password")?;
3275 inner_config.authentication(tiberius::AuthMethod::sql_server(user, password));
3278
3279 let enforce_external_addresses = ENFORCE_EXTERNAL_ADDRESSES.get(dyncfg);
3282
3283 let tunnel = match &self.tunnel {
3284 Tunnel::Direct => {
3285 let resolved_addresses: Vec<SocketAddr> =
3286 resolve_address(&self.host, enforce_external_addresses)
3287 .await?
3288 .into_iter()
3289 .map(|ip| SocketAddr::new(ip, self.port))
3290 .collect();
3291 mz_sql_server_util::config::TunnelConfig::Direct {
3292 resolved_addresses: resolved_addresses.into_boxed_slice(),
3293 }
3294 }
3295 Tunnel::Ssh(SshTunnel {
3296 connection_id,
3297 connection: ssh_connection,
3298 }) => {
3299 let secret = secrets_reader
3300 .read_in_task_if(in_task, *connection_id)
3301 .await
3302 .context("ssh secret")?;
3303 let key_pair = SshKeyPair::from_bytes(&secret).context("ssh key pair")?;
3304 let addresses = resolve_address(&ssh_connection.host, enforce_external_addresses)
3307 .await
3308 .context("ssh tunnel")?;
3309
3310 let config = SshTunnelConfig {
3311 host: addresses.into_iter().map(|a| a.to_string()).collect(),
3312 port: ssh_connection.port,
3313 user: ssh_connection.user.clone(),
3314 key_pair,
3315 };
3316 mz_sql_server_util::config::TunnelConfig::Ssh {
3317 config,
3318 manager: storage_configuration
3319 .connection_context
3320 .ssh_tunnel_manager
3321 .clone(),
3322 timeout: storage_configuration.parameters.ssh_timeout_config.clone(),
3323 host: self.host.clone(),
3324 port: self.port,
3325 }
3326 }
3327 Tunnel::AwsPrivatelink(private_link_connection) => {
3328 assert_none!(private_link_connection.port);
3329 mz_sql_server_util::config::TunnelConfig::AwsPrivatelink {
3330 connection_id: private_link_connection.connection_id,
3331 port: self.port,
3332 }
3333 }
3334 Tunnel::AwsPrivatelinks(_) => {
3335 unreachable!("MATCHING broker rules are only available for Kafka connections.");
3336 }
3337 };
3338
3339 Ok(mz_sql_server_util::Config::new(
3340 inner_config,
3341 tunnel,
3342 in_task,
3343 ))
3344 }
3345}
3346
3347#[derive(Debug, Clone, thiserror::Error)]
3348pub enum SqlServerConnectionValidationError {
3349 #[error("Invalid SQL Server system replication settings")]
3350 ReplicationSettingsError(Vec<(String, String, String)>),
3351}
3352
3353impl SqlServerConnectionValidationError {
3354 pub fn detail(&self) -> Option<String> {
3355 match self {
3356 Self::ReplicationSettingsError(settings) => Some(format!(
3357 "Invalid SQL Server system replication settings: {}",
3358 itertools::join(
3359 settings.iter().map(|(setting, expected, actual)| format!(
3360 "{}: expected {}, got {}",
3361 setting, expected, actual
3362 )),
3363 "; "
3364 )
3365 )),
3366 }
3367 }
3368
3369 pub fn hint(&self) -> Option<String> {
3370 match self {
3371 _ => None,
3372 }
3373 }
3374}
3375
3376impl<R: ConnectionResolver> IntoInlineConnection<SqlServerConnectionDetails, R>
3377 for SqlServerConnectionDetails<ReferencedConnection>
3378{
3379 fn into_inline_connection(self, r: R) -> SqlServerConnectionDetails {
3380 let SqlServerConnectionDetails {
3381 host,
3382 port,
3383 database,
3384 user,
3385 password,
3386 tunnel,
3387 encryption,
3388 certificate_validation_policy,
3389 tls_root_cert,
3390 } = self;
3391
3392 SqlServerConnectionDetails {
3393 host,
3394 port,
3395 database,
3396 user,
3397 password,
3398 tunnel: tunnel.into_inline_connection(&r),
3399 encryption,
3400 certificate_validation_policy,
3401 tls_root_cert,
3402 }
3403 }
3404}
3405
3406impl<C: ConnectionAccess> AlterCompatible for SqlServerConnectionDetails<C> {
3407 fn alter_compatible(
3408 &self,
3409 id: mz_repr::GlobalId,
3410 other: &Self,
3411 ) -> Result<(), crate::controller::AlterError> {
3412 let SqlServerConnectionDetails {
3413 tunnel,
3414 host: _,
3416 port: _,
3417 database: _,
3418 user: _,
3419 password: _,
3420 encryption: _,
3421 certificate_validation_policy: _,
3422 tls_root_cert: _,
3423 } = self;
3424
3425 let compatibility_checks = [(tunnel.alter_compatible(id, &other.tunnel).is_ok(), "tunnel")];
3426
3427 for (compatible, field) in compatibility_checks {
3428 if !compatible {
3429 tracing::warn!(
3430 "SqlServerConnectionDetails incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3431 self,
3432 other
3433 );
3434
3435 return Err(AlterError { id });
3436 }
3437 }
3438 Ok(())
3439 }
3440}
3441
3442#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3444pub struct SshConnection {
3445 pub host: String,
3446 pub port: u16,
3447 pub user: String,
3448}
3449
3450use self::inline::{
3451 ConnectionAccess, ConnectionResolver, InlinedConnection, IntoInlineConnection,
3452 ReferencedConnection,
3453};
3454
3455impl AlterCompatible for SshConnection {
3456 fn alter_compatible(&self, _id: GlobalId, _other: &Self) -> Result<(), AlterError> {
3457 Ok(())
3459 }
3460}
3461
3462#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3464pub struct AwsPrivatelink {
3465 pub connection_id: CatalogItemId,
3467 pub availability_zone: Option<String>,
3469 pub port: Option<u16>,
3472}
3473
3474impl AlterCompatible for AwsPrivatelink {
3475 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3476 let AwsPrivatelink {
3477 connection_id,
3478 availability_zone: _,
3479 port: _,
3480 } = self;
3481
3482 let compatibility_checks = [(connection_id == &other.connection_id, "connection_id")];
3483
3484 for (compatible, field) in compatibility_checks {
3485 if !compatible {
3486 tracing::warn!(
3487 "AwsPrivatelink incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3488 self,
3489 other
3490 );
3491
3492 return Err(AlterError { id });
3493 }
3494 }
3495
3496 Ok(())
3497 }
3498}
3499
3500#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3501pub struct AwsPrivatelinks {
3502 pub rules: Vec<AwsPrivatelinkRule>,
3506}
3507
3508#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3509pub struct AwsPrivatelinkRule {
3510 pub pattern: ConnectionRulePattern,
3512 pub to: AwsPrivatelink,
3514}
3515
3516#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
3518pub struct SshTunnel<C: ConnectionAccess = InlinedConnection> {
3519 pub connection_id: CatalogItemId,
3521 pub connection: C::Ssh,
3523}
3524
3525impl<R: ConnectionResolver> IntoInlineConnection<SshTunnel, R> for SshTunnel<ReferencedConnection> {
3526 fn into_inline_connection(self, r: R) -> SshTunnel {
3527 let SshTunnel {
3528 connection,
3529 connection_id,
3530 } = self;
3531
3532 SshTunnel {
3533 connection: r.resolve_connection(connection).unwrap_ssh(),
3534 connection_id,
3535 }
3536 }
3537}
3538
3539impl SshTunnel<InlinedConnection> {
3540 async fn connect(
3543 &self,
3544 storage_configuration: &StorageConfiguration,
3545 remote_host: &str,
3546 remote_port: u16,
3547 in_task: InTask,
3548 ) -> Result<ManagedSshTunnelHandle, anyhow::Error> {
3549 let resolved = resolve_address(
3551 &self.connection.host,
3552 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3553 )
3554 .await?;
3555 storage_configuration
3556 .connection_context
3557 .ssh_tunnel_manager
3558 .connect(
3559 SshTunnelConfig {
3560 host: resolved
3561 .iter()
3562 .map(|a| a.to_string())
3563 .collect::<BTreeSet<_>>(),
3564 port: self.connection.port,
3565 user: self.connection.user.clone(),
3566 key_pair: SshKeyPair::from_bytes(
3567 &storage_configuration
3568 .connection_context
3569 .secrets_reader
3570 .read_in_task_if(in_task, self.connection_id)
3571 .await?,
3572 )?,
3573 },
3574 remote_host,
3575 remote_port,
3576 storage_configuration.parameters.ssh_timeout_config,
3577 in_task,
3578 )
3579 .await
3580 }
3581}
3582
3583impl<C: ConnectionAccess> AlterCompatible for SshTunnel<C> {
3584 fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
3585 let SshTunnel {
3586 connection_id,
3587 connection,
3588 } = self;
3589
3590 let compatibility_checks = [
3591 (connection_id == &other.connection_id, "connection_id"),
3592 (
3593 connection.alter_compatible(id, &other.connection).is_ok(),
3594 "connection",
3595 ),
3596 ];
3597
3598 for (compatible, field) in compatibility_checks {
3599 if !compatible {
3600 tracing::warn!(
3601 "SshTunnel incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
3602 self,
3603 other
3604 );
3605
3606 return Err(AlterError { id });
3607 }
3608 }
3609
3610 Ok(())
3611 }
3612}
3613
3614impl SshConnection {
3615 #[allow(clippy::unused_async)]
3616 async fn validate(
3617 &self,
3618 id: CatalogItemId,
3619 storage_configuration: &StorageConfiguration,
3620 ) -> Result<(), anyhow::Error> {
3621 let secret = storage_configuration
3622 .connection_context
3623 .secrets_reader
3624 .read_in_task_if(
3625 InTask::No,
3627 id,
3628 )
3629 .await?;
3630 let key_pair = SshKeyPair::from_bytes(&secret)?;
3631
3632 let resolved = resolve_address(
3634 &self.host,
3635 ENFORCE_EXTERNAL_ADDRESSES.get(storage_configuration.config_set()),
3636 )
3637 .await?;
3638
3639 let config = SshTunnelConfig {
3640 host: resolved
3641 .iter()
3642 .map(|a| a.to_string())
3643 .collect::<BTreeSet<_>>(),
3644 port: self.port,
3645 user: self.user.clone(),
3646 key_pair,
3647 };
3648 config
3651 .validate(storage_configuration.parameters.ssh_timeout_config)
3652 .await
3653 }
3654
3655 fn validate_by_default(&self) -> bool {
3656 false
3657 }
3658}
3659
3660impl AwsPrivatelinkConnection {
3661 #[allow(clippy::unused_async)]
3662 async fn validate(
3663 &self,
3664 id: CatalogItemId,
3665 storage_configuration: &StorageConfiguration,
3666 ) -> Result<(), ConnectionValidationError> {
3667 Self::check_service_name(&self.service_name)?;
3670
3671 let Some(ref cloud_resource_reader) = storage_configuration
3672 .connection_context
3673 .cloud_resource_reader
3674 else {
3675 return Err(anyhow!("AWS PrivateLink connections are unsupported").into());
3676 };
3677
3678 let status = cloud_resource_reader.read(id).await?;
3680
3681 let availability = status
3682 .conditions
3683 .as_ref()
3684 .and_then(|conditions| conditions.iter().find(|c| c.type_ == "Available"));
3685
3686 match availability {
3687 Some(condition) if condition.status == "True" => Ok(()),
3688 Some(condition) => Err(anyhow!("{}", condition.message).into()),
3689 None => Err(anyhow!("Endpoint availability is unknown").into()),
3690 }
3691 }
3692
3693 fn validate_by_default(&self) -> bool {
3694 false
3695 }
3696}
3697
3698#[cfg(test)]
3699mod tests {
3700 use super::*;
3701
3702 #[mz_ore::test]
3703 fn test_catalog_headers() {
3704 let props = BTreeMap::from_iter(
3705 [
3706 (REST_CATALOG_PROP_URI, "https://catalog.example"),
3707 (REST_CATALOG_PROP_WAREHOUSE, "wh"),
3708 ("header.x-goog-user-project", "some-project"),
3709 (REST_CATALOG_PROP_ACCESS_DELEGATION, "vended-credentials"),
3710 ]
3711 .map(|(k, v)| (k.to_string(), v.to_string())),
3712 );
3713
3714 let headers = IcebergCatalogConnection::catalog_headers(&props).expect("valid headers");
3717 assert_eq!(headers.len(), 2);
3718 assert_eq!(headers["x-goog-user-project"], "some-project");
3719 assert_eq!(headers["x-iceberg-access-delegation"], "vended-credentials");
3720
3721 let props = BTreeMap::from([("header.bad name".to_string(), "v".to_string())]);
3724 assert!(IcebergCatalogConnection::catalog_headers(&props).is_err());
3725 }
3726
3727 #[mz_ore::test]
3728 fn test_check_service_name() {
3729 for name in [
3732 "com.amazonaws.vpce.us-east-1.vpce-svc-0e123abc123198abc",
3733 "com.amazonaws.vpce.test.vpce-svc-e2e-test",
3734 "com.amazonaws.us-east-1.s3",
3735 "com.amazonaws.anything",
3736 ] {
3737 assert_eq!(
3738 AwsPrivatelinkConnection::check_service_name(name),
3739 Ok(()),
3740 "expected {name} to be accepted"
3741 );
3742 }
3743
3744 for name in [
3745 "",
3746 "com.amazonaws",
3747 "vpce-svc-0e123abc123198abc",
3748 "my-db-lb-0123456789abcdef.elb.eu-central-1.amazonaws.com",
3749 "db.internal.example.org",
3750 ] {
3751 let err = AwsPrivatelinkConnection::check_service_name(name)
3752 .expect_err("service name should be rejected");
3753 assert_eq!(err.name, name);
3754 }
3755 }
3756}