mz/
sql_client.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::{
11    env,
12    fs::OpenOptions,
13    io::Write,
14    path::{Path, PathBuf},
15    process::Command,
16};
17
18use mz_cloud_api::client::region::RegionInfo;
19use mz_frontegg_auth::AppPassword;
20use url::Url;
21
22use crate::error::Error;
23
24/// The [application_name](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-APPLICATION-NAME)
25/// which gets reported to the Postgres server we're connecting to.
26const PG_APPLICATION_NAME: &str = "mz_psql";
27
28/// Default filename for the custom .psqlrc file.
29const PG_PSQLRC_MZ_FILENAME: &str = ".psqlrc-mz";
30
31// Default content for the .psqlrc-mz file.
32// It enables timing and includes all the configuration from
33// the main `.psqlrc` file.
34const PG_PSQLRC_MZ_DEFAULT_CONTENT: &str = "\\timing\n\\include ~/.psqlrc";
35
36/// Configures the required parameters of a [`Client`].
37pub struct ClientConfig {
38    /// A singular, legitimate app password that will remain in use to identify
39    /// the user throughout the client's existence.
40    pub app_password: AppPassword,
41}
42
43pub struct Client {
44    pub(crate) app_password: AppPassword,
45}
46
47impl Client {
48    /// Creates a new `Client` from its required configuration parameters.
49    pub fn new(config: ClientConfig) -> Client {
50        Client {
51            app_password: config.app_password,
52        }
53    }
54
55    /// Build the PSQL url to connect into a environment
56    fn build_psql_url(&self, region_info: &RegionInfo, user: &str, cluster: Option<String>) -> Url {
57        let mut url = Url::parse(&format!("postgres://{}", region_info.sql_address))
58            .expect("url known to be valid");
59        url.set_username(user).unwrap();
60        url.set_path("materialize");
61
62        if let Some(cert_file) = openssl_probe::probe().cert_file {
63            url.query_pairs_mut()
64                .append_pair("sslmode", "verify-full")
65                .append_pair("sslrootcert", &cert_file.to_string_lossy());
66        } else {
67            url.query_pairs_mut().append_pair("sslmode", "require");
68        }
69
70        if let Some(cluster) = cluster {
71            url.query_pairs_mut()
72                .append_pair("options", &format!("--cluster={}", cluster));
73        }
74
75        url
76    }
77
78    /// Creates and fills a file with content
79    /// if it does not exists.
80    fn create_file_with_content_if_not_exists(
81        &self,
82        path: &PathBuf,
83        content: Option<&[u8]>,
84    ) -> Result<(), Error> {
85        // Create the new file and use `.create_new(true)` to avoid
86        // race conditions: https://doc.rust-lang.org/stable/std/fs/struct.OpenOptions.html#method.create_new
87        match OpenOptions::new().write(true).create_new(true).open(path) {
88            Ok(mut file) => {
89                if let Some(content) = content {
90                    let _ = file.write_all(content);
91                }
92            }
93            Err(e) => {
94                if e.kind() == std::io::ErrorKind::AlreadyExists {
95                    // Do nothing.
96                } else {
97                    return Err(Error::IOError(e));
98                }
99            }
100        }
101
102        Ok(())
103    }
104
105    /// This function configures an own .psqlrc-mz file
106    /// to include the '\timing' function every time
107    /// the user executes the `mz sql` command.
108    pub fn configure_psqlrc(&self) -> Result<(), Error> {
109        // Look for the '.psqlrc' file in the home dir.
110        let Some(mut path) = dirs::home_dir() else {
111            return Err(Error::HomeDirNotFoundError);
112        };
113        path.push(PG_PSQLRC_MZ_FILENAME);
114
115        let _ = self.create_file_with_content_if_not_exists(
116            &path,
117            Some(PG_PSQLRC_MZ_DEFAULT_CONTENT.as_bytes()),
118        );
119
120        // Check if '.psqlrc' exists, if it doesn't, create one.
121        // Otherwise the '\include ~/.psqlrc' line
122        // will throw an error message in every execution.
123        path.pop();
124        path.push(".psqlrc");
125        let _ = self.create_file_with_content_if_not_exists(&path, None);
126
127        Ok(())
128    }
129
130    /// Returns a sql shell command associated with this context
131    pub fn shell(&self, region_info: &RegionInfo, user: &str, cluster: Option<String>) -> Command {
132        // Feels ok to avoid stopping the executing if
133        // we can't configure the file.
134        // Worst case scenario timing will not be enabled.
135        let _ = self.configure_psqlrc();
136
137        let mut command = Command::new("psql");
138        command
139            .arg(self.build_psql_url(region_info, user, cluster).as_str())
140            .env("PGPASSWORD", &self.app_password.to_string())
141            .env("PGAPPNAME", PG_APPLICATION_NAME)
142            .env("PSQLRC", "~/.psqlrc-mz");
143
144        command
145    }
146
147    fn find<P>(&self, exe_name: P) -> Option<PathBuf>
148    where
149        P: AsRef<Path>,
150    {
151        env::var_os("PATH").and_then(|paths| {
152            env::split_paths(&paths)
153                .filter_map(|dir| {
154                    let full_path = dir.join(&exe_name);
155                    if full_path.is_file() {
156                        Some(full_path)
157                    } else {
158                        None
159                    }
160                })
161                .next()
162        })
163    }
164
165    /// Runs pg_isready to check if an environment is healthy
166    pub fn is_ready(&self, region_info: &RegionInfo, user: &str) -> Result<bool, Error> {
167        if self.find("pg_isready").is_some() {
168            let mut command = Command::new("pg_isready");
169            Ok(command
170                .args(vec![
171                    "-q",
172                    "-d",
173                    self.build_psql_url(region_info, user, None).as_str(),
174                ])
175                .env("PGPASSWORD", &self.app_password.to_string())
176                .env("PGAPPNAME", PG_APPLICATION_NAME)
177                .output()?
178                .status
179                .success())
180        } else {
181            panic!("the pg_isready program is not present. Make sure it is available in the $PATH.")
182        }
183    }
184}