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