use crate::error::{Error, Result};
use crate::paths;
use flate2::read::GzDecoder;
use serde::Deserialize;
use std::fs;
use std::io::{self, Cursor};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tar::Archive;
const GITHUB_REPO: &str = "ClickHouse/clickhousectl";
const BUILDS_BASE_URL: &str = "https://builds.clickhouse.com/clickhousectl";
const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
}
fn target_triple() -> Result<&'static str> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("macos", "x86_64") => Ok("x86_64-apple-darwin"),
("macos", "aarch64") => Ok("aarch64-apple-darwin"),
("linux", "x86_64") => Ok("x86_64-unknown-linux-musl"),
("linux", "aarch64") => Ok("aarch64-unknown-linux-musl"),
_ => Err(Error::UnsupportedPlatform {
os: os.to_string(),
arch: arch.to_string(),
}),
}
}
fn parse_version(tag: &str) -> Option<(u32, u32, u32)> {
let v = tag.strip_prefix('v').unwrap_or(tag);
let parts: Vec<&str> = v.split('.').collect();
if parts.len() == 3 {
Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
))
} else {
None
}
}
fn is_newer(current: &str, latest: &str) -> bool {
match (parse_version(current), parse_version(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}
async fn fetch_latest_release(timeout: std::time::Duration) -> Result<GitHubRelease> {
let url = format!(
"https://api.github.com/repos/{}/releases/latest",
GITHUB_REPO
);
let client = crate::http::client_builder().timeout(timeout).build()?;
let response = client
.get(&url)
.send()
.await?
.error_for_status()
.map_err(|e| Error::Download(format!("GitHub API request failed: {}", e)))?;
let release: GitHubRelease = response.json().await?;
Ok(release)
}
fn extract_binary_from_archive(archive_bytes: &[u8]) -> Result<Vec<u8>> {
let decoder = GzDecoder::new(Cursor::new(archive_bytes));
let mut archive = Archive::new(decoder);
for entry in archive
.entries()
.map_err(|e| Error::Extract(format!("Failed to read release archive: {}", e)))?
{
let mut entry =
entry.map_err(|e| Error::Extract(format!("Failed to read archive entry: {}", e)))?;
if !entry.header().entry_type().is_file() {
continue;
}
let path = entry
.path()
.map_err(|e| Error::Extract(format!("Failed to read archive entry path: {}", e)))?;
if path.file_name().and_then(|n| n.to_str()) == Some("clickhousectl") {
let mut buf = Vec::new();
io::copy(&mut entry, &mut buf).map_err(|e| {
Error::Extract(format!("Failed to extract binary from archive: {}", e))
})?;
return Ok(buf);
}
}
Err(Error::Extract(
"Release archive did not contain a clickhousectl binary".into(),
))
}
const EXPLICIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const BACKGROUND_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400);
pub async fn check_for_update() -> Result<Option<(String, String)>> {
let current = env!("CARGO_PKG_VERSION");
let release = fetch_latest_release(EXPLICIT_TIMEOUT).await?;
let latest = &release.tag_name;
let display = latest.strip_prefix('v').unwrap_or(latest);
let _ = save_update_check(display);
if is_newer(current, latest) {
Ok(Some((current.to_string(), display.to_string())))
} else {
Ok(None)
}
}
pub async fn perform_update() -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
let release = fetch_latest_release(EXPLICIT_TIMEOUT).await?;
let latest = &release.tag_name;
if !is_newer(current, latest) {
let display = latest.strip_prefix('v').unwrap_or(latest);
println!("Already up to date (v{}).", display);
let _ = save_update_check(display);
return Ok(());
}
let target = target_triple()?;
let archive_name = format!("clickhousectl-{}-{}.tar.gz", target, latest);
let download_url = format!("{}/{}", BUILDS_BASE_URL, archive_name);
let display = latest.strip_prefix('v').unwrap_or(latest);
println!("Downloading clickhousectl v{}...", display);
let client = crate::http::client_builder()
.timeout(std::time::Duration::from_secs(300))
.build()?;
let response = client
.get(&download_url)
.send()
.await?
.error_for_status()
.map_err(|e| Error::Download(format!("Download failed: {}", e)))?;
let archive_bytes = response.bytes().await?;
let binary_bytes = extract_binary_from_archive(&archive_bytes)?;
let current_exe = std::env::current_exe().map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Could not determine current executable path: {}", e),
))
})?;
let actual_path = fs::canonicalize(¤t_exe).unwrap_or(current_exe);
let tmp_path = actual_path.with_extension("tmp-update");
fs::write(&tmp_path, &binary_bytes).map_err(|e| {
Error::Download(format!(
"Failed to write update to {}: {}. Check file permissions.",
tmp_path.display(),
e
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
Error::Download(format!("Failed to set executable permissions: {}", e))
})?;
}
fs::rename(&tmp_path, &actual_path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
Error::Download(format!(
"Failed to replace binary at {}: {}. Check file permissions.",
actual_path.display(),
e
))
})?;
println!("Updated clickhousectl: v{} → v{}", current, display);
let _ = clear_update_check();
Ok(())
}
fn update_check_path() -> Result<PathBuf> {
Ok(paths::base_dir()?.join("last_update_check"))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn save_update_check(latest_version: &str) -> Result<()> {
let path = update_check_path()?;
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let content = format!("{}\n{}", now_secs(), latest_version);
fs::write(&path, content)?;
Ok(())
}
fn read_update_check() -> Option<(u64, String)> {
let path = update_check_path().ok()?;
let content = fs::read_to_string(path).ok()?;
let mut lines = content.lines();
let ts: u64 = lines.next()?.parse().ok()?;
let version = lines.next()?.to_string();
Some((ts, version))
}
fn clear_update_check() -> Result<()> {
let path = update_check_path()?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::Io(e)),
}
}
fn cache_is_stale(cache: Option<(u64, String)>, now: u64) -> bool {
match cache {
Some((ts, _)) => now.saturating_sub(ts) >= CHECK_INTERVAL_SECS,
None => true,
}
}
pub fn print_cached_update_notice() {
if let Some((_, cached_version)) = read_update_check() {
let current = env!("CARGO_PKG_VERSION");
if is_newer(current, &cached_version) {
use std::io::Write;
let _ = writeln!(
std::io::stderr(),
"\nThere is a new version of clickhousectl. Update with `clickhousectl update`."
);
}
}
}
async fn do_refresh_update_cache(timeout: std::time::Duration) {
let current = env!("CARGO_PKG_VERSION");
match fetch_latest_release(timeout).await {
Ok(r) => {
let latest = r.tag_name;
let display = latest.strip_prefix('v').unwrap_or(&latest);
let _ = save_update_check(display);
}
Err(_) => {
let version = read_update_check()
.map(|(_, v)| v)
.unwrap_or_else(|| current.to_string());
let _ = save_update_check(&version);
}
}
}
pub async fn refresh_update_cache() {
if !cache_is_stale(read_update_check(), now_secs()) {
return;
}
do_refresh_update_cache(BACKGROUND_TIMEOUT).await;
}
pub async fn force_refresh_update_cache() {
do_refresh_update_cache(EXPLICIT_TIMEOUT).await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_version() {
assert_eq!(parse_version("v0.1.17"), Some((0, 1, 17)));
assert_eq!(parse_version("0.1.17"), Some((0, 1, 17)));
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("v1.2"), None);
assert_eq!(parse_version("garbage"), None);
}
#[test]
fn test_is_newer() {
assert!(is_newer("0.1.17", "v0.2.0"));
assert!(is_newer("0.1.17", "0.1.18"));
assert!(is_newer("0.1.17", "1.0.0"));
assert!(!is_newer("0.1.17", "0.1.17"));
assert!(!is_newer("0.1.17", "0.1.16"));
assert!(!is_newer("0.2.0", "0.1.99"));
}
#[test]
fn test_cache_is_stale() {
let now = 1_000_000;
assert!(cache_is_stale(None, now));
assert!(!cache_is_stale(Some((now, "0.2.0".into())), now));
assert!(!cache_is_stale(
Some((now - (CHECK_INTERVAL_SECS - 1), "0.2.0".into())),
now
));
assert!(cache_is_stale(
Some((now - CHECK_INTERVAL_SECS, "0.2.0".into())),
now
));
assert!(cache_is_stale(
Some((now - 2 * CHECK_INTERVAL_SECS, "0.2.0".into())),
now
));
}
#[test]
fn test_target_triple() {
let target = target_triple().unwrap();
assert!(target.contains('-'));
}
fn build_release_archive(inner_dir: &str, binary_bytes: &[u8]) -> Vec<u8> {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut builder = Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header
.set_path(format!("{}/clickhousectl", inner_dir))
.unwrap();
header.set_size(binary_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append(&header, binary_bytes).unwrap();
builder.into_inner().unwrap().finish().unwrap()
}
#[test]
fn extracts_clickhousectl_binary_from_release_archive() {
let payload = b"\x7fELF fake binary contents".as_slice();
let archive = build_release_archive("clickhousectl-aarch64-apple-darwin-v0.0.1", payload);
let extracted = extract_binary_from_archive(&archive).unwrap();
assert_eq!(extracted, payload);
}
#[test]
fn extract_fails_when_archive_has_no_clickhousectl_entry() {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let builder = Builder::new(encoder);
let empty = builder.into_inner().unwrap().finish().unwrap();
let err = extract_binary_from_archive(&empty).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("did not contain"), "got: {}", msg);
}
}