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