Add macOS ARM64 support and enhance CLI authentication and version management

This commit is contained in:
dal 2025-02-12 13:00:29 -07:00
parent c6d70b62dc
commit caec707450
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
10 changed files with 631 additions and 62 deletions

View File

@ -27,6 +27,10 @@ jobs:
target: x86_64-apple-darwin
artifact_name: buster-cli-darwin-x86_64.tar.gz
use_tar: true
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: buster-cli-darwin-arm64.tar.gz
use_tar: true
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: buster-cli-windows-x86_64.zip
@ -116,6 +120,8 @@ jobs:
**/buster-cli-linux-x86_64.tar.gz.sha256
**/buster-cli-darwin-x86_64.tar.gz
**/buster-cli-darwin-x86_64.tar.gz.sha256
**/buster-cli-darwin-arm64.tar.gz
**/buster-cli-darwin-arm64.tar.gz.sha256
**/buster-cli-windows-x86_64.zip
**/buster-cli-windows-x86_64.zip.sha256
draft: false

View File

@ -1,7 +1,8 @@
[package]
name = "buster-cli"
version = "0.1.0"
version = "0.0.1"
edition = "2021"
build = "build.rs"
[lib]
name = "buster_cli"
@ -11,24 +12,33 @@ path = "src/lib.rs"
[dependencies]
anyhow = "1.0.79"
clap = { version = "4.4.18", features = ["derive"] }
clap = { version = "4.4.18", features = ["derive", "env"] }
confy = "0.6.0"
dirs = "5.0.1"
futures = "0.3.29"
indicatif = "0.17.8"
inquire = "0.7.5"
lazy_static = "1.4.0"
ratatui = "0.29.0"
regex = "1.10.3"
reqwest = { version = "0.12.9", features = ["json", "rustls-tls"] }
reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "stream"] }
rpassword = "7.3.1"
serde = { version = "1.0.196", features = ["derive"] }
serde_json = "1.0.113"
serde_yaml = "0.9.34"
tempfile = "3.10.0"
thiserror = "2.0.3"
tokio = { version = "1.36.0", features = ["full"] }
tokio-util = { version = "0.7.10", features = ["io"] }
futures-util = "0.3.30"
uuid = { version = "1.7.0", features = ["v4", "serde"] }
colored = "3.0"
rustls = { version = "0.22", features = ["tls12"] }
rustls = { version = "0.23", features = ["tls12"] }
url = "2.5.0"
zip = "2.2.2"
[dev-dependencies]
tempfile = "3.16.0"
[build-dependencies]
chrono = "0.4"

21
cli/build.rs Normal file
View File

@ -0,0 +1,21 @@
use chrono::Utc;
use std::process::Command;
fn main() {
// Set the build date
let build_date = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
// Get the git hash if possible
let git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=GIT_HASH={}", git_hash.trim());
// Tell cargo to rerun this if any git changes occur
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs/heads/main");
}

View File

@ -1,60 +1,132 @@
use anyhow::Result;
use anyhow::{Context, Result};
use clap::Parser;
use inquire::{Password, Text};
use std::env;
use thiserror::Error;
use crate::utils::{
buster_credentials::{get_buster_credentials, set_buster_credentials, BusterCredentials},
BusterClient,
};
pub async fn auth() -> Result<()> {
// Get the API key from the credentials file.
// If the file doesn't exist, set it to the default.
let mut buster_creds = match get_buster_credentials().await {
Ok(buster_creds) => buster_creds,
Err(_) => match set_buster_credentials(BusterCredentials::default()).await {
Ok(_) => get_buster_credentials().await?,
Err(e) => anyhow::bail!("Failed to set credentials: {}", e),
},
};
const DEFAULT_HOST: &str = "https://api2.buster.so";
let url_input = Text::new("Enter the URL of your Buster API")
.with_default(&buster_creds.url)
.prompt()?;
#[derive(Error, Debug)]
pub enum AuthError {
#[error("URL is required")]
MissingUrl,
#[error("API key is required")]
MissingApiKey,
#[error("Invalid API key")]
InvalidApiKey,
#[error("Failed to validate credentials: {0}")]
ValidationError(String),
#[error("Failed to save credentials: {0}")]
StorageError(String),
}
// If no URL at all, error
if url_input.is_empty() {
anyhow::bail!("URL is required");
#[derive(Parser, Debug)]
#[command(about = "Authenticate with Buster API")]
pub struct AuthArgs {
/// The Buster API host URL
#[arg(long, env = "BUSTER_HOST")]
pub host: Option<String>,
/// Your Buster API key
#[arg(long, env = "BUSTER_API_KEY")]
pub api_key: Option<String>,
/// Don't save credentials to disk
#[arg(long)]
pub no_save: bool,
}
async fn validate_credentials(url: &str, api_key: &str) -> Result<(), AuthError> {
let buster_client = BusterClient::new(url.to_string(), api_key.to_string())
.map_err(|e| AuthError::ValidationError(e.to_string()))?;
if !buster_client.validate_api_key().await
.map_err(|e| AuthError::ValidationError(e.to_string()))? {
return Err(AuthError::InvalidApiKey);
}
Ok(())
}
pub async fn auth() -> Result<()> {
let args = AuthArgs::parse();
auth_with_args(args).await
}
pub async fn auth_with_args(args: AuthArgs) -> Result<()> {
// Get existing credentials or create default
let mut buster_creds = match get_buster_credentials().await {
Ok(creds) => creds,
Err(_) => BusterCredentials {
url: DEFAULT_HOST.to_string(),
api_key: String::new(),
},
};
// Apply host from args or use default
if let Some(host) = args.host {
buster_creds.url = host;
}
// Apply API key from args or environment
if let Some(api_key) = args.api_key {
buster_creds.api_key = api_key;
}
// Interactive mode for missing values
if buster_creds.url.is_empty() {
let url_input = Text::new("Enter the URL of your Buster API")
.with_default(DEFAULT_HOST)
.with_help_message("Press Enter to use the default URL")
.prompt()
.context("Failed to get URL input")?;
if url_input.is_empty() {
buster_creds.url = DEFAULT_HOST.to_string();
} else {
buster_creds.url = url_input;
}
}
if buster_creds.api_key.is_empty() {
let obfuscated_api_key = if buster_creds.api_key.is_empty() {
String::from("None")
} else {
format!("{}****", &buster_creds.api_key[..4])
};
let api_key_input = Password::new(&format!("Enter your API key [{obfuscated_api_key}]:"))
.without_confirmation()
.with_help_message("Your API key can be found in your Buster dashboard")
.prompt()
.context("Failed to get API key input")?;
if api_key_input.is_empty() && buster_creds.api_key.is_empty() {
return Err(AuthError::MissingApiKey.into());
} else if !api_key_input.is_empty() {
buster_creds.api_key = api_key_input;
}
}
// Validate credentials
validate_credentials(&buster_creds.url, &buster_creds.api_key).await?;
// Save credentials unless --no-save is specified
if !args.no_save {
set_buster_credentials(buster_creds.clone()).await
.context("Failed to save credentials")?;
println!("Credentials saved successfully!");
}
println!("Authentication successful!");
if args.no_save {
println!("Note: Credentials were not saved due to --no-save flag");
}
buster_creds.url = url_input;
// Obfuscate the API key for display
let obfuscated_api_key = if buster_creds.api_key.is_empty() {
String::from("None")
} else {
format!("{}****", &buster_creds.api_key[..4])
};
let api_key_input = Password::new(&format!("Enter your API key [{obfuscated_api_key}]:"))
.without_confirmation()
.prompt()?;
if !api_key_input.is_empty() {
buster_creds.api_key = api_key_input;
} else if buster_creds.api_key.is_empty() {
anyhow::bail!("API key is required");
}
// Validate the API key.
let buster_client = BusterClient::new(buster_creds.url.clone(), buster_creds.api_key.clone())?;
if !buster_client.validate_api_key().await? {
anyhow::bail!("Invalid API key");
}
// Save the credentials.
set_buster_credentials(buster_creds).await?;
println!("Authentication successful!");
Ok(())
}

View File

@ -1,13 +1,16 @@
mod auth;
pub mod auth;
mod deploy;
mod deploy_v2;
mod generate;
mod import;
mod init;
pub mod version;
pub mod update;
pub use auth::auth;
pub use auth::{auth, auth_with_args, AuthArgs};
pub use deploy::deploy;
pub use deploy_v2::deploy_v2;
pub use generate::GenerateCommand;
pub use import::import;
pub use init::init;
pub use update::UpdateCommand;

263
cli/src/commands/update.rs Normal file
View File

@ -0,0 +1,263 @@
use anyhow::{Result, Context};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::fs;
use std::env;
use reqwest::Client;
use indicatif::{ProgressBar, ProgressStyle};
use colored::*;
use futures_util::StreamExt;
use inquire::Confirm;
use std::io::Write;
use zip::ZipArchive;
const GITHUB_RELEASES_URL: &str = "https://github.com/buster-so/buster/releases/download";
pub struct UpdateCommand {
check_only: bool,
force: bool,
no_prompt: bool,
}
impl UpdateCommand {
pub fn new(check_only: bool, force: bool, no_prompt: bool) -> Self {
Self {
check_only,
force,
no_prompt,
}
}
pub async fn execute(&self) -> Result<()> {
// Check current version and latest version
let current_version = env!("CARGO_PKG_VERSION");
let latest_version = super::version::check_latest_version()
.await?
.context("Failed to get latest version")?;
println!("Current version: {}", current_version);
println!("Latest version: {}", latest_version);
let update_available = super::version::is_update_available(current_version, &latest_version);
if !update_available && !self.force {
println!("\n{}", "You are using the latest version".green());
return Ok(());
}
if self.check_only {
if update_available {
println!("\n{}", "Update available!".yellow().bold());
println!("Run {} to update", "buster update".cyan());
}
return Ok(());
}
if !self.no_prompt {
let confirm = Confirm::new("Do you want to update to the latest version?")
.with_default(true)
.with_help_message("This will replace your current binary with the latest version")
.prompt();
match confirm {
Ok(true) => (),
Ok(false) => {
println!("Update cancelled");
return Ok(());
}
Err(_) => {
return Err(anyhow::anyhow!("Update cancelled due to input error"));
}
}
}
self.perform_update(&latest_version).await
}
async fn perform_update(&self, version: &str) -> Result<()> {
let package_name = self.get_package_name()?;
let download_url = format!("{}/{}/{}", GITHUB_RELEASES_URL, version, package_name);
let checksum_url = format!("{}.sha256", download_url);
if env::var("BUSTER_DEBUG").is_ok() {
println!("Debug: Download URL: {}", download_url);
println!("Debug: Checksum URL: {}", checksum_url);
}
println!("\nDownloading update...");
// Create temporary directory
let temp_dir = tempfile::Builder::new()
.prefix("buster-update-")
.tempdir()?;
// Download package
let package_path = temp_dir.path().join(&package_name);
match self.download_file(&download_url, &package_path).await {
Ok(_) => println!("✓ Package downloaded successfully"),
Err(e) => return Err(anyhow::anyhow!("Failed to download package: {}\nURL: {}", e, download_url)),
}
// Download and verify checksum
let checksum_path = temp_dir.path().join(format!("{}.sha256", package_name));
match self.download_file(&checksum_url, &checksum_path).await {
Ok(_) => println!("✓ Checksum file downloaded"),
Err(e) => {
if env::var("BUSTER_DEV").is_ok() {
println!("Skipping checksum verification in development mode");
} else {
return Err(anyhow::anyhow!("Failed to download checksum: {}\nURL: {}", e, checksum_url));
}
}
}
// Get current binary path
let current_binary = env::current_exe()?;
let backup_path = current_binary.with_extension("bak");
// Create backup
println!("Creating backup...");
fs::copy(&current_binary, &backup_path)?;
// Extract and replace binary
println!("Installing update...");
self.replace_binary(&package_path, &current_binary)?;
// Verify new binary
if self.verify_new_binary(&current_binary)? {
fs::remove_file(backup_path)?;
println!("\n{}", "✓ Successfully updated!".green().bold());
println!("Version: {}", version);
} else {
println!("\n{}", "✗ Update verification failed, rolling back...".red());
fs::copy(&backup_path, &current_binary)?;
return Err(anyhow::anyhow!("Failed to verify new binary"));
}
Ok(())
}
fn get_package_name(&self) -> Result<String> {
let os = env::consts::OS;
let arch = env::consts::ARCH;
let package_name = match (os, arch) {
("linux", "x86_64") => "buster-cli-linux-x86_64.tar.gz",
("macos", "x86_64") => "buster-cli-darwin-x86_64.tar.gz",
("macos", "aarch64") => "buster-cli-darwin-arm64.tar.gz",
("windows", "x86_64") => "buster-cli-windows-x86_64.zip",
_ => return Err(anyhow::anyhow!("Unsupported platform: {}-{}", os, arch)),
};
Ok(package_name.to_string())
}
async fn download_file(&self, url: &str, path: &Path) -> Result<()> {
let client = Client::new();
let response = client
.get(url)
.header("User-Agent", "buster-cli")
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to download file: HTTP {}\nURL: {}",
response.status(),
url
));
}
let total_size = response.content_length().unwrap_or(0);
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
let mut file = fs::File::create(path)?;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
pb.inc(chunk.len() as u64);
file.write_all(&chunk)?;
}
pb.finish_and_clear();
Ok(())
}
fn verify_checksum(&self, package_path: &Path, checksum_path: &Path) -> Result<()> {
// For local development, skip checksum verification if env var is set
if env::var("BUSTER_DEV").is_ok() {
println!("Skipping checksum verification in development mode");
return Ok(());
}
let expected_checksum = fs::read_to_string(checksum_path)?;
let expected_checksum = expected_checksum.split_whitespace().next()
.context("Invalid checksum file format")?;
let output = Command::new("shasum")
.arg("-a")
.arg("256")
.arg(package_path)
.output()?;
let actual_checksum = String::from_utf8(output.stdout)?;
let actual_checksum = actual_checksum.split_whitespace().next()
.context("Failed to compute checksum")?;
if actual_checksum != expected_checksum {
return Err(anyhow::anyhow!(
"Checksum verification failed\nExpected: {}\nGot: {}",
expected_checksum,
actual_checksum
));
}
Ok(())
}
fn replace_binary(&self, package_path: &Path, target_path: &Path) -> Result<()> {
// Extract the package based on its extension
if package_path.extension().and_then(|s| s.to_str()) == Some("zip") {
// Handle ZIP files (Windows)
let file = fs::File::open(package_path)?;
let mut archive = ZipArchive::new(file)?;
// Extract the binary
let mut binary = archive.by_name("buster-cli.exe")?;
let mut temp_path = package_path.parent().unwrap().join("buster-cli.exe");
let mut outfile = fs::File::create(&temp_path)?;
std::io::copy(&mut binary, &mut outfile)?;
// Move to target location
fs::rename(temp_path, target_path)?;
} else {
// Handle tar.gz files (Unix)
Command::new("tar")
.arg("xzf")
.arg(package_path)
.current_dir(package_path.parent().unwrap())
.output()?;
// Move the extracted binary to the target location
let extracted_binary = package_path.parent().unwrap().join("buster-cli");
fs::rename(extracted_binary, target_path)?;
}
Ok(())
}
fn verify_new_binary(&self, binary_path: &Path) -> Result<bool> {
let output = Command::new(binary_path)
.arg("version")
.output()?;
Ok(output.status.success())
}
}

119
cli/src/commands/version.rs Normal file
View File

@ -0,0 +1,119 @@
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime};
use std::fs;
use std::path::PathBuf;
use dirs::cache_dir;
const GITHUB_API_URL: &str = "https://api.github.com/repos/buster-so/buster/releases/latest";
const CACHE_TTL: Duration = Duration::from_secs(3600); // 1 hour
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
body: Option<String>,
}
#[derive(Deserialize, Serialize)]
struct VersionCache {
version: String,
timestamp: u64,
}
pub async fn check_latest_version() -> Result<Option<String>> {
if let Some(cached_version) = get_cached_version()? {
return Ok(Some(cached_version));
}
let client = Client::new();
let response = client
.get(GITHUB_API_URL)
.header("User-Agent", "buster-cli")
.send()
.await?;
let release: GitHubRelease = response.json().await?;
cache_version(&release.tag_name)?;
Ok(Some(release.tag_name))
}
fn get_cache_path() -> Option<PathBuf> {
let mut cache_path = cache_dir()?;
cache_path.push("buster");
cache_path.push("version_check.json");
Some(cache_path)
}
fn get_cached_version() -> Result<Option<String>> {
let cache_path = match get_cache_path() {
Some(path) => path,
None => return Ok(None),
};
if !cache_path.exists() {
return Ok(None);
}
let cache_content = fs::read_to_string(cache_path)?;
let cached: VersionCache = serde_json::from_str(&cache_content)?;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_secs();
if now - cached.timestamp > CACHE_TTL.as_secs() {
return Ok(None);
}
Ok(Some(cached.version))
}
fn cache_version(version: &str) -> Result<()> {
let cache_path = match get_cache_path() {
Some(path) => path,
None => return Ok(()),
};
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
let cache = VersionCache {
version: version.to_string(),
timestamp: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_secs(),
};
let cache_content = serde_json::to_string(&cache)?;
fs::write(cache_path, cache_content)?;
Ok(())
}
pub fn is_update_available(current: &str, latest: &str) -> bool {
// Strip 'v' prefix if present
let current = current.trim_start_matches('v');
let latest = latest.trim_start_matches('v');
// Split into version components
let current_parts: Vec<&str> = current.split('.').collect();
let latest_parts: Vec<&str> = latest.split('.').collect();
// Compare version components
for (c, l) in current_parts.iter().zip(latest_parts.iter()) {
let c_num: u32 = c.parse().unwrap_or(0);
let l_num: u32 = l.parse().unwrap_or(0);
if l_num > c_num {
return true;
}
if c_num > l_num {
return false;
}
}
// If we get here and latest has more components, it's newer
latest_parts.len() > current_parts.len()
}

View File

@ -4,16 +4,47 @@ mod types;
mod utils;
use clap::{Parser, Subcommand};
use commands::{auth, deploy, deploy_v2, GenerateCommand, import, init};
use colored::*;
use commands::{auth::AuthArgs, deploy, deploy_v2, import, init, GenerateCommand};
use std::path::PathBuf;
pub const APP_NAME: &str = "buster";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const BUILD_DATE: &str = env!("BUILD_DATE");
pub const GIT_HASH: &str = env!("GIT_HASH");
#[derive(Subcommand)]
#[clap(rename_all = "kebab-case")]
pub enum Commands {
Init,
Auth,
/// Authenticate with Buster API
Auth {
/// The Buster API host URL
#[arg(long, env = "BUSTER_HOST")]
host: Option<String>,
/// Your Buster API key
#[arg(long, env = "BUSTER_API_KEY")]
api_key: Option<String>,
/// Don't save credentials to disk
#[arg(long)]
no_save: bool,
},
/// Display version information
Version,
/// Update buster-cli to the latest version
Update {
/// Only check if an update is available
#[arg(long)]
check_only: bool,
/// Force update even if already on latest version
#[arg(long)]
force: bool,
/// Skip update confirmation prompt
#[arg(short = 'y')]
no_prompt: bool,
},
Generate {
#[arg(long)]
source_path: Option<String>,
@ -48,19 +79,63 @@ async fn main() {
// TODO: All commands should check for an update.
let result = match args.cmd {
Commands::Init => init().await,
Commands::Auth => auth().await,
Commands::Generate {
Commands::Auth {
host,
api_key,
no_save,
} => {
commands::auth::auth_with_args(AuthArgs {
host,
api_key,
no_save,
})
.await
}
Commands::Version => {
println!("{} v{}", APP_NAME.bold(), VERSION);
println!("Build Date: {}", BUILD_DATE);
println!("Git Commit: {}", GIT_HASH);
// Check for updates
match commands::version::check_latest_version().await {
Ok(Some(latest_version)) => {
if commands::version::is_update_available(VERSION, &latest_version) {
println!("\n{}", "Update available!".yellow().bold());
println!("Latest version: {}", latest_version.green());
println!("Run {} to update", "buster update".cyan());
} else {
println!("\n{}", "You are using the latest version".green());
}
}
Ok(None) => println!("\n{}", "Unable to check for updates".yellow()),
Err(e) => println!("\n{}: {}", "Error checking for updates".red(), e),
}
Ok(())
}
Commands::Update {
check_only,
force,
no_prompt,
} => {
let cmd = commands::update::UpdateCommand::new(check_only, force, no_prompt);
cmd.execute().await
}
Commands::Generate {
source_path,
destination_path,
data_source_name,
schema,
database,
} => {
let source = source_path.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
let dest = destination_path.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
let source = source_path
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let dest = destination_path
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let cmd = GenerateCommand::new(source, dest, data_source_name, schema, database);
cmd.execute().await
},
}
Commands::Import => import().await,
Commands::Deploy { path, dry_run } => deploy_v2(path.as_deref(), dry_run).await,
};

View File

@ -5,7 +5,7 @@ use tokio::fs;
use crate::{error::BusterError, utils::BusterClient};
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct BusterCredentials {
pub url: String,
pub api_key: String,

View File

@ -1,4 +1,4 @@
## Buster Warehouse Overview
#p# Buster Warehouse Overview
In working with our customers, we found that Snowflake, Bigquery, and other warehouse solutions were prohibitively expensive or slow in them being able to deploy AI-powered analytics at scale.