use crate::core::error::{Error, Result};
use crate::core::resources::ResourceSpec;
use crate::providers::common::{ProvisionedInfrastructure, ProvisioningConfig};
use blueprint_core::{debug, info};
use blueprint_std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VultrInstance {
id: String,
main_ip: String,
v6_main_ip: Option<String>,
internal_ip: Option<String>,
hostname: String,
os: String,
region: String,
plan: String,
status: String,
power_status: String,
}
pub struct VultrProvisioner {
api_key: String,
client: reqwest::Client,
}
impl VultrProvisioner {
pub async fn new(api_key: String) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(blueprint_std::time::Duration::from_secs(30))
.build()
.map_err(|e| Error::ConfigurationError(format!("Failed to create HTTP client: {e}")))?;
Ok(Self { api_key, client })
}
pub async fn provision_instance(
&self,
spec: &ResourceSpec,
config: &ProvisioningConfig,
) -> Result<ProvisionedInfrastructure> {
let plan = self.select_plan(spec);
let region = if config.region.is_empty() {
"ewr" } else {
&config.region
};
info!(
"Provisioning Vultr instance with plan {} in region {}",
plan, region
);
let create_payload = serde_json::json!({
"region": region,
"plan": plan,
"label": config.name,
"hostname": config.name,
"os_id": 1743, "backups": "disabled",
"enable_ipv6": false,
"ddos_protection": false,
"activation_email": false,
"ssh_keys": config.ssh_key_name.as_ref().map(|k| vec![k]).unwrap_or_default(),
"user_data": self.generate_user_data(spec),
});
let response = self
.client
.post("https://api.vultr.com/v2/instances")
.bearer_auth(&self.api_key)
.json(&create_payload)
.send()
.await
.map_err(|e| Error::ConfigurationError(format!("Failed to create instance: {e}")))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::ConfigurationError(format!(
"Vultr API error: {error_text}"
)));
}
let response_json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::ConfigurationError(format!("Failed to parse response: {e}")))?;
let instance_id = response_json["instance"]["id"]
.as_str()
.ok_or_else(|| Error::ConfigurationError("No instance ID in response".into()))?
.to_string();
let instance = self.wait_for_instance(&instance_id).await?;
let mut metadata = HashMap::new();
metadata.insert("plan".to_string(), plan.to_string());
metadata.insert("os".to_string(), instance.os);
metadata.insert("hostname".to_string(), instance.hostname);
Ok(ProvisionedInfrastructure {
provider: crate::core::remote::CloudProvider::Vultr,
instance_id,
public_ip: Some(instance.main_ip),
private_ip: instance.internal_ip,
region: instance.region,
instance_type: plan.to_string(),
metadata,
})
}
async fn wait_for_instance(&self, instance_id: &str) -> Result<VultrInstance> {
let mut attempts = 0;
let max_attempts = 60;
loop {
if attempts >= max_attempts {
return Err(Error::ConfigurationError(
"Instance provisioning timeout".into(),
));
}
let instance = self.get_instance(instance_id).await?;
if instance.status == "active" && instance.power_status == "running" {
info!("Vultr instance {} is active", instance_id);
return Ok(instance);
}
debug!(
"Instance {} status: {}, power: {}",
instance_id, instance.status, instance.power_status
);
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
attempts += 1;
}
}
async fn get_instance(&self, instance_id: &str) -> Result<VultrInstance> {
let url = format!("https://api.vultr.com/v2/instances/{instance_id}");
let response = self
.client
.get(&url)
.bearer_auth(&self.api_key)
.send()
.await
.map_err(|e| Error::ConfigurationError(format!("Failed to get instance: {e}")))?;
if !response.status().is_success() {
return Err(Error::ConfigurationError(
"Failed to get instance details".into(),
));
}
let json: serde_json::Value = response
.json()
.await
.map_err(|e| Error::ConfigurationError(format!("Failed to parse response: {e}")))?;
let instance = &json["instance"];
Ok(VultrInstance {
id: instance["id"].as_str().unwrap_or("").to_string(),
main_ip: instance["main_ip"].as_str().unwrap_or("").to_string(),
v6_main_ip: instance["v6_main_ip"].as_str().map(|s| s.to_string()),
internal_ip: instance["internal_ip"].as_str().map(|s| s.to_string()),
hostname: instance["hostname"].as_str().unwrap_or("").to_string(),
os: instance["os"].as_str().unwrap_or("").to_string(),
region: instance["region"].as_str().unwrap_or("").to_string(),
plan: instance["plan"].as_str().unwrap_or("").to_string(),
status: instance["status"].as_str().unwrap_or("").to_string(),
power_status: instance["power_status"].as_str().unwrap_or("").to_string(),
})
}
pub async fn terminate_instance(&self, instance_id: &str) -> Result<()> {
let url = format!("https://api.vultr.com/v2/instances/{instance_id}");
let response = self
.client
.delete(&url)
.bearer_auth(&self.api_key)
.send()
.await
.map_err(|e| Error::ConfigurationError(format!("Failed to terminate instance: {e}")))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(Error::ConfigurationError(format!(
"Failed to terminate: {error_text}"
)));
}
info!("Terminated Vultr instance: {}", instance_id);
Ok(())
}
pub async fn get_instance_status(
&self,
instance_id: &str,
) -> Result<crate::infra::types::InstanceStatus> {
match self.get_instance(instance_id).await {
Ok(instance) => match (instance.status.as_str(), instance.power_status.as_str()) {
("active", "running") => Ok(crate::infra::types::InstanceStatus::Running),
("active", "stopped") => Ok(crate::infra::types::InstanceStatus::Stopped),
("pending", _) => Ok(crate::infra::types::InstanceStatus::Starting),
_ => Ok(crate::infra::types::InstanceStatus::Unknown),
},
Err(_) => Ok(crate::infra::types::InstanceStatus::Terminated),
}
}
fn select_plan(&self, spec: &ResourceSpec) -> &'static str {
match (spec.cpu, spec.memory_gb, spec.gpu_count) {
(_, _, Some(_)) => "vc2-8c-32gb",
(cpu, mem, _) if mem > cpu * 4.0 => {
if mem <= 2.0 {
"vc2-1c-2gb"
} else if mem <= 4.0 {
"vc2-2c-4gb"
} else if mem <= 8.0 {
"vc2-4c-8gb"
} else if mem <= 16.0 {
"vc2-6c-16gb"
} else {
"vc2-8c-32gb"
}
}
(cpu, _, _) if cpu >= 6.0 => "vhf-6c-24gb",
(cpu, _, _) if cpu >= 4.0 => "vhf-4c-16gb",
(cpu, _, _) if cpu >= 2.0 => "vhf-2c-8gb",
(cpu, mem, _) if cpu <= 1.0 && mem <= 1.0 => "vc2-1c-1gb",
(cpu, mem, _) if cpu <= 1.0 && mem <= 2.0 => "vc2-1c-2gb",
(cpu, mem, _) if cpu <= 2.0 && mem <= 4.0 => "vc2-2c-4gb",
_ => "vc2-2c-4gb",
}
}
fn generate_user_data(&self, _spec: &ResourceSpec) -> String {
let script = r#"#!/bin/bash
# Update system
apt-get update
apt-get upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
# Install monitoring tools
apt-get install -y htop iotop sysstat
# Configure firewall
ufw allow 22/tcp
ufw allow 8080/tcp
ufw allow 9615/tcp
ufw allow 9944/tcp
ufw --force enable
"#;
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(script)
}
}