Skip to main content

cli/
database.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::time::Duration;
16
17use base64::Engine;
18use base64::engine::general_purpose;
19use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME};
20use common_error::ext::BoxedError;
21use humantime::format_duration;
22use serde_json::Value;
23use servers::http::GreptimeQueryOutput;
24use servers::http::header::constants::GREPTIME_DB_HEADER_TIMEOUT;
25use servers::http::result::greptime_result_v1::GreptimedbV1Response;
26use snafu::ResultExt;
27
28use crate::error::{
29    BuildClientSnafu, HttpQuerySqlSnafu, ParseProxyOptsSnafu, Result, SerdeJsonSnafu,
30};
31
32#[derive(Debug, Clone)]
33pub struct DatabaseClient {
34    addr: String,
35    catalog: String,
36    auth_header: Option<String>,
37    timeout: Duration,
38    proxy: Option<reqwest::Proxy>,
39    no_proxy: bool,
40}
41
42pub fn parse_proxy_opts(
43    proxy: Option<String>,
44    no_proxy: bool,
45) -> std::result::Result<Option<reqwest::Proxy>, BoxedError> {
46    if no_proxy {
47        return Ok(None);
48    }
49    proxy
50        .map(|proxy| {
51            reqwest::Proxy::all(proxy)
52                .context(ParseProxyOptsSnafu)
53                .map_err(BoxedError::new)
54        })
55        .transpose()
56}
57
58impl DatabaseClient {
59    pub fn new(
60        addr: String,
61        catalog: String,
62        auth_basic: Option<String>,
63        timeout: Duration,
64        proxy: Option<reqwest::Proxy>,
65        no_proxy: bool,
66    ) -> Self {
67        let auth_header = if let Some(basic) = auth_basic {
68            let encoded = general_purpose::STANDARD.encode(basic);
69            Some(format!("basic {}", encoded))
70        } else {
71            None
72        };
73
74        if no_proxy {
75            common_telemetry::info!("Proxy disabled");
76        } else if let Some(ref proxy) = proxy {
77            common_telemetry::info!("Using proxy: {:?}", proxy);
78        } else {
79            common_telemetry::info!("Using system proxy(if any)");
80        }
81
82        Self {
83            addr,
84            catalog,
85            auth_header,
86            timeout,
87            proxy,
88            no_proxy,
89        }
90    }
91
92    pub fn addr(&self) -> &str {
93        &self.addr
94    }
95
96    pub async fn sql_in_public(&self, sql: &str) -> Result<Option<Vec<Vec<Value>>>> {
97        self.sql(sql, DEFAULT_SCHEMA_NAME).await
98    }
99
100    /// Requires the explicit packed-import protocol before any restore mutation.
101    pub async fn require_packed_import(&self) -> Result<()> {
102        let url = format!("http://{}/v1/capabilities", self.addr);
103        let mut builder = reqwest::Client::builder().timeout(self.timeout);
104        if let Some(proxy) = self.proxy.clone() {
105            builder = builder.proxy(proxy);
106        }
107        if self.no_proxy {
108            builder = builder.no_proxy();
109        }
110        let client = builder.build().context(BuildClientSnafu)?;
111        let mut request = client.get(&url);
112        if let Some(auth) = &self.auth_header {
113            request = request.header("Authorization", auth);
114        }
115        let response = request.send().await.with_context(|_| HttpQuerySqlSnafu {
116            reason: "packed import capability request failed",
117        })?;
118        if response.status() == reqwest::StatusCode::NOT_FOUND {
119            return crate::error::InvalidArgumentsSnafu {
120                msg: "target does not support packed import",
121            }
122            .fail();
123        }
124        let response = response
125            .error_for_status()
126            .with_context(|_| HttpQuerySqlSnafu {
127                reason: "packed import capability request rejected",
128            })?;
129        let body = response.text().await.with_context(|_| HttpQuerySqlSnafu {
130            reason: "cannot read capability response",
131        })?;
132        let value: Value = serde_json::from_str(&body).context(SerdeJsonSnafu)?;
133        if !value.is_object() {
134            return crate::error::UnexpectedSnafu {
135                msg: "invalid capability response: expected JSON object",
136            }
137            .fail();
138        }
139        if value.get("metric_packed_import").and_then(Value::as_u64) != Some(1) {
140            return crate::error::InvalidArgumentsSnafu {
141                msg: "target does not support packed import version 1",
142            }
143            .fail();
144        }
145        Ok(())
146    }
147
148    /// Execute sql query.
149    pub async fn sql(&self, sql: &str, schema: &str) -> Result<Option<Vec<Vec<Value>>>> {
150        let body = self.sql_response(sql, schema).await?;
151        Ok(body.output().first().and_then(|output| match output {
152            GreptimeQueryOutput::Records(records) => Some(records.rows().clone()),
153            GreptimeQueryOutput::AffectedRows(_) => None,
154        }))
155    }
156
157    pub(crate) async fn sql_response(
158        &self,
159        sql: &str,
160        schema: &str,
161    ) -> Result<GreptimedbV1Response> {
162        let url = format!("http://{}/v1/sql", self.addr);
163        let params = [
164            ("db", format!("{}-{}", self.catalog, schema)),
165            ("sql", sql.to_string()),
166        ];
167        let mut builder = reqwest::Client::builder();
168        if let Some(proxy) = self.proxy.clone() {
169            builder = builder.proxy(proxy);
170        }
171        if self.no_proxy {
172            builder = builder.no_proxy();
173        }
174        let client = builder.build().context(BuildClientSnafu)?;
175        let mut request = client
176            .post(&url)
177            .form(&params)
178            .header("Content-Type", "application/x-www-form-urlencoded");
179        if let Some(ref auth) = self.auth_header {
180            request = request.header("Authorization", auth);
181        }
182
183        request = request.header(
184            GREPTIME_DB_HEADER_TIMEOUT,
185            format_duration(self.timeout).to_string(),
186        );
187
188        let response = request.send().await.with_context(|_| HttpQuerySqlSnafu {
189            reason: format!("bad url: {}", url),
190        })?;
191        let response = response
192            .error_for_status()
193            .with_context(|_| HttpQuerySqlSnafu {
194                reason: format!("query failed: {}", sql),
195            })?;
196
197        let text = response.text().await.with_context(|_| HttpQuerySqlSnafu {
198            reason: "cannot get response text".to_string(),
199        })?;
200
201        serde_json::from_str::<GreptimedbV1Response>(&text).context(SerdeJsonSnafu)
202    }
203}
204
205/// Split at `-`.
206pub(crate) fn split_database(database: &str) -> Result<(String, Option<String>)> {
207    let (catalog, schema) = match database.split_once('-') {
208        Some((catalog, schema)) => (catalog, schema),
209        None => (DEFAULT_CATALOG_NAME, database),
210    };
211
212    if schema == "*" {
213        Ok((catalog.to_string(), None))
214    } else {
215        Ok((catalog.to_string(), Some(schema.to_string())))
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    #[tokio::test]
222    async fn packed_capability_probe_checks_auth_and_protocol_version() {
223        use tokio::io::{AsyncReadExt, AsyncWriteExt};
224        for (status, body, supported) in [
225            (200, r#"{"metric_packed_import":1,"future":2}"#, true),
226            (200, "{}", false),
227            (200, r#"{"metric_packed_import":2}"#, false),
228            (404, "{}", false),
229            (401, "{}", false),
230            (403, "{}", false),
231            (200, "[]", false),
232            (200, "invalid-json", false),
233        ] {
234            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
235            let address = listener.local_addr().unwrap();
236            let server = tokio::spawn(async move {
237                let (mut socket, _) = listener.accept().await.unwrap();
238                let mut request = vec![0; 4096];
239                let n = socket.read(&mut request).await.unwrap();
240                let request = String::from_utf8_lossy(&request[..n]).to_lowercase();
241                assert!(request.starts_with("get /v1/capabilities "));
242                assert!(request.contains("authorization: basic dxnlcjpwyxnzd29yza=="));
243                socket.write_all(format!("HTTP/1.1 {status} Response\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
244            });
245            let client = super::DatabaseClient::new(
246                address.to_string(),
247                "greptime".into(),
248                Some("user:password".into()),
249                std::time::Duration::from_secs(5),
250                None,
251                true,
252            );
253            assert_eq!(
254                client.require_packed_import().await.is_ok(),
255                supported,
256                "{status}: {body}"
257            );
258            server.await.unwrap();
259        }
260    }
261
262    use super::*;
263
264    #[test]
265    fn test_split_database() {
266        let result = split_database("catalog-schema").unwrap();
267        assert_eq!(result, ("catalog".to_string(), Some("schema".to_string())));
268
269        let result = split_database("schema").unwrap();
270        assert_eq!(result, ("greptime".to_string(), Some("schema".to_string())));
271
272        let result = split_database("catalog-*").unwrap();
273        assert_eq!(result, ("catalog".to_string(), None));
274
275        let result = split_database("*").unwrap();
276        assert_eq!(result, ("greptime".to_string(), None));
277    }
278}