use std::path::{Path, PathBuf};
use crate::CortexClient;
pub const SIDECAR_VERSION: &str = "2.21.0";
const RELEASE_BASE: &str = "https://github.com/liliang-cn/cortexdb/releases/download";
#[derive(Debug)]
pub enum SidecarError {
Io(std::io::Error),
Download(String),
Checksum(String),
UnsupportedPlatform(String),
NotHealthy,
Other(Box<dyn std::error::Error + Send + Sync>),
}
impl std::fmt::Display for SidecarError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "io: {e}"),
Self::Download(msg) => write!(f, "download failed: {msg}"),
Self::Checksum(asset) => write!(f, "checksum mismatch for {asset}"),
Self::UnsupportedPlatform(p) => write!(f, "unsupported platform: {p}"),
Self::NotHealthy => write!(f, "sidecar did not become healthy"),
Self::Other(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for SidecarError {}
impl From<std::io::Error> for SidecarError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
pub struct Sidecar {
binary: PathBuf,
}
pub struct RunningSidecar {
child: tokio::process::Child,
endpoint: String,
token: String,
}
fn platform_asset() -> Result<String, SidecarError> {
let os = match std::env::consts::OS {
"macos" => "darwin",
"linux" => "linux",
"windows" => "windows",
other => return Err(SidecarError::UnsupportedPlatform(other.into())),
};
let arch = match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => return Err(SidecarError::UnsupportedPlatform(other.into())),
};
Ok(format!("cortexdb-grpc_{os}_{arch}.tar.gz"))
}
impl Sidecar {
pub async fn ensure() -> Result<Self, SidecarError> {
Self::ensure_with_base_url(RELEASE_BASE).await
}
pub async fn ensure_with_base_url(base: &str) -> Result<Self, SidecarError> {
if let Ok(p) = std::env::var("CORTEXDB_GRPC_BIN") {
return Ok(Self { binary: p.into() });
}
if let Ok(p) = which("cortexdb-grpc") {
return Ok(Self { binary: p });
}
let cache = dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("cortexdb")
.join("bin")
.join(format!("v{SIDECAR_VERSION}"));
let exe = cache.join(if cfg!(windows) {
"cortexdb-grpc.exe"
} else {
"cortexdb-grpc"
});
if exe.exists() {
return Ok(Self { binary: exe });
}
download_release(base, &cache, &exe).await?;
Ok(Self { binary: exe })
}
pub fn from_binary(path: impl Into<PathBuf>) -> Self {
Self {
binary: path.into(),
}
}
pub fn binary(&self) -> &Path {
&self.binary
}
pub async fn spawn(&self, db_path: impl AsRef<Path>) -> Result<RunningSidecar, SidecarError> {
let port = pick_free_port()?;
let addr = format!("127.0.0.1:{port}");
let token: String = {
use rand::Rng;
let mut rng = rand::rng();
(0..32)
.map(|_| rng.sample(rand::distr::Alphanumeric) as char)
.collect()
};
let child = tokio::process::Command::new(&self.binary)
.arg("-db")
.arg(db_path.as_ref())
.arg("-addr")
.arg(&addr)
.arg("-token")
.arg(&token)
.kill_on_drop(true)
.spawn()?;
let endpoint = format!("http://{addr}");
let running = RunningSidecar {
child,
endpoint,
token,
};
running.wait_healthy().await?;
Ok(running)
}
}
impl RunningSidecar {
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn token(&self) -> &str {
&self.token
}
pub async fn client(&self) -> Result<CortexClient, SidecarError> {
CortexClient::builder(self.endpoint.clone())
.token(self.token.clone())
.connect()
.await
.map_err(SidecarError::Other)
}
pub async fn shutdown(mut self) -> Result<(), SidecarError> {
self.child.kill().await?;
Ok(())
}
async fn wait_healthy(&self) -> Result<(), SidecarError> {
for _ in 0..50 {
if let Ok(c) = self.client().await {
if c.admin()
.health(crate::proto::HealthRequest {})
.await
.is_ok()
{
return Ok(());
}
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Err(SidecarError::NotHealthy)
}
}
fn pick_free_port() -> std::io::Result<u16> {
let l = std::net::TcpListener::bind("127.0.0.1:0")?;
Ok(l.local_addr()?.port())
}
fn which(name: &str) -> Result<PathBuf, std::io::Error> {
let paths = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&paths) {
let candidate = dir.join(name);
if candidate.is_file() {
return Ok(candidate);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
name.to_string(),
))
}
async fn download_release(base: &str, cache: &Path, exe: &Path) -> Result<(), SidecarError> {
let asset = platform_asset()?;
let url = format!("{base}/v{SIDECAR_VERSION}/{asset}");
let sum_url = format!("{url}.sha256");
let bytes = fetch(&url).await?;
let expected = String::from_utf8_lossy(&fetch(&sum_url).await?)
.split_whitespace()
.next()
.unwrap_or_default()
.to_string();
let actual = {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(&bytes))
};
if actual != expected {
return Err(SidecarError::Checksum(asset));
}
std::fs::create_dir_all(cache)?;
let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
tar::Archive::new(gz).unpack(cache)?;
if !exe.exists() {
return Err(SidecarError::Download(format!(
"archive did not contain {}",
exe.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(exe, std::fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
async fn fetch(url: &str) -> Result<Vec<u8>, SidecarError> {
let resp = reqwest::get(url)
.await
.map_err(|e| SidecarError::Download(e.to_string()))?;
if !resp.status().is_success() {
return Err(SidecarError::Download(format!(
"{url}: HTTP {}",
resp.status()
)));
}
Ok(resp
.bytes()
.await
.map_err(|e| SidecarError::Download(e.to_string()))?
.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn platform_asset_is_known() {
let asset = platform_asset().expect("supported platform");
assert!(asset.starts_with("cortexdb-grpc_"));
assert!(asset.ends_with(".tar.gz"));
}
#[test]
fn pick_free_port_works() {
let port = pick_free_port().expect("free port");
assert!(port > 0);
}
#[test]
fn which_finds_cargo() {
assert!(which("cargo").is_ok());
}
}