Skip to main content

mz_testdrive/
action.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::future::Future;
12
13use std::path::PathBuf;
14use std::str::FromStr;
15use std::sync::LazyLock;
16use std::time::Duration;
17use std::{env, fs};
18
19use anyhow::{Context, anyhow, bail};
20use async_trait::async_trait;
21use aws_credential_types::provider::ProvideCredentials;
22use aws_types::SdkConfig;
23use futures::future::FutureExt;
24use itertools::Itertools;
25use mz_adapter::catalog::{Catalog, ConnCatalog};
26use mz_adapter::session::Session;
27use mz_build_info::BuildInfo;
28use mz_catalog::config::ClusterReplicaSizeMap;
29use mz_catalog::durable::BootstrapArgs;
30use mz_ccsr::SubjectVersion;
31use mz_kafka_util::client::{MzClientContext, create_new_client_config_simple};
32use mz_ore::error::ErrorExt;
33use mz_ore::metrics::MetricsRegistry;
34use mz_ore::now::SYSTEM_TIME;
35use mz_ore::retry::Retry;
36use mz_ore::task;
37use mz_ore::url::SensitiveUrl;
38use mz_persist_client::cache::PersistClientCache;
39use mz_persist_client::cfg::PersistConfig;
40use mz_persist_client::rpc::PubSubClientConnection;
41use mz_persist_client::{PersistClient, PersistLocation};
42use mz_postgres_util::{
43    Sql, batch_execute as pg_batch_execute, query as pg_query, query_one as pg_query_one,
44    sql as pg_sql,
45};
46use mz_sql::catalog::EnvironmentId;
47use mz_tls_util::make_tls;
48use rdkafka::ClientConfig;
49use rdkafka::producer::Producer;
50use regex::{Captures, Regex};
51use semver::Version;
52use tokio_postgres::CancelToken;
53use tokio_postgres::error::{DbError, SqlState};
54use tracing::info;
55use url::Url;
56
57use crate::error::PosError;
58use crate::parser::{
59    Command, PosCommand, SqlExpectedError, SqlOutput, VersionConstraint, validate_ident,
60};
61use crate::util;
62use crate::util::postgres::postgres_client;
63
64pub mod consistency;
65
66mod duckdb;
67mod file;
68mod fivetran;
69mod glue;
70mod http;
71mod kafka;
72mod mysql;
73mod nop;
74mod persist;
75mod postgres;
76mod protobuf;
77mod psql;
78mod s3;
79mod schema_registry;
80mod set;
81mod skip_end;
82mod skip_if;
83mod sleep;
84mod sql;
85mod sql_server;
86mod version_check;
87mod webhook;
88
89pub(crate) async fn verify_kafka_topics_exhausted(state: &State) -> Result<(), anyhow::Error> {
90    kafka::verify_topics_exhausted(state).await
91}
92
93/// User-settable configuration parameters.
94#[derive(Debug, Clone)]
95pub struct Config {
96    // === Testdrive options. ===
97    /// Variables to make available to the testdrive script.
98    ///
99    /// The value of each entry will be made available to the script in a
100    /// variable named `arg.KEY`.
101    pub arg_vars: BTreeMap<String, String>,
102    /// A random number to distinguish each run of a testdrive script.
103    pub seed: Option<String>,
104    /// Whether to reset Materialize state before executing each script and
105    /// to clean up AWS state after each script.
106    pub reset: bool,
107    /// Force the use of the specified temporary directory to use.
108    ///
109    /// If unspecified, testdrive creates a temporary directory with a random
110    /// name.
111    pub temp_dir: Option<String>,
112    /// Source string to print out on errors.
113    pub source: Option<String>,
114    /// The default timeout for cancellable operations.
115    pub default_timeout: Duration,
116    /// The default number of tries for retriable operations.
117    pub default_max_tries: usize,
118    /// The initial backoff interval for retry operations.
119    ///
120    /// Set to 0 to retry immediately on failure.
121    pub initial_backoff: Duration,
122    /// Backoff factor to use for retry operations.
123    ///
124    /// Set to 1 to retry at a steady pace.
125    pub backoff_factor: f64,
126    /// Should we skip coordinator and catalog consistency checks.
127    pub consistency_checks: consistency::Level,
128    /// Whether to run statement logging consistency checks (adds a few seconds at the end of every
129    /// test file).
130    pub check_statement_logging: bool,
131    /// Whether to automatically rewrite wrong results instead of failing.
132    pub rewrite_results: bool,
133
134    // === Materialize options. ===
135    /// The pgwire connection parameters for the Materialize instance that
136    /// testdrive will connect to.
137    pub materialize_pgconfig: tokio_postgres::Config,
138    /// The internal pgwire connection parameters for the Materialize instance that
139    /// testdrive will connect to.
140    pub materialize_internal_pgconfig: tokio_postgres::Config,
141    /// Whether to use HTTPS instead of plain HTTP for the HTTP(S) connections.
142    pub materialize_use_https: bool,
143    /// The port for the public endpoints of the materialize instance that
144    /// testdrive will connect to via HTTP.
145    pub materialize_http_port: u16,
146    /// The port for the internal endpoints of the materialize instance that
147    /// testdrive will connect to via HTTP.
148    pub materialize_internal_http_port: u16,
149    /// The port for the password endpoints of the materialize instance that
150    /// testdrive will connect to via SQL.
151    pub materialize_password_sql_port: u16,
152    /// The port for the SASL endpoints of the materialize instance that
153    /// testdrive will connect to via SQL.
154    pub materialize_sasl_sql_port: u16,
155    /// Session parameters to set after connecting to materialize.
156    pub materialize_params: Vec<(String, String)>,
157    /// An optional catalog configuration.
158    pub materialize_catalog_config: Option<CatalogConfig>,
159    /// Build information
160    pub build_info: &'static BuildInfo,
161    /// Configured cluster replica sizes
162    pub materialize_cluster_replica_sizes: ClusterReplicaSizeMap,
163
164    // === Persist options. ===
165    /// Handle to the persist consensus system.
166    pub persist_consensus_url: Option<SensitiveUrl>,
167    /// Handle to the persist blob storage.
168    pub persist_blob_url: Option<SensitiveUrl>,
169
170    // === Confluent options. ===
171    /// The address of the Kafka broker that testdrive will interact with.
172    pub kafka_addr: String,
173    /// Default number of partitions to use for topics
174    pub kafka_default_partitions: usize,
175    /// Arbitrary rdkafka options for testdrive to use when connecting to the
176    /// Kafka broker.
177    pub kafka_opts: Vec<(String, String)>,
178    /// The URL of the schema registry that testdrive will connect to.
179    pub schema_registry_url: Url,
180    /// An optional path to a TLS certificate that testdrive will present when
181    /// performing client authentication.
182    ///
183    /// The keystore must be in the PKCS#12 format.
184    pub cert_path: Option<String>,
185    /// An optional password for the TLS certificate.
186    pub cert_password: Option<String>,
187    /// An optional username for basic authentication with the Confluent Schema
188    /// Registry.
189    pub ccsr_username: Option<String>,
190    /// An optional password for basic authentication with the Confluent Schema
191    /// Registry.
192    pub ccsr_password: Option<String>,
193
194    // === AWS options. ===
195    /// The configuration to use when connecting to AWS.
196    pub aws_config: SdkConfig,
197    /// The ID of the AWS account that `aws_config` configures.
198    pub aws_account: String,
199
200    // === Fivetran options. ===
201    /// Address of the Fivetran Destination that is currently running.
202    pub fivetran_destination_url: String,
203    /// Directory that is accessible to the Fivetran Destination.
204    pub fivetran_destination_files_path: String,
205}
206
207pub struct MaterializeState {
208    catalog_config: Option<CatalogConfig>,
209
210    sql_addr: String,
211    use_https: bool,
212    http_addr: String,
213    internal_sql_addr: String,
214    internal_http_addr: String,
215    password_sql_addr: String,
216    sasl_sql_addr: String,
217    user: String,
218    pgclient: tokio_postgres::Client,
219    environment_id: EnvironmentId,
220    bootstrap_args: BootstrapArgs,
221}
222
223pub struct State {
224    // The Config that this `State` was originally created from.
225    pub config: Config,
226
227    // === Testdrive state. ===
228    arg_vars: BTreeMap<String, String>,
229    cmd_vars: BTreeMap<String, String>,
230    seed: String,
231    temp_path: PathBuf,
232    _tempfile: Option<tempfile::TempDir>,
233    default_timeout: Duration,
234    timeout: Duration,
235    max_tries: usize,
236    initial_backoff: Duration,
237    backoff_factor: f64,
238    consistency_checks: consistency::Level,
239    check_statement_logging: bool,
240    consistency_checks_adhoc_skip: bool,
241    regex: Option<Regex>,
242    regex_replacement: String,
243    error_line_count: usize,
244    error_string: String,
245
246    // === Materialize state. ===
247    materialize: MaterializeState,
248
249    // === Persist state. ===
250    persist_consensus_url: Option<SensitiveUrl>,
251    persist_blob_url: Option<SensitiveUrl>,
252    build_info: &'static BuildInfo,
253    persist_clients: PersistClientCache,
254
255    // === Confluent state. ===
256    schema_registry_url: Url,
257    ccsr_client: mz_ccsr::Client,
258    kafka_addr: String,
259    kafka_admin: rdkafka::admin::AdminClient<MzClientContext>,
260    kafka_admin_opts: rdkafka::admin::AdminOptions,
261    kafka_config: ClientConfig,
262    kafka_default_partitions: usize,
263    kafka_producer: rdkafka::producer::FutureProducer<MzClientContext>,
264    kafka_topics: BTreeMap<String, usize>,
265    /// Topics whose final `kafka-verify-data` must consume the complete topic.
266    kafka_verify_topics: BTreeSet<String>,
267
268    // === AWS state. ===
269    aws_account: String,
270    aws_config: SdkConfig,
271
272    // === Database driver state. ===
273    pub duckdb_clients: BTreeMap<String, std::sync::Arc<std::sync::Mutex<::duckdb::Connection>>>,
274    mysql_clients: BTreeMap<String, mysql_async::Conn>,
275    postgres_clients: BTreeMap<String, tokio_postgres::Client>,
276    sql_server_clients: BTreeMap<String, mz_sql_server_util::Client>,
277    /// Tasks spawned by `postgres-execute background=true`. Joined at the end
278    /// of the file so their failures fail the test.
279    background_tasks: Vec<BackgroundTask>,
280
281    // === Fivetran state. ===
282    fivetran_destination_url: String,
283    fivetran_destination_files_path: String,
284
285    // === Rewrite state. ===
286    rewrite_results: bool,
287    /// Current file, results are replaced inline
288    pub rewrites: Vec<Rewrite>,
289    /// Start position of currently expected result
290    pub rewrite_pos_start: usize,
291    /// End position of currently expected result
292    pub rewrite_pos_end: usize,
293}
294
295pub struct Rewrite {
296    pub content: String,
297    pub start: usize,
298    pub end: usize,
299}
300
301/// A query spawned by `postgres-execute background=true`, retained so it can be
302/// joined at the end of the file.
303///
304/// Aborting `handle` stops the Rust task from awaiting the query's response,
305/// but it does not stop the query on the server. The tokio-postgres connection
306/// driver waits for every pending response before it closes the connection, so
307/// dropping the client leaves the SQL running until Materialize replies. To
308/// actually stop a query that overran its deadline we send an out-of-band
309/// cancel request through `cancel_token`, the same mechanism psql uses for
310/// Ctrl-C, before aborting the task.
311pub(crate) struct BackgroundTask {
312    desc: String,
313    handle: task::JoinHandle<Result<(), anyhow::Error>>,
314    cancel_token: CancelToken,
315    /// Connection URL, used to rebuild the TLS connector that the cancel
316    /// request opens its own connection with.
317    url: String,
318}
319
320/// Best-effort cancellation of the in-progress query on the connection
321/// `cancel_token` was derived from. Opens a fresh connection to send the cancel
322/// request. Cancellation is inherently racy and the caller aborts the task
323/// regardless, so failures are logged rather than propagated.
324async fn cancel_background_query(cancel_token: &CancelToken, url: &str, timeout: Duration) {
325    let tls = match tokio_postgres::Config::from_str(url)
326        .map_err(anyhow::Error::from)
327        .and_then(|config| make_tls(&config).map_err(anyhow::Error::from))
328    {
329        Ok(tls) => tls,
330        Err(e) => {
331            tracing::warn!("could not build TLS connector to cancel background query: {e}");
332            return;
333        }
334    };
335    match tokio::time::timeout(timeout, cancel_token.cancel_query(tls)).await {
336        Ok(Ok(())) => {}
337        Ok(Err(e)) => tracing::warn!("cancel request for background query failed: {e}"),
338        Err(_) => tracing::warn!("cancel request for background query timed out"),
339    }
340}
341
342impl State {
343    pub async fn initialize_cmd_vars(&mut self) -> Result<(), anyhow::Error> {
344        self.cmd_vars
345            .insert("testdrive.kafka-addr".into(), self.kafka_addr.clone());
346        self.cmd_vars.insert(
347            "testdrive.schema-registry-url".into(),
348            self.schema_registry_url.to_string(),
349        );
350        self.cmd_vars
351            .insert("testdrive.seed".into(), self.seed.clone());
352        self.cmd_vars.insert(
353            "testdrive.temp-dir".into(),
354            self.temp_path.display().to_string(),
355        );
356        self.cmd_vars
357            .insert("testdrive.aws-region".into(), self.aws_region().into());
358        self.cmd_vars
359            .insert("testdrive.aws-endpoint".into(), self.aws_endpoint().into());
360        self.cmd_vars
361            .insert("testdrive.aws-account".into(), self.aws_account.clone());
362        {
363            let aws_credentials = self
364                .aws_config
365                .credentials_provider()
366                .ok_or_else(|| anyhow!("no AWS credentials provider configured"))?
367                .provide_credentials()
368                .await
369                .context("fetching AWS credentials")?;
370            self.cmd_vars.insert(
371                "testdrive.aws-access-key-id".into(),
372                aws_credentials.access_key_id().to_owned(),
373            );
374            self.cmd_vars.insert(
375                "testdrive.aws-secret-access-key".into(),
376                aws_credentials.secret_access_key().to_owned(),
377            );
378            self.cmd_vars.insert(
379                "testdrive.aws-token".into(),
380                aws_credentials
381                    .session_token()
382                    .map(|token| token.to_owned())
383                    .unwrap_or_else(String::new),
384            );
385        }
386        self.cmd_vars.insert(
387            "testdrive.materialize-environment-id".into(),
388            self.materialize.environment_id.to_string(),
389        );
390        self.cmd_vars.insert(
391            "testdrive.materialize-sql-addr".into(),
392            self.materialize.sql_addr.clone(),
393        );
394        self.cmd_vars.insert(
395            "testdrive.materialize-internal-sql-addr".into(),
396            self.materialize.internal_sql_addr.clone(),
397        );
398        self.cmd_vars.insert(
399            "testdrive.materialize-password-sql-addr".into(),
400            self.materialize.password_sql_addr.clone(),
401        );
402        self.cmd_vars.insert(
403            "testdrive.materialize-sasl-sql-addr".into(),
404            self.materialize.sasl_sql_addr.clone(),
405        );
406        self.cmd_vars.insert(
407            "testdrive.materialize-user".into(),
408            self.materialize.user.clone(),
409        );
410        self.cmd_vars.insert(
411            "testdrive.fivetran-destination-url".into(),
412            self.fivetran_destination_url.clone(),
413        );
414        self.cmd_vars.insert(
415            "testdrive.fivetran-destination-files-path".into(),
416            self.fivetran_destination_files_path.clone(),
417        );
418
419        for (key, value) in env::vars() {
420            self.cmd_vars.insert(format!("env.{}", key), value);
421        }
422
423        for (key, value) in &self.arg_vars {
424            validate_ident(key)?;
425            self.cmd_vars
426                .insert(format!("arg.{}", key), value.to_string());
427        }
428
429        Ok(())
430    }
431    /// Makes of copy of the durable catalog and runs a function on its
432    /// state. Returns `None` if there's no catalog information in the State.
433    pub async fn with_catalog_copy<F, T>(
434        &self,
435        system_parameter_defaults: BTreeMap<String, String>,
436        build_info: &'static BuildInfo,
437        bootstrap_args: &BootstrapArgs,
438        enable_expression_cache_override: Option<bool>,
439        f: F,
440    ) -> Result<Option<T>, anyhow::Error>
441    where
442        F: FnOnce(ConnCatalog) -> T,
443    {
444        async fn persist_client(
445            persist_consensus_url: SensitiveUrl,
446            persist_blob_url: SensitiveUrl,
447            persist_clients: &PersistClientCache,
448        ) -> Result<PersistClient, anyhow::Error> {
449            let persist_location = PersistLocation {
450                blob_uri: persist_blob_url,
451                consensus_uri: persist_consensus_url,
452            };
453            Ok(persist_clients.open(persist_location).await?)
454        }
455
456        if let Some(CatalogConfig {
457            persist_consensus_url,
458            persist_blob_url,
459        }) = &self.materialize.catalog_config
460        {
461            let persist_client = persist_client(
462                persist_consensus_url.clone(),
463                persist_blob_url.clone(),
464                &self.persist_clients,
465            )
466            .await?;
467            let catalog = Catalog::open_debug_read_only_persist_catalog_config(
468                persist_client,
469                SYSTEM_TIME.clone(),
470                self.materialize.environment_id.clone(),
471                system_parameter_defaults,
472                build_info,
473                bootstrap_args,
474                enable_expression_cache_override,
475            )
476            .await?;
477            let res = f(catalog.for_session(&Session::dummy()));
478            catalog.expire().await;
479            Ok(Some(res))
480        } else {
481            Ok(None)
482        }
483    }
484
485    pub fn aws_endpoint(&self) -> &str {
486        self.aws_config.endpoint_url().unwrap_or("")
487    }
488
489    pub fn aws_region(&self) -> &str {
490        self.aws_config.region().map(|r| r.as_ref()).unwrap_or("")
491    }
492
493    /// Resets the adhoc skip consistency check that users can toggle per-file, and returns whether
494    /// the consistency checks should be skipped for this current run.
495    pub fn clear_skip_consistency_checks(&mut self) -> bool {
496        std::mem::replace(&mut self.consistency_checks_adhoc_skip, false)
497    }
498
499    /// Joins all tasks spawned by `postgres-execute background=true`,
500    /// returning one error per task that failed or did not complete within the
501    /// default timeout. Must be called before the end of the file so that
502    /// background failures fail the test.
503    pub(crate) async fn join_background_tasks(&mut self) -> Vec<anyhow::Error> {
504        let mut errors = Vec::new();
505        for BackgroundTask {
506            desc,
507            mut handle,
508            cancel_token,
509            url,
510        } in self.background_tasks.drain(..)
511        {
512            // Poll the handle by reference so it survives a timeout. Dropping a
513            // `JoinHandle` only detaches the task, it does not stop it, and a
514            // detached background query would keep running SQL against the same
515            // Materialize instance while later files execute.
516            match tokio::time::timeout(self.default_timeout, &mut handle).await {
517                Ok(Ok(())) => {}
518                Ok(Err(e)) => errors.push(e.context(format!("background query failed: {desc}"))),
519                Err(_) => {
520                    // Aborting `handle` alone leaves the query running on the
521                    // server. Cancel it first so it cannot overlap consistency
522                    // checks or later files, then abort and reap the task.
523                    cancel_background_query(&cancel_token, &url, self.default_timeout).await;
524                    handle.abort_and_wait().await;
525                    errors.push(anyhow!(
526                        "background query did not complete before the end of the file: {desc}"
527                    ));
528                }
529            }
530        }
531        errors
532    }
533
534    pub async fn reset_materialize(&self) -> Result<(), anyhow::Error> {
535        let (inner_client, _) = postgres_client(
536            &format!(
537                "postgres://mz_system:materialize@{}",
538                self.materialize.internal_sql_addr
539            ),
540            self.default_timeout,
541        )
542        .await?;
543
544        let version = pg_query_one(&inner_client, pg_sql!("SELECT mz_version_num()"), &[])
545            .await
546            .context("getting version of materialize")
547            .map(|row| row.get::<_, i32>(0))?;
548
549        let semver = pg_query_one(
550            &inner_client,
551            pg_sql!("SELECT right(split_part(mz_version(), ' ', 1), -1)"),
552            &[],
553        )
554        .await
555        .context("getting semver of materialize")
556        .map(|row| row.get::<_, String>(0))?
557        .parse::<semver::Version>()
558        .context("parsing semver of materialize")?;
559
560        pg_batch_execute(&inner_client, pg_sql!("ALTER SYSTEM RESET ALL"))
561            .await
562            .context("resetting materialize state: ALTER SYSTEM RESET ALL")?;
563
564        // Dangerous functions are useful for tests so we enable it for all tests.
565        {
566            let rename_version = Version::parse("0.128.0-dev.1").expect("known to be valid");
567            let enable_unsafe_functions = if semver >= rename_version {
568                "unsafe_enable_unsafe_functions"
569            } else {
570                "enable_unsafe_functions"
571            };
572            let res = pg_batch_execute(
573                &inner_client,
574                pg_sql!(
575                    "ALTER SYSTEM SET {} = on",
576                    Sql::ident(enable_unsafe_functions)
577                ),
578            )
579            .await
580            .context("enabling dangerous functions");
581            if let Err(e) = res {
582                match e.root_cause().downcast_ref::<DbError>() {
583                    Some(e) if *e.code() == SqlState::CANT_CHANGE_RUNTIME_PARAM => {
584                        info!(
585                            "can't enable unsafe functions because the server is safe mode; \
586                             testdrive scripts will fail if they use unsafe functions",
587                        );
588                    }
589                    _ => return Err(e),
590                }
591            }
592        }
593
594        for row in pg_query(&inner_client, pg_sql!("SHOW DATABASES"), &[])
595            .await
596            .context("resetting materialize state: SHOW DATABASES")?
597        {
598            let db_name: String = row.get(0);
599            if db_name.starts_with("testdrive_no_reset_") {
600                continue;
601            }
602            let drop_database = pg_sql!("DROP DATABASE {}", Sql::ident(&db_name));
603            sql::print_query(drop_database.as_str(), None);
604            pg_batch_execute(&inner_client, drop_database)
605                .await
606                .context(format!(
607                    "resetting materialize state: DROP DATABASE {}",
608                    db_name,
609                ))?;
610        }
611
612        // Get all user clusters not running any objects owned by users
613        let inactive_user_clusters = "
614        WITH
615            active_user_clusters AS
616            (
617                SELECT DISTINCT cluster_id, object_id
618                FROM
619                    (
620                        SELECT cluster_id, id FROM mz_catalog.mz_sources
621                        UNION ALL SELECT cluster_id, id FROM mz_catalog.mz_sinks
622                        UNION ALL
623                            SELECT cluster_id, id
624                            FROM mz_catalog.mz_materialized_views
625                        UNION ALL
626                            SELECT cluster_id, id FROM mz_catalog.mz_indexes
627                        UNION ALL
628                            SELECT cluster_id, id
629                            FROM mz_internal.mz_subscriptions
630                    )
631                    AS t (cluster_id, object_id)
632                WHERE cluster_id IS NOT NULL AND object_id LIKE 'u%'
633            )
634        SELECT name
635        FROM mz_catalog.mz_clusters
636        WHERE
637            id NOT IN ( SELECT cluster_id FROM active_user_clusters ) AND id LIKE 'u%'
638                AND
639            owner_id LIKE 'u%';";
640
641        let inactive_clusters = pg_query(&inner_client, Sql::new(inactive_user_clusters), &[])
642            .await
643            .context("resetting materialize state: inactive_user_clusters")?;
644
645        if !inactive_clusters.is_empty() {
646            println!("cleaning up user clusters from previous tests...")
647        }
648
649        for cluster_name in inactive_clusters {
650            let cluster_name: String = cluster_name.get(0);
651            if cluster_name.starts_with("testdrive_no_reset_") {
652                continue;
653            }
654            let drop_cluster = pg_sql!("DROP CLUSTER {}", Sql::ident(&cluster_name));
655            sql::print_query(drop_cluster.as_str(), None);
656            pg_batch_execute(&inner_client, drop_cluster)
657                .await
658                .context(format!(
659                    "resetting materialize state: DROP CLUSTER {}",
660                    cluster_name,
661                ))?;
662        }
663
664        pg_batch_execute(&inner_client, pg_sql!("CREATE DATABASE materialize"))
665            .await
666            .context("resetting materialize state: CREATE DATABASE materialize")?;
667
668        // Attempt to remove all users but the current user. Old versions of
669        // Materialize did not support roles, so this degrades gracefully if
670        // mz_roles does not exist.
671        if let Ok(rows) = pg_query(&inner_client, pg_sql!("SELECT name FROM mz_roles"), &[]).await {
672            for row in rows {
673                let role_name: String = row.get(0);
674                if role_name == self.materialize.user || role_name.starts_with("mz_") {
675                    continue;
676                }
677                let drop_role = pg_sql!("DROP ROLE {}", Sql::ident(&role_name));
678                sql::print_query(drop_role.as_str(), None);
679                pg_batch_execute(&inner_client, drop_role)
680                    .await
681                    .context(format!(
682                        "resetting materialize state: DROP ROLE {}",
683                        role_name,
684                    ))?;
685            }
686        }
687
688        // Alter materialize user with all system privileges.
689        pg_batch_execute(
690            &inner_client,
691            pg_sql!(
692                "GRANT ALL PRIVILEGES ON SYSTEM TO {}",
693                Sql::ident(&self.materialize.user)
694            ),
695        )
696        .await?;
697
698        // Grant initial privileges.
699        pg_batch_execute(
700            &inner_client,
701            pg_sql!("GRANT USAGE ON DATABASE materialize TO PUBLIC"),
702        )
703        .await?;
704        pg_batch_execute(
705            &inner_client,
706            pg_sql!(
707                "GRANT ALL PRIVILEGES ON DATABASE materialize TO {}",
708                Sql::ident(&self.materialize.user)
709            ),
710        )
711        .await?;
712        pg_batch_execute(
713            &inner_client,
714            pg_sql!(
715                "GRANT ALL PRIVILEGES ON SCHEMA materialize.public TO {}",
716                Sql::ident(&self.materialize.user)
717            ),
718        )
719        .await?;
720
721        let cluster = match version {
722            ..=8199 => "default",
723            8200.. => "quickstart",
724        };
725        pg_batch_execute(
726            &inner_client,
727            pg_sql!("GRANT USAGE ON CLUSTER {} TO PUBLIC", Sql::ident(cluster)),
728        )
729        .await?;
730        pg_batch_execute(
731            &inner_client,
732            pg_sql!(
733                "GRANT ALL PRIVILEGES ON CLUSTER {} TO {}",
734                Sql::ident(cluster),
735                Sql::ident(&self.materialize.user)
736            ),
737        )
738        .await?;
739
740        Ok(())
741    }
742
743    /// Delete Kafka topics + CCSR subjects that were created in this run
744    pub async fn reset_kafka(&self) -> Result<(), anyhow::Error> {
745        use rdkafka::types::RDKafkaErrorCode;
746        let mut errors: Vec<anyhow::Error> = Vec::new();
747
748        let metadata = self.kafka_producer.client().fetch_metadata(
749            None,
750            Some(std::cmp::max(Duration::from_secs(1), self.default_timeout)),
751        )?;
752
753        let testdrive_topics: Vec<_> = metadata
754            .topics()
755            .iter()
756            .filter_map(|t| {
757                if t.name().starts_with("testdrive-") {
758                    Some(t.name())
759                } else {
760                    None
761                }
762            })
763            .collect();
764
765        if !testdrive_topics.is_empty() {
766            match self
767                .kafka_admin
768                .delete_topics(&testdrive_topics, &self.kafka_admin_opts)
769                .await
770            {
771                Ok(res) => {
772                    if res.len() != testdrive_topics.len() {
773                        errors.push(anyhow!(
774                            "kafka topic deletion returned {} results, but exactly {} expected",
775                            res.len(),
776                            testdrive_topics.len()
777                        ));
778                    }
779                    for (res, topic) in res.iter().zip_eq(testdrive_topics.iter()) {
780                        match res {
781                            Ok(_) | Err((_, RDKafkaErrorCode::UnknownTopicOrPartition)) => (),
782                            Err((_, err)) => {
783                                errors.push(anyhow!("unable to delete {}: {}", topic, err));
784                            }
785                        }
786                    }
787                }
788                Err(e) => {
789                    errors.push(e.into());
790                }
791            };
792        }
793
794        let schema_registry_errors = self.reset_schema_registry().await;
795
796        errors.extend(schema_registry_errors);
797        if errors.is_empty() {
798            Ok(())
799        } else {
800            bail!(
801                "deleting Kafka topics: {} errors: {}",
802                errors.len(),
803                errors
804                    .into_iter()
805                    .map(|e| e.to_string_with_causes())
806                    .join("\n")
807            );
808        }
809    }
810
811    #[allow(clippy::disallowed_types)]
812    async fn reset_schema_registry(&self) -> Vec<anyhow::Error> {
813        use std::collections::HashMap;
814
815        let mut errors = Vec::new();
816        match self
817            .ccsr_client
818            .list_subjects()
819            .await
820            .context("listing schema registry subjects")
821        {
822            Ok(subjects) => {
823                let testdrive_subjects: Vec<_> = subjects
824                    .into_iter()
825                    .filter(|s| s.starts_with("testdrive-"))
826                    .collect();
827
828                // Build the dependency graphs: subject -> list of subjects it references
829                let mut graphs: HashMap<SubjectVersion, Vec<SubjectVersion>> = HashMap::new();
830
831                for subject in &testdrive_subjects {
832                    match self.ccsr_client.get_subject_with_references(subject).await {
833                        Ok((subj, refs)) => {
834                            // Filter to only testdrive subjects
835                            let refs: Vec<_> = refs
836                                .into_iter()
837                                .filter(|r| r.subject.starts_with("testdrive-"))
838                                .collect();
839                            graphs.insert(
840                                SubjectVersion {
841                                    subject: subj.name,
842                                    version: subj.version,
843                                },
844                                refs,
845                            );
846                        }
847                        Err(mz_ccsr::GetBySubjectError::SubjectNotFound) => {
848                            // Subject was already deleted, skip it
849                        }
850                        Err(e) => {
851                            errors.push(anyhow::anyhow!(
852                                "failed to get references for subject {}: {}",
853                                subject,
854                                e
855                            ));
856                        }
857                    }
858                }
859
860                // Get topological ordering (0 = root/no dependencies, higher = more dependents)
861                // We need to delete in reverse order (highest first = subjects that depend on others)
862                let subjects_to_delete: Vec<_> = match mz_ccsr::topological_sort(&graphs) {
863                    Ok(ordered) => {
864                        let mut subjects: Vec<_> = ordered.into_iter().collect();
865                        subjects.sort_by(|a, b| a.1.cmp(&b.1));
866                        subjects.into_iter().map(|(s, _)| s.clone()).collect()
867                    }
868                    Err(_) => {
869                        tracing::info!("Cycle detected, attempting to delete anyway");
870                        // Cycle detected or other error - fall back to deleting in any order
871                        graphs.into_keys().collect()
872                    }
873                };
874
875                for subject in subjects_to_delete {
876                    match self.ccsr_client.delete_subject(&subject.subject).await {
877                        Ok(()) | Err(mz_ccsr::DeleteError::SubjectNotFound) => (),
878                        Err(e) => errors.push(e.into()),
879                    }
880                }
881            }
882            Err(e) => {
883                errors.push(e);
884            }
885        }
886        errors
887    }
888}
889
890/// Configuration for the Catalog.
891#[derive(Debug, Clone)]
892pub struct CatalogConfig {
893    /// Handle to the persist consensus system.
894    pub persist_consensus_url: SensitiveUrl,
895    /// Handle to the persist blob storage.
896    pub persist_blob_url: SensitiveUrl,
897}
898
899pub enum ControlFlow {
900    Continue,
901    SkipBegin,
902    SkipEnd,
903}
904
905#[async_trait]
906pub(crate) trait Run {
907    async fn run(self, state: &mut State) -> Result<ControlFlow, PosError>;
908}
909
910#[async_trait]
911impl Run for PosCommand {
912    async fn run(self, state: &mut State) -> Result<ControlFlow, PosError> {
913        macro_rules! handle_version {
914            ($version_constraint:expr) => {
915                match $version_constraint {
916                    Some(VersionConstraint { min, max }) => {
917                        match version_check::run_version_check(min, max, state).await {
918                            Ok(true) => return Ok(ControlFlow::Continue),
919                            Ok(false) => {}
920                            Err(err) => return Err(PosError::new(err, self.pos)),
921                        }
922                    }
923                    None => {}
924                }
925            };
926        }
927
928        let wrap_err = |e| PosError::new(e, self.pos);
929        // Substitute variables at startup except for the command-specific ones
930        // Those will be substituted at runtime
931        let ignore_prefix = match &self.command {
932            Command::Builtin(builtin, _) => Some(builtin.name.clone()),
933            _ => None,
934        };
935        let subst = |msg: &str, vars: &BTreeMap<String, String>| {
936            substitute_vars(msg, vars, &ignore_prefix, false).map_err(wrap_err)
937        };
938        let subst_re = |msg: &str, vars: &BTreeMap<String, String>| {
939            substitute_vars(msg, vars, &ignore_prefix, true).map_err(wrap_err)
940        };
941
942        let r = match self.command {
943            Command::Builtin(mut builtin, version_constraint) => {
944                handle_version!(version_constraint);
945                for val in builtin.args.values_mut() {
946                    *val = subst(val, &state.cmd_vars)?;
947                }
948                for line in &mut builtin.input {
949                    *line = subst(line, &state.cmd_vars)?;
950                }
951                match builtin.name.as_ref() {
952                    "check-consistency" => consistency::run_consistency_checks(state).await,
953                    "skip-consistency-checks" => {
954                        consistency::skip_consistency_checks(builtin, state)
955                    }
956                    "check-shard-tombstone" => {
957                        consistency::run_check_shard_tombstone(builtin, state).await
958                    }
959                    "duckdb-execute" => duckdb::run_execute(builtin, state).await,
960                    "duckdb-query" => duckdb::run_query(builtin, state).await,
961                    "fivetran-destination" => {
962                        fivetran::run_destination_command(builtin, state).await
963                    }
964                    "file-append" => file::run_append(builtin, state).await,
965                    "file-delete" => file::run_delete(builtin, state).await,
966                    "glue-create-schema" => glue::run_create_schema(builtin, state).await,
967                    "glue-verify-compatibility" => {
968                        glue::run_verify_compatibility(builtin, state).await
969                    }
970                    "http-request" => http::run_request(builtin, state).await,
971                    "kafka-add-partitions" => kafka::run_add_partitions(builtin, state).await,
972                    "kafka-create-topic" => kafka::run_create_topic(builtin, state).await,
973                    "kafka-wait-topic" => kafka::run_wait_topic(builtin, state).await,
974                    "kafka-delete-records" => kafka::run_delete_records(builtin, state).await,
975                    "kafka-delete-topic-flaky" => kafka::run_delete_topic(builtin, state).await,
976                    "kafka-ingest" => kafka::run_ingest(builtin, state).await,
977                    "kafka-verify-data" => kafka::run_verify_data(builtin, state).await,
978                    "kafka-verify-commit" => kafka::run_verify_commit(builtin, state).await,
979                    "kafka-verify-topic" => kafka::run_verify_topic(builtin, state).await,
980                    "mysql-connect" => mysql::run_connect(builtin, state).await,
981                    "mysql-execute" => mysql::run_execute(builtin, state).await,
982                    "nop" => nop::run_nop(),
983                    "postgres-connect" => postgres::run_connect(builtin, state).await,
984                    "postgres-execute" => postgres::run_execute(builtin, state).await,
985                    "postgres-verify-slot" => postgres::run_verify_slot(builtin, state).await,
986                    "protobuf-compile-descriptors" => {
987                        protobuf::run_compile_descriptors(builtin, state).await
988                    }
989                    "psql-execute" => psql::run_execute(builtin, state).await,
990                    "s3-verify-data" => s3::run_verify_data(builtin, state).await,
991                    "s3-verify-keys" => s3::run_verify_keys(builtin, state).await,
992                    "s3-file-upload" => s3::run_upload(builtin, state).await,
993                    "s3-set-presigned-url" => s3::run_set_presigned_url(builtin, state).await,
994                    "s3-upload-parquet-types" => s3::run_upload_parquet_types(builtin, state).await,
995                    "s3-upload-parquet-unsorted-map" => {
996                        s3::run_upload_parquet_unsorted_map(builtin, state).await
997                    }
998                    "schema-registry-publish" => schema_registry::run_publish(builtin, state).await,
999                    "schema-registry-verify" => schema_registry::run_verify(builtin, state).await,
1000                    "schema-registry-wait" => schema_registry::run_wait(builtin, state).await,
1001                    "skip-if" => skip_if::run_skip_if(builtin, state).await,
1002                    "skip-end" => skip_end::run_skip_end(),
1003                    "sql-server-connect" => sql_server::run_connect(builtin, state).await,
1004                    "sql-server-execute" => sql_server::run_execute(builtin, state).await,
1005                    "sql-server-set-from-sql" => sql_server::run_set_from_sql(builtin, state).await,
1006                    "persist-force-compaction" => {
1007                        persist::run_force_compaction(builtin, state).await
1008                    }
1009                    "random-sleep" => sleep::run_random_sleep(builtin),
1010                    "set-regex" => set::run_regex_set(builtin, state),
1011                    "unset-regex" => set::run_regex_unset(builtin, state),
1012                    "set-sql-timeout" => set::run_sql_timeout(builtin, state),
1013                    "set-max-tries" => set::run_max_tries(builtin, state),
1014                    "sleep-is-probably-flaky-i-have-justified-my-need-with-a-comment" => {
1015                        sleep::run_sleep(builtin)
1016                    }
1017                    "set" => set::set_vars(builtin, state),
1018                    "set-arg-default" => set::run_set_arg_default(builtin, state),
1019                    "set-from-sql" => set::run_set_from_sql(builtin, state).await,
1020                    "set-from-file" => set::run_set_from_file(builtin, state).await,
1021                    "webhook-append" => webhook::run_append(builtin, state).await,
1022                    _ => {
1023                        return Err(PosError::new(
1024                            anyhow!("unknown built-in command {}", builtin.name),
1025                            self.pos,
1026                        ));
1027                    }
1028                }
1029            }
1030            Command::Sql(mut sql, version_constraint) => {
1031                handle_version!(version_constraint);
1032                sql.query = subst(&sql.query, &state.cmd_vars)?;
1033                if let SqlOutput::Full { expected_rows, .. } = &mut sql.expected_output {
1034                    for row in expected_rows {
1035                        for col in row {
1036                            *col = subst(col, &state.cmd_vars)?;
1037                        }
1038                    }
1039                }
1040                sql::run_sql(sql, state).await
1041            }
1042            Command::FailSql(mut sql, version_constraint) => {
1043                handle_version!(version_constraint);
1044                sql.query = subst(&sql.query, &state.cmd_vars)?;
1045                sql.expected_error = match &sql.expected_error {
1046                    SqlExpectedError::Contains(s) => {
1047                        SqlExpectedError::Contains(subst(s, &state.cmd_vars)?)
1048                    }
1049                    SqlExpectedError::Exact(s) => {
1050                        SqlExpectedError::Exact(subst(s, &state.cmd_vars)?)
1051                    }
1052                    SqlExpectedError::Regex(s) => {
1053                        SqlExpectedError::Regex(subst_re(s, &state.cmd_vars)?)
1054                    }
1055                    SqlExpectedError::Timeout => SqlExpectedError::Timeout,
1056                };
1057                sql.expected_detail = match sql.expected_detail {
1058                    Some(s) => Some(subst(&s, &state.cmd_vars)?),
1059                    None => None,
1060                };
1061                sql.expected_hint = match sql.expected_hint {
1062                    Some(s) => Some(subst(&s, &state.cmd_vars)?),
1063                    None => None,
1064                };
1065                sql::run_fail_sql(sql, state).await
1066            }
1067        };
1068
1069        r.map_err(wrap_err)
1070    }
1071}
1072
1073/// Substituted `${}`-delimited variables from `vars` into `msg`
1074fn substitute_vars(
1075    msg: &str,
1076    vars: &BTreeMap<String, String>,
1077    ignore_prefix: &Option<String>,
1078    regex_escape: bool,
1079) -> Result<String, anyhow::Error> {
1080    static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\{([^}]+)\}").unwrap());
1081    let mut err = None;
1082    let out = RE.replace_all(msg, |caps: &Captures| {
1083        let name = &caps[1];
1084        if let Some(ignore_prefix) = &ignore_prefix {
1085            if name.starts_with(format!("{}.", ignore_prefix).as_str()) {
1086                // Do not substitute, leave original variable name in place
1087                return caps.get(0).unwrap().as_str().to_string();
1088            }
1089        }
1090
1091        if let Some(val) = vars.get(name) {
1092            if regex_escape {
1093                regex::escape(val)
1094            } else {
1095                val.to_string()
1096            }
1097        } else {
1098            err = Some(anyhow!("unknown variable: {}", name));
1099            "#VAR-MISSING#".to_string()
1100        }
1101    });
1102    match err {
1103        Some(err) => Err(err),
1104        None => Ok(out.into_owned()),
1105    }
1106}
1107
1108/// Initializes a [`State`] object by connecting to the various external
1109/// services specified in `config`.
1110///
1111/// Returns the initialized `State` and a cleanup future. The cleanup future
1112/// should be `await`ed only *after* dropping the `State` to check whether any
1113/// errors occured while dropping the `State`. This awkward API is a workaround
1114/// for the lack of `AsyncDrop` support in Rust.
1115pub async fn create_state(
1116    config: &Config,
1117) -> Result<(State, impl Future<Output = Result<(), anyhow::Error>>), anyhow::Error> {
1118    let seed = config
1119        .seed
1120        .clone()
1121        .unwrap_or_else(|| format!("{:010}", rand::random::<u32>()));
1122
1123    let (_tempfile, temp_path) = match &config.temp_dir {
1124        Some(temp_dir) => {
1125            fs::create_dir_all(temp_dir).context("creating temporary directory")?;
1126            (None, PathBuf::from(&temp_dir))
1127        }
1128        _ => {
1129            // Stash the tempfile object so that it does not go out of scope and delete
1130            // the tempdir prematurely
1131            let tempfile_handle = tempfile::tempdir().context("creating temporary directory")?;
1132            let temp_path = tempfile_handle.path().to_path_buf();
1133            (Some(tempfile_handle), temp_path)
1134        }
1135    };
1136
1137    let materialize_catalog_config = config.materialize_catalog_config.clone();
1138
1139    let materialize_url = util::postgres::config_url(&config.materialize_pgconfig)?;
1140    info!("Connecting to {}", materialize_url.as_str());
1141    let (pgclient, pgconn) = Retry::default()
1142        .max_duration(config.default_timeout)
1143        .retry_async_canceling(|_| async move {
1144            let mut pgconfig = config.materialize_pgconfig.clone();
1145            pgconfig.connect_timeout(config.default_timeout);
1146            let tls = make_tls(&pgconfig)?;
1147            pgconfig.connect(tls).await.map_err(|e| anyhow!(e))
1148        })
1149        .await?;
1150
1151    let pgconn_task =
1152        task::spawn(|| "pgconn_task", pgconn).map(|join| join.context("running SQL connection"));
1153
1154    let materialize_state =
1155        create_materialize_state(&config, materialize_catalog_config, pgclient).await?;
1156
1157    let schema_registry_url = config.schema_registry_url.to_owned();
1158
1159    let ccsr_client = {
1160        let mut ccsr_config = mz_ccsr::ClientConfig::new(schema_registry_url.clone());
1161
1162        if let Some(cert_path) = &config.cert_path {
1163            let cert = fs::read(cert_path).context("reading cert")?;
1164            let pass = config.cert_password.as_deref().unwrap_or("").to_owned();
1165            let ident = mz_ccsr::tls::Identity::from_pkcs12_der(cert, pass)
1166                .context("reading keystore file as pkcs12")?;
1167            ccsr_config = ccsr_config.identity(ident);
1168        }
1169
1170        if let Some(ccsr_username) = &config.ccsr_username {
1171            ccsr_config = ccsr_config.auth(ccsr_username.clone(), config.ccsr_password.clone());
1172        }
1173
1174        ccsr_config.build().context("Creating CCSR client")?
1175    };
1176
1177    let (kafka_addr, kafka_admin, kafka_admin_opts, kafka_producer, kafka_topics, kafka_config) = {
1178        use rdkafka::admin::{AdminClient, AdminOptions};
1179        use rdkafka::producer::FutureProducer;
1180
1181        let mut kafka_config = create_new_client_config_simple();
1182        kafka_config.set("bootstrap.servers", &config.kafka_addr);
1183        kafka_config.set("group.id", "materialize-testdrive");
1184        kafka_config.set("auto.offset.reset", "earliest");
1185        kafka_config.set("isolation.level", "read_committed");
1186        if let Some(cert_path) = &config.cert_path {
1187            kafka_config.set("security.protocol", "ssl");
1188            kafka_config.set("ssl.keystore.location", cert_path);
1189            if let Some(cert_password) = &config.cert_password {
1190                kafka_config.set("ssl.keystore.password", cert_password);
1191            }
1192        }
1193        kafka_config.set("message.max.bytes", "15728640");
1194
1195        for (key, value) in &config.kafka_opts {
1196            kafka_config.set(key, value);
1197        }
1198
1199        let admin: AdminClient<_> = kafka_config
1200            .create_with_context(MzClientContext::default())
1201            .with_context(|| format!("opening Kafka connection: {}", config.kafka_addr))?;
1202
1203        let admin_opts = AdminOptions::new().operation_timeout(Some(config.default_timeout));
1204
1205        let producer: FutureProducer<_> = kafka_config
1206            .create_with_context(MzClientContext::default())
1207            .with_context(|| format!("opening Kafka producer connection: {}", config.kafka_addr))?;
1208
1209        let topics = BTreeMap::new();
1210
1211        (
1212            config.kafka_addr.to_owned(),
1213            admin,
1214            admin_opts,
1215            producer,
1216            topics,
1217            kafka_config,
1218        )
1219    };
1220
1221    let mut state = State {
1222        config: config.clone(),
1223
1224        // === Testdrive state. ===
1225        arg_vars: config.arg_vars.clone(),
1226        cmd_vars: BTreeMap::new(),
1227        seed,
1228        temp_path,
1229        _tempfile,
1230        default_timeout: config.default_timeout,
1231        timeout: config.default_timeout,
1232        max_tries: config.default_max_tries,
1233        initial_backoff: config.initial_backoff,
1234        backoff_factor: config.backoff_factor,
1235        consistency_checks: config.consistency_checks,
1236        check_statement_logging: config.check_statement_logging,
1237        consistency_checks_adhoc_skip: false,
1238        regex: None,
1239        regex_replacement: set::DEFAULT_REGEX_REPLACEMENT.into(),
1240        rewrite_results: config.rewrite_results,
1241        error_line_count: 0,
1242        error_string: "".to_string(),
1243
1244        // === Materialize state. ===
1245        materialize: materialize_state,
1246
1247        // === Persist state. ===
1248        persist_consensus_url: config.persist_consensus_url.clone(),
1249        persist_blob_url: config.persist_blob_url.clone(),
1250        build_info: config.build_info,
1251        persist_clients: PersistClientCache::new(
1252            PersistConfig::new_default_configs(config.build_info, SYSTEM_TIME.clone()),
1253            &MetricsRegistry::new(),
1254            |_, _| PubSubClientConnection::noop(),
1255        ),
1256
1257        // === Confluent state. ===
1258        schema_registry_url,
1259        ccsr_client,
1260        kafka_addr,
1261        kafka_admin,
1262        kafka_admin_opts,
1263        kafka_config,
1264        kafka_default_partitions: config.kafka_default_partitions,
1265        kafka_producer,
1266        kafka_topics,
1267        kafka_verify_topics: BTreeSet::new(),
1268
1269        // === AWS state. ===
1270        aws_account: config.aws_account.clone(),
1271        aws_config: config.aws_config.clone(),
1272
1273        // === Database driver state. ===
1274        duckdb_clients: BTreeMap::new(),
1275        mysql_clients: BTreeMap::new(),
1276        postgres_clients: BTreeMap::new(),
1277        sql_server_clients: BTreeMap::new(),
1278        background_tasks: Vec::new(),
1279
1280        // === Fivetran state. ===
1281        fivetran_destination_url: config.fivetran_destination_url.clone(),
1282        fivetran_destination_files_path: config.fivetran_destination_files_path.clone(),
1283
1284        rewrites: Vec::new(),
1285        rewrite_pos_start: 0,
1286        rewrite_pos_end: 0,
1287    };
1288    state.initialize_cmd_vars().await?;
1289    Ok((state, pgconn_task))
1290}
1291
1292async fn create_materialize_state(
1293    config: &&Config,
1294    materialize_catalog_config: Option<CatalogConfig>,
1295    pgclient: tokio_postgres::Client,
1296) -> Result<MaterializeState, anyhow::Error> {
1297    let materialize_url = util::postgres::config_url(&config.materialize_pgconfig)?;
1298    let materialize_internal_url =
1299        util::postgres::config_url(&config.materialize_internal_pgconfig)?;
1300
1301    for (key, value) in &config.materialize_params {
1302        // Session parameter values are raw SQL fragments from testdrive config.
1303        #[allow(clippy::disallowed_methods)]
1304        pgclient
1305            .batch_execute(&format!("SET {key} = {value}"))
1306            .await
1307            .context("setting session parameter")?;
1308    }
1309
1310    let materialize_user = config
1311        .materialize_pgconfig
1312        .get_user()
1313        .expect("testdrive URL must contain user")
1314        .to_string();
1315
1316    let materialize_sql_addr = format!(
1317        "{}:{}",
1318        materialize_url.host_str().unwrap(),
1319        materialize_url.port().unwrap()
1320    );
1321    let materialize_http_addr = format!(
1322        "{}:{}",
1323        materialize_url.host_str().unwrap(),
1324        config.materialize_http_port
1325    );
1326    let materialize_internal_sql_addr = format!(
1327        "{}:{}",
1328        materialize_internal_url.host_str().unwrap(),
1329        materialize_internal_url.port().unwrap()
1330    );
1331    let materialize_password_sql_addr = format!(
1332        "{}:{}",
1333        materialize_url.host_str().unwrap(),
1334        config.materialize_password_sql_port
1335    );
1336    let materialize_sasl_sql_addr = format!(
1337        "{}:{}",
1338        materialize_url.host_str().unwrap(),
1339        config.materialize_sasl_sql_port
1340    );
1341    let materialize_internal_http_addr = format!(
1342        "{}:{}",
1343        materialize_internal_url.host_str().unwrap(),
1344        config.materialize_internal_http_port
1345    );
1346    let environment_id = pg_query_one(&pgclient, pg_sql!("SELECT mz_environment_id()"), &[])
1347        .await?
1348        .get::<_, String>(0)
1349        .parse()
1350        .context("parsing environment ID")?;
1351
1352    let bootstrap_args = BootstrapArgs {
1353        cluster_replica_size_map: config.materialize_cluster_replica_sizes.clone(),
1354        default_cluster_replica_size: "ABC".to_string(),
1355        default_cluster_replication_factor: 1,
1356        bootstrap_role: None,
1357    };
1358
1359    let materialize_state = MaterializeState {
1360        catalog_config: materialize_catalog_config,
1361        sql_addr: materialize_sql_addr,
1362        use_https: config.materialize_use_https,
1363        http_addr: materialize_http_addr,
1364        internal_sql_addr: materialize_internal_sql_addr,
1365        internal_http_addr: materialize_internal_http_addr,
1366        password_sql_addr: materialize_password_sql_addr,
1367        sasl_sql_addr: materialize_sasl_sql_addr,
1368        user: materialize_user,
1369        pgclient,
1370        environment_id,
1371        bootstrap_args,
1372    };
1373
1374    Ok(materialize_state)
1375}