use backon::{ExponentialBuilder, Retryable};
use log::{error, info, warn};
use reqwest::Client;
use std::time::Duration;
use crate::lume::errors::LumeError;
use crate::lume::models::{CloneConfig, RunConfig, VmConfig, VmInfo};
const DEFAULT_API_URL: &str = "http://127.0.0.1:7777/lume";
const CONNECT_TIMEOUT: u64 = 10; const MAX_TIMEOUT: u64 = 300;
pub(crate) fn lume_delete_means_absent(status: reqwest::StatusCode, body: &str) -> bool {
if status == reqwest::StatusCode::NOT_FOUND {
return true;
}
body.contains("Virtual machine not found") || body.contains("VM not found")
}
pub struct LumeClient {
client: Client,
base_url: String,
}
impl LumeClient {
pub fn new() -> Result<Self, LumeError> {
Self::with_base_url(DEFAULT_API_URL)
}
pub fn get_base_url(&self) -> &str {
&self.base_url
}
pub fn with_base_url(base_url: &str) -> Result<Self, LumeError> {
let client = Client::builder()
.http1_only()
.timeout(Duration::from_secs(MAX_TIMEOUT))
.connect_timeout(Duration::from_secs(CONNECT_TIMEOUT))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60))
.build()
.map_err(LumeError::from)?;
Ok(Self {
client,
base_url: base_url.to_string(),
})
}
#[allow(dead_code)]
pub async fn create_vm(&self, config: VmConfig) -> Result<(), LumeError> {
let url = format!("{}/vms", self.base_url);
let response = self.client.post(&url).json(&config).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(LumeError::ApiError(format!(
"Failed to create VM: {}",
error_text
)));
}
Ok(())
}
pub async fn run_vm(&self, name: &str, config: Option<RunConfig>) -> Result<(), LumeError> {
let url = format!("{}/vms/{}/run", self.base_url, name);
info!("Sending request to start VM: {}", name);
let send_run_request = || {
let url = url.clone();
let cfg = config.clone();
async move {
let mut request = self.client.post(&url);
if let Some(run_config) = cfg {
request = request.json(&run_config);
}
let response = request
.send()
.await
.map_err(|e| LumeError::ApiError(format!("HTTP request failed: {:?}", e)))?;
let status = response.status();
let response_text = response
.text()
.await
.unwrap_or_else(|_| "Failed to read response body".to_string());
info!(
"VM Run API Response: Status = {}, Body = {}",
status, response_text
);
if !status.is_success() {
return Err(LumeError::ApiError(format!(
"Failed to run VM: {}",
response_text
)));
}
Ok(())
}
};
send_run_request
.retry(ExponentialBuilder::default().with_max_times(5))
.sleep(tokio::time::sleep)
.when(|e| matches!(e, LumeError::ApiError(_)))
.notify(|err, dur| warn!("Retrying VM start after {:?}: {:?}", dur, err))
.await
.map_err(|e| LumeError::ApiError(format!("Retry exhausted: {:?}", e)))?;
info!("Successfully started VM: {}", name);
Ok(())
}
pub async fn clone_vm(&self, source_name: &str, new_name: &str) -> Result<(), LumeError> {
let url = format!("{}/vms/clone", self.base_url);
let config = CloneConfig {
name: source_name.to_string(),
new_name: new_name.to_string(),
};
info!("Cloning VM {} to {}", source_name, new_name);
let send_clone_request = || async {
let response = self
.client
.post(&url)
.json(&config)
.send()
.await
.map_err(|e| LumeError::ApiError(format!("HTTP request failed: {:?}", e)))?;
let status = response.status();
info!("Clone operation response status: {}", status);
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(LumeError::ApiError(format!(
"Failed to clone VM: {}",
error_text
)));
}
Ok(())
};
send_clone_request
.retry(ExponentialBuilder::default().with_max_times(5)) .sleep(tokio::time::sleep)
.when(|e| matches!(e, LumeError::ApiError(_))) .notify(|err, dur| warn!("Retrying VM clone after {:?}: {:?}", dur, err))
.await
.map_err(|e| LumeError::ApiError(format!("Retry exhausted: {:?}", e)))?;
info!("VM {} successfully cloned to {}", source_name, new_name);
Ok(())
}
pub async fn delete_vm(&self, name: &str) -> Result<(), LumeError> {
let url = format!("{}/vms/{}", self.base_url, name);
info!("Deleting VM {}", name);
let send_delete_request =
|| async {
let response =
self.client.delete(&url).send().await.map_err(|e| {
LumeError::ApiError(format!("HTTP request failed: {:?}", e))
})?;
let status = response.status();
let response_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
info!("Delete operation response status: {}", status);
info!("Delete operation response body: {}", response_text);
if !status.is_success() {
if lume_delete_means_absent(status, &response_text) {
info!("VM {} already absent — treating delete as success", name);
return Ok(());
}
return Err(LumeError::ApiError(format!(
"Failed to delete VM: {}",
response_text
)));
}
Ok(())
};
send_delete_request
.retry(ExponentialBuilder::default().with_max_times(5)) .sleep(tokio::time::sleep)
.when(|e| matches!(e, LumeError::ApiError(_))) .notify(|err, dur| warn!("Retrying VM deletion after {:?}: {:?}", dur, err))
.await
.map_err(|e| LumeError::ApiError(format!("Retry exhausted: {:?}", e)))?;
info!("VM {} successfully deleted", name);
Ok(())
}
pub async fn list_vms(&self) -> Result<Vec<VmInfo>, LumeError> {
let url = format!("{}/vms", self.base_url);
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(LumeError::ApiError(format!(
"Failed to list VMs: {}",
error_text
)));
}
let vms = response.json::<Vec<VmInfo>>().await?;
Ok(vms)
}
pub async fn get_vm(&self, name: &str) -> Result<VmInfo, LumeError> {
info!("Getting VM details for {}", name);
let url = format!("{}/vms/{}", self.base_url, name);
let max_retries = 3;
let mut attempts = 0;
let retry_delay = Duration::from_millis(300);
loop {
attempts += 1;
match self.client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
match response.json::<VmInfo>().await {
Ok(vm_info) => return Ok(vm_info),
Err(e) => {
warn!(
"Failed to parse VM details JSON (attempt {}/{}): {:?}",
attempts, max_retries, e
);
if attempts >= max_retries {
return Err(LumeError::RequestError(e));
}
}
}
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if attempts >= max_retries {
return Err(LumeError::ApiError(format!(
"Failed to get VM details: {}",
error_text
)));
}
}
}
Err(e) => {
warn!(
"Request error while getting VM details for {} (attempt {}/{}): {:?}",
name, attempts, max_retries, e
);
if attempts >= max_retries {
return Err(LumeError::RequestError(e));
}
}
}
tokio::time::sleep(retry_delay).await;
}
}
pub async fn pull_image(
&self,
image: &str,
vm_name: &str,
registry: Option<&str>,
organization: Option<&str>,
no_cache: bool,
) -> Result<(), LumeError> {
use serde_json::json;
info!("Pulling image '{}' for VM '{}'", image, vm_name);
let mut pull_data = json!({
"image": image,
"name": vm_name,
"noCache": no_cache,
});
if let Some(registry_val) = registry {
pull_data["registry"] = json!(registry_val);
}
if let Some(org_val) = organization {
pull_data["organization"] = json!(org_val);
}
let url = format!("{}/pull", self.base_url);
info!("Sending pull request: {}", pull_data);
let response = self.client.post(&url).json(&pull_data).send().await?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
error!("Failed to pull image: {}", error_text);
return Err(LumeError::ApiError(format!(
"Failed to pull image: {}",
error_text
)));
}
info!("Image pull request sent successfully for '{}'", image);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::StatusCode;
#[test]
fn classifier_treats_400_not_found_body_as_absent() {
let body = r#"{"message":"Virtual machine not found: cirun-r1"}"#;
assert!(lume_delete_means_absent(StatusCode::BAD_REQUEST, body));
}
#[test]
fn classifier_treats_http_404_as_absent_regardless_of_body() {
assert!(lume_delete_means_absent(StatusCode::NOT_FOUND, ""));
}
#[test]
fn classifier_rejects_500_and_other_failures() {
assert!(!lume_delete_means_absent(
StatusCode::INTERNAL_SERVER_ERROR,
"boom"
));
assert!(!lume_delete_means_absent(
StatusCode::CONFLICT,
"vm is locked"
));
}
#[test]
fn classifier_does_not_match_unrelated_400() {
assert!(!lume_delete_means_absent(
StatusCode::BAD_REQUEST,
"invalid name format"
));
}
}