Skip to main content

sqlness_runner/
util.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::io::Read;
16use std::net::SocketAddr;
17use std::path::{Path, PathBuf};
18use std::process::Command;
19use std::time::Duration;
20
21use sha2::{Digest, Sha256};
22use tokio::io::AsyncWriteExt;
23use tokio::net::TcpSocket;
24use tokio::time;
25use tokio_stream::StreamExt;
26
27/// Check port every 0.1 second.
28const PORT_CHECK_INTERVAL: Duration = Duration::from_millis(100);
29
30pub const PROGRAM: &str = "greptime";
31
32fn http_proxy() -> Option<String> {
33    for proxy in ["http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY"] {
34        if let Ok(proxy_addr) = std::env::var(proxy) {
35            println!("Getting Proxy from env var: {}={}", proxy, proxy_addr);
36            return Some(proxy_addr);
37        }
38    }
39    None
40}
41
42fn https_proxy() -> Option<String> {
43    for proxy in ["https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"] {
44        if let Ok(proxy_addr) = std::env::var(proxy) {
45            println!("Getting Proxy from env var: {}={}", proxy, proxy_addr);
46            return Some(proxy_addr);
47        }
48    }
49    None
50}
51
52async fn download_files(url: &str, path: &str) {
53    let proxy = if url.starts_with("http://") {
54        http_proxy().map(|proxy| reqwest::Proxy::http(proxy).unwrap())
55    } else if url.starts_with("https://") {
56        https_proxy().map(|proxy| reqwest::Proxy::https(proxy).unwrap())
57    } else {
58        None
59    };
60
61    let client = proxy
62        .map(|proxy| {
63            reqwest::Client::builder()
64                .proxy(proxy)
65                .build()
66                .expect("Failed to build client")
67        })
68        .unwrap_or(reqwest::Client::new());
69
70    let mut file = tokio::fs::File::create(path)
71        .await
72        .unwrap_or_else(|_| panic!("Failed to create file in {path}"));
73    println!("Downloading {}...", url);
74
75    let resp = client
76        .get(url)
77        .send()
78        .await
79        .expect("Failed to send download request")
80        .error_for_status()
81        .unwrap_or_else(|e| panic!("Failed to download {url}: {e}"));
82    let len = resp.content_length();
83    let mut stream = resp.bytes_stream();
84    let mut size_downloaded = 0;
85
86    while let Some(chunk_result) = stream.next().await {
87        let chunk = chunk_result.unwrap();
88        size_downloaded += chunk.len();
89        if let Some(len) = len {
90            print!("\rDownloading {}/{} bytes", size_downloaded, len);
91        } else {
92            print!("\rDownloaded {} bytes", size_downloaded);
93        }
94
95        file.write_all(&chunk).await.unwrap();
96    }
97
98    file.flush().await.unwrap();
99
100    println!("\nDownloaded {}", url);
101}
102
103fn decompress(archive: &str, dest: &str) {
104    let tar = std::fs::File::open(archive).unwrap();
105    let dec = flate2::read::GzDecoder::new(tar);
106    let mut a = tar::Archive::new(dec);
107    a.unpack(dest).unwrap();
108}
109
110/// Use curl to download the binary from the release page.
111///
112/// # Arguments
113///
114/// * `version` - The version of the binary to download. i.e. "v0.9.5"
115pub async fn pull_binary(version: &str) {
116    // Release assets name macOS builds `darwin`.
117    let os = match std::env::consts::OS {
118        "macos" => "darwin",
119        os => os,
120    };
121    let arch = match std::env::consts::ARCH {
122        "x86_64" => "amd64",
123        "aarch64" => "arm64",
124        _ => panic!("Unsupported arch: {}", std::env::consts::ARCH),
125    };
126    let triple = format!("greptime-{}-{}-{}", os, arch, version);
127    let filename = format!("{triple}.tar.gz");
128
129    let url = format!(
130        "https://github.com/GreptimeTeam/greptimedb/releases/download/{version}/{filename}"
131    );
132    println!("Downloading {version} binary from {}", url);
133
134    // mkdir {version}
135    let _ = std::fs::create_dir(version);
136
137    let archive = Path::new(version).join(filename);
138    let folder_path = Path::new(version);
139
140    // download the binary to the version directory
141    download_files(&url, &archive.to_string_lossy()).await;
142
143    let checksum_file = format!("{triple}.sha256sum");
144    let checksum_url = format!(
145        "https://github.com/GreptimeTeam/greptimedb/releases/download/{version}/{checksum_file}"
146    );
147    download_files(
148        &checksum_url,
149        &PathBuf::from_iter([version, &checksum_file]).to_string_lossy(),
150    )
151    .await;
152
153    // verify the checksum
154    let mut file = std::fs::File::open(&archive).unwrap();
155    let mut sha256 = Sha256::new();
156    std::io::copy(&mut file, &mut sha256).unwrap();
157    let checksum: Vec<u8> = sha256.finalize().to_vec();
158
159    let mut expected_checksum =
160        std::fs::File::open(PathBuf::from_iter([version, &checksum_file])).unwrap();
161    let mut buf = String::new();
162    expected_checksum.read_to_string(&mut buf).unwrap();
163    let expected_checksum = hex::decode(buf.lines().next().unwrap()).unwrap();
164
165    assert_eq!(
166        checksum, expected_checksum,
167        "Checksum mismatched, downloaded file is corrupted"
168    );
169
170    decompress(&archive.to_string_lossy(), &folder_path.to_string_lossy());
171    println!("Downloaded and extracted {version} binary to {folder_path:?}");
172
173    // move the binary to the version directory
174    std::fs::rename(
175        PathBuf::from_iter([version, &triple, "greptime"]),
176        PathBuf::from_iter([version, "greptime"]),
177    )
178    .unwrap();
179
180    // remove the archive and inner folder
181    std::fs::remove_file(&archive).unwrap();
182    std::fs::remove_dir(PathBuf::from_iter([version, &triple])).unwrap();
183}
184
185/// Pull the binary if it does not exist and `pull_version_on_need` is true.
186pub async fn maybe_pull_binary(version: &str, pull_version_on_need: bool) {
187    let exist = Path::new(version).join(PROGRAM).is_file();
188    match (exist, pull_version_on_need) {
189        (true, _) => println!("Binary {version} exists"),
190        (false, false) => panic!(
191            "Binary {version} does not exist, please run with --pull-version-on-need or manually download it"
192        ),
193        (false, true) => {
194            pull_binary(version).await;
195        }
196    }
197}
198
199/// Set up a standalone etcd in docker.
200pub fn setup_etcd(client_ports: Vec<u16>, peer_port: Option<u16>, etcd_version: Option<&str>) {
201    if std::process::Command::new("docker")
202        .args(["-v"])
203        .status()
204        .is_err()
205    {
206        panic!("Docker is not installed");
207    }
208    let peer_port = peer_port.unwrap_or(2380);
209    let exposed_port: Vec<_> = client_ports.iter().chain(Some(&peer_port)).collect();
210    let exposed_port_str = exposed_port
211        .iter()
212        .flat_map(|p| ["-p".to_string(), format!("{p}:{p}")])
213        .collect::<Vec<_>>();
214    let etcd_version = etcd_version.unwrap_or("v3.5.17");
215    let etcd_image = format!("quay.io/coreos/etcd:{etcd_version}");
216    let peer_url = format!("http://0.0.0.0:{peer_port}");
217    let my_local_ip = local_ip_address::local_ip().unwrap();
218
219    let my_local_ip_str = my_local_ip.to_string();
220
221    let mut arg_list = vec![];
222    arg_list.extend([
223        "run",
224        "-d",
225        "-v",
226        "/usr/share/ca-certificates/:/etc/ssl/certs",
227    ]);
228    arg_list.extend(exposed_port_str.iter().map(std::ops::Deref::deref));
229    arg_list.extend([
230        "--name",
231        "etcd",
232        &etcd_image,
233        "etcd",
234        "-name",
235        "etcd0",
236        "-advertise-client-urls",
237    ]);
238
239    let adv_client_urls = client_ports
240        .iter()
241        .map(|p| format!("http://{my_local_ip_str}:{p}"))
242        .collect::<Vec<_>>()
243        .join(",");
244
245    arg_list.push(&adv_client_urls);
246
247    arg_list.extend(["-listen-client-urls"]);
248
249    let client_ports_fmt = client_ports
250        .iter()
251        .map(|p| format!("http://0.0.0.0:{p}"))
252        .collect::<Vec<_>>()
253        .join(",");
254
255    arg_list.push(&client_ports_fmt);
256
257    arg_list.push("-initial-advertise-peer-urls");
258    let advertise_peer_url = format!("http://{my_local_ip_str}:{peer_port}");
259    arg_list.push(&advertise_peer_url);
260
261    arg_list.extend(["-listen-peer-urls", &peer_url]);
262
263    arg_list.extend(["-initial-cluster-token", "etcd-cluster-1"]);
264
265    arg_list.push("-initial-cluster");
266
267    let init_cluster_url = format!("etcd0=http://{my_local_ip_str}:{peer_port}");
268
269    arg_list.push(&init_cluster_url);
270
271    arg_list.extend(["-initial-cluster-state", "new"]);
272
273    let mut cmd = std::process::Command::new("docker");
274
275    cmd.args(arg_list);
276
277    println!("Starting etcd with command: {:?}", cmd);
278
279    let status = cmd.status();
280    if status.is_err() {
281        panic!("Failed to start etcd: {:?}", status);
282    } else if let Ok(status) = status {
283        if status.success() {
284            println!(
285                "Started etcd with client ports {:?} and peer port {}, statues:{status:?}",
286                client_ports, peer_port
287            );
288        } else {
289            panic!("Failed to start etcd: {:?}", status);
290        }
291    }
292}
293
294/// Stop and remove the etcd container, failing if it cannot be confirmed absent.
295pub fn stop_rm_etcd_checked() -> Result<(), String> {
296    let status = std::process::Command::new("docker")
297        .args(["container", "rm", "--force", "etcd"])
298        .status()
299        .map_err(|error| format!("Failed to run Docker while removing etcd: {error}"))?;
300    if status.success() {
301        println!("Removed etcd container");
302        return Ok(());
303    }
304
305    let listed = std::process::Command::new("docker")
306        .args([
307            "container",
308            "ls",
309            "--all",
310            "--filter",
311            "name=^/etcd$",
312            "--format",
313            "{{.ID}}",
314        ])
315        .output()
316        .map_err(|error| {
317            format!(
318                "Docker failed to remove etcd ({status}) and could not verify its absence: {error}"
319            )
320        })?;
321    if !listed.status.success() {
322        return Err(format!(
323            "Docker failed to remove etcd ({status}) and listing containers failed: {}",
324            String::from_utf8_lossy(&listed.stderr).trim()
325        ));
326    }
327    if String::from_utf8_lossy(&listed.stdout).trim().is_empty() {
328        println!("Etcd container is already absent");
329        return Ok(());
330    }
331
332    Err(format!(
333        "Docker failed to remove etcd container (status {status}); the container still exists"
334    ))
335}
336
337/// Stop and remove the etcd container.
338///
339/// Legacy callers retain panic-on-cleanup-failure behavior. Compatibility
340/// profiles use [`stop_rm_etcd_checked`] to report the failure structurally.
341pub fn stop_rm_etcd() {
342    stop_rm_etcd_checked().unwrap_or_else(|error| panic!("{error}"));
343}
344
345/// Set up a PostgreSQL server in docker.
346pub fn setup_pg(pg_port: u16, pg_version: Option<&str>) {
347    if std::process::Command::new("docker")
348        .args(["-v"])
349        .status()
350        .is_err()
351    {
352        panic!("Docker is not installed");
353    }
354
355    let pg_image = if let Some(pg_version) = pg_version {
356        format!("postgres:{pg_version}")
357    } else {
358        "postgres:latest".to_string()
359    };
360    let pg_password = "admin";
361    let pg_user = "greptimedb";
362
363    let mut arg_list = vec![];
364    arg_list.extend(["run", "-d"]);
365
366    let pg_password_env = format!("POSTGRES_PASSWORD={pg_password}");
367    let pg_user_env = format!("POSTGRES_USER={pg_user}");
368    let pg_port_forward = format!("{pg_port}:5432");
369    arg_list.extend(["-e", &pg_password_env, "-e", &pg_user_env]);
370    arg_list.extend(["-p", &pg_port_forward]);
371
372    arg_list.extend(["--name", "greptimedb_pg", &pg_image]);
373
374    let mut cmd = std::process::Command::new("docker");
375
376    cmd.args(arg_list);
377
378    println!("Starting PostgreSQL with command: {:?}", cmd);
379
380    let status = cmd.status();
381    if status.is_err() {
382        panic!("Failed to start PostgreSQL: {:?}", status);
383    } else if let Ok(status) = status {
384        if status.success() {
385            println!("Started PostgreSQL with port {}", pg_port);
386        } else {
387            panic!("Failed to start PostgreSQL: {:?}", status);
388        }
389    }
390}
391
392/// Set up a MySql server in docker.
393pub fn setup_mysql(mysql_port: u16, mysql_version: Option<&str>) {
394    if std::process::Command::new("docker")
395        .args(["-v"])
396        .status()
397        .is_err()
398    {
399        panic!("Docker is not installed");
400    }
401
402    let mysql_image = if let Some(mysql_version) = mysql_version {
403        format!("greptime/mysql:{mysql_version}")
404    } else {
405        "greptime/mysql:5.7".to_string()
406    };
407    let mysql_password = "admin";
408    let mysql_user = "greptimedb";
409
410    let mut arg_list = vec![];
411    arg_list.extend(["run", "-d"]);
412
413    let mysql_password_env = format!("MYSQL_PASSWORD={mysql_password}");
414    let mysql_user_env = format!("MYSQL_USER={mysql_user}");
415    let mysql_root_password_env = format!("MYSQL_ROOT_PASSWORD={mysql_password}");
416    let mysql_port_forward = format!("{mysql_port}:3306");
417    arg_list.extend([
418        "-e",
419        &mysql_password_env,
420        "-e",
421        &mysql_user_env,
422        "-e",
423        &mysql_root_password_env,
424        "-e",
425        "MYSQL_DATABASE=mysql",
426    ]);
427    arg_list.extend(["-p", &mysql_port_forward]);
428
429    arg_list.extend(["--name", "greptimedb_mysql", &mysql_image]);
430
431    let mut cmd = std::process::Command::new("docker");
432
433    cmd.args(arg_list);
434
435    println!("Starting MySQL with command: {:?}", cmd);
436
437    let status = cmd.status();
438    if status.is_err() {
439        panic!("Failed to start MySQL: {:?}", status);
440    } else if let Ok(status) = status {
441        if status.success() {
442            println!("Started MySQL with port {}", mysql_port);
443        } else {
444            panic!("Failed to start MySQL: {:?}", status);
445        }
446    }
447}
448
449/// Get the dir of test cases. This function only works when the runner is run
450/// under the project's dir because it depends on some envs set by cargo.
451pub fn get_case_dir(case_dir: Option<PathBuf>) -> String {
452    let runner_path = match case_dir {
453        Some(path) => path,
454        None => {
455            // retrieve the manifest runner (./tests/runner)
456            let mut runner_crate_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
457            // change directory to cases' dir from runner's (should be runner/../cases)
458            let _ = runner_crate_path.pop();
459            runner_crate_path.push("cases");
460            runner_crate_path
461        }
462    };
463
464    runner_path.into_os_string().into_string().unwrap()
465}
466
467/// Get the dir that contains workspace manifest (the top-level Cargo.toml).
468pub fn get_workspace_root() -> String {
469    // retrieve the manifest runner (./tests/runner)
470    let mut runner_crate_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
471
472    // change directory to workspace's root (runner/../..)
473    let _ = runner_crate_path.pop();
474    let _ = runner_crate_path.pop();
475
476    runner_crate_path.into_os_string().into_string().unwrap()
477}
478
479pub fn get_binary_dir(mode: &str) -> PathBuf {
480    // first go to the workspace root.
481    let mut workspace_root = PathBuf::from(get_workspace_root());
482
483    // change directory to target dir (workspace/target/<build mode>/)
484    workspace_root.push("target");
485    workspace_root.push(mode);
486
487    workspace_root
488}
489
490/// Spin-waiting a socket address is available, or timeout.
491/// Returns whether the addr is up.
492pub async fn check_port(ip_addr: SocketAddr, timeout: Duration) -> bool {
493    let check_task = async {
494        loop {
495            let socket = TcpSocket::new_v4().expect("Cannot create v4 socket");
496            match socket.connect(ip_addr).await {
497                Ok(mut stream) => {
498                    let _ = stream.shutdown().await;
499                    break;
500                }
501                Err(_) => time::sleep(PORT_CHECK_INTERVAL).await,
502            }
503        }
504    };
505
506    tokio::time::timeout(timeout, check_task).await.is_ok()
507}
508
509/// Get the path of sqlness config dir `tests/conf`.
510pub fn sqlness_conf_path() -> PathBuf {
511    let mut sqlness_root_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
512    sqlness_root_path.pop();
513    sqlness_root_path.push("conf");
514    sqlness_root_path
515}
516
517/// Start kafka cluster if needed. Config file is `conf/kafka-cluster.yml`.
518///
519/// ```shell
520/// docker compose -f kafka-cluster.yml up kafka -d --wait
521/// ```
522pub fn setup_wal() {
523    let mut sqlness_root_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
524    sqlness_root_path.pop();
525    sqlness_root_path.push("conf");
526
527    Command::new("docker")
528        .current_dir(sqlness_conf_path())
529        .args([
530            "compose",
531            "-f",
532            "kafka-cluster.yml",
533            "up",
534            "kafka",
535            "-d",
536            "--wait",
537        ])
538        .output()
539        .expect("Failed to start kafka cluster");
540
541    println!("kafka cluster is up");
542}
543
544/// Stop kafka cluster if needed. Config file is `conf/kafka-cluster.yml`.
545///
546/// ```shell
547/// docker compose -f docker-compose-standalone.yml down kafka
548/// ```
549pub fn teardown_wal() {
550    let mut sqlness_root_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
551    sqlness_root_path.pop();
552    sqlness_root_path.push("conf");
553
554    Command::new("docker")
555        .current_dir(sqlness_conf_path())
556        .args(["compose", "-f", "kafka-cluster.yml", "down", "kafka"])
557        .output()
558        .expect("Failed to stop kafka cluster");
559
560    println!("kafka cluster is down");
561}
562
563/// Get a random available port by binding to port 0
564pub fn get_random_port() -> u16 {
565    use std::net::TcpListener;
566    let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
567    listener
568        .local_addr()
569        .expect("Failed to get local address")
570        .port()
571}
572
573/// Retry to execute the function until success or the maximum number of retries is reached.
574pub async fn retry_with_backoff<T, E, Fut, F>(
575    mut fut: F,
576    max_retry: usize,
577    init_backoff: Duration,
578) -> Result<T, E>
579where
580    F: FnMut() -> Fut,
581    Fut: Future<Output = Result<T, E>>,
582{
583    let mut backoff = init_backoff;
584    for attempt in 0..max_retry {
585        match fut().await {
586            Ok(res) => return Ok(res),
587            Err(err) if attempt + 1 == max_retry => return Err(err),
588            Err(_) => {
589                tokio::time::sleep(backoff).await;
590                backoff *= 2;
591            }
592        }
593    }
594
595    unreachable!("loop should have returned before here")
596}