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