use std::sync::atomic::{AtomicBool, Ordering};
use serde::Deserialize;
use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
pub struct Vultr;
#[derive(Deserialize)]
struct InstanceResponse {
instances: Vec<Instance>,
meta: VultrMeta,
}
#[derive(Deserialize)]
struct Instance {
id: String,
label: String,
main_ip: String,
#[serde(default)]
v6_main_ip: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
region: String,
#[serde(default)]
plan: String,
#[serde(default)]
os: String,
#[serde(default)]
power_status: String,
}
#[derive(Deserialize)]
struct VultrMeta {
links: VultrLinks,
}
#[derive(Deserialize)]
struct VultrLinks {
#[serde(default)]
next: String,
}
impl Provider for Vultr {
fn name(&self) -> &str {
"vultr"
}
fn short_label(&self) -> &str {
"vultr"
}
fn fetch_hosts_cancellable(
&self,
token: &str,
cancel: &AtomicBool,
) -> Result<Vec<ProviderHost>, ProviderError> {
let mut all_hosts = Vec::new();
let mut cursor: Option<String> = None;
let agent = super::http_agent();
let mut pages = 0u64;
loop {
if cancel.load(Ordering::Relaxed) {
return Err(ProviderError::Cancelled);
}
let url = match &cursor {
None => "https://api.vultr.com/v2/instances?per_page=500".to_string(),
Some(c) => format!(
"https://api.vultr.com/v2/instances?per_page=500&cursor={}",
super::percent_encode(c)
),
};
let resp: InstanceResponse = agent
.get(&url)
.header("Authorization", &format!("Bearer {}", token))
.call()
.map_err(map_ureq_error)?
.body_mut()
.read_json()
.map_err(|e| ProviderError::Parse(e.to_string()))?;
if resp.instances.is_empty() {
break;
}
for instance in &resp.instances {
let ip = if !instance.main_ip.is_empty() && instance.main_ip != "0.0.0.0" {
instance.main_ip.clone()
} else if !instance.v6_main_ip.is_empty() && instance.v6_main_ip != "::" {
instance.v6_main_ip.clone()
} else {
continue;
};
let mut metadata = Vec::new();
if !instance.region.is_empty() {
metadata.push(("region".to_string(), instance.region.clone()));
}
if !instance.plan.is_empty() {
metadata.push(("plan".to_string(), instance.plan.clone()));
}
if !instance.os.is_empty() {
metadata.push(("os".to_string(), instance.os.clone()));
}
if !instance.power_status.is_empty() {
metadata.push(("status".to_string(), instance.power_status.clone()));
}
all_hosts.push(ProviderHost {
server_id: instance.id.clone(),
name: instance.label.clone(),
ip,
tags: instance.tags.clone(),
metadata,
});
}
if resp.meta.links.next.is_empty() {
break;
}
cursor = Some(resp.meta.links.next.clone());
pages += 1;
if pages >= 500 {
break;
}
}
Ok(all_hosts)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_instance_response() {
let json = r#"{
"instances": [
{
"id": "abc-123",
"label": "my-server",
"main_ip": "5.6.7.8",
"tags": ["web"]
},
{
"id": "def-456",
"label": "pending-server",
"main_ip": "0.0.0.0",
"tags": []
}
],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances.len(), 2);
assert_eq!(resp.instances[0].label, "my-server");
assert_eq!(resp.instances[0].main_ip, "5.6.7.8");
assert_eq!(resp.instances[1].main_ip, "0.0.0.0");
}
#[test]
fn test_vultr_empty_ip_skipped() {
let json = r#"{
"instances": [
{
"id": "abc-123",
"label": "empty-ip",
"main_ip": "",
"tags": []
}
],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances.len(), 1);
assert!(resp.instances[0].main_ip.is_empty());
}
#[test]
fn test_vultr_v6_fallback() {
let json = r#"{
"instances": [
{
"id": "v6-only",
"label": "v6-server",
"main_ip": "0.0.0.0",
"v6_main_ip": "2001:db8::1",
"tags": []
}
],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
let instance = &resp.instances[0];
assert_eq!(instance.main_ip, "0.0.0.0");
assert_eq!(instance.v6_main_ip, "2001:db8::1");
}
fn select_vultr_ip(instance: &Instance) -> Option<String> {
if !instance.main_ip.is_empty() && instance.main_ip != "0.0.0.0" {
Some(instance.main_ip.clone())
} else if !instance.v6_main_ip.is_empty() && instance.v6_main_ip != "::" {
Some(instance.v6_main_ip.clone())
} else {
None
}
}
#[test]
fn test_vultr_both_placeholder_skipped() {
let json = r#"{
"instances": [
{"id": "xyz", "label": "both-zero", "main_ip": "0.0.0.0", "v6_main_ip": "::", "tags": []}
],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_vultr_ip(&resp.instances[0]), None);
}
#[test]
fn test_vultr_prefers_v4_over_v6() {
let json = r#"{
"instances": [
{"id": "a", "label": "dual", "main_ip": "5.6.7.8", "v6_main_ip": "2001:db8::1", "tags": []}
],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(
select_vultr_ip(&resp.instances[0]),
Some("5.6.7.8".to_string())
);
}
#[test]
fn test_vultr_tags_preserved() {
let json = r#"{
"instances": [
{"id": "t", "label": "tagged", "main_ip": "1.2.3.4", "tags": ["web", "prod"]}
],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances[0].tags, vec!["web", "prod"]);
}
#[test]
fn test_vultr_cursor_pagination_has_next() {
let json = r#"{
"instances": [{"id": "a", "label": "a", "main_ip": "1.2.3.4", "tags": []}],
"meta": {"links": {"next": "bmV4dA=="}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert!(!resp.meta.links.next.is_empty());
}
#[test]
fn test_vultr_default_v6_empty_string() {
let json = r#"{
"instances": [{"id": "a", "label": "a", "main_ip": "1.2.3.4", "tags": []}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances[0].v6_main_ip, "");
}
#[test]
fn test_vultr_default_tags_empty() {
let json = r#"{
"instances": [{"id": "a", "label": "a", "main_ip": "1.2.3.4"}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert!(resp.instances[0].tags.is_empty());
}
#[test]
fn test_vultr_empty_instance_list_stops() {
let json = r#"{
"instances": [],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert!(resp.instances.is_empty());
}
#[test]
fn test_vultr_string_id_preserved() {
let json = r#"{
"instances": [{
"id": "cb676a46-66fd-4dfb-b839-443f2e6c0b60",
"label": "uuid-test",
"main_ip": "1.2.3.4",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances[0].id, "cb676a46-66fd-4dfb-b839-443f2e6c0b60");
}
#[test]
fn test_vultr_valid_v4_ignores_placeholder_v6() {
let json = r#"{
"instances": [{
"id": "a",
"label": "v4-with-placeholder-v6",
"main_ip": "5.6.7.8",
"v6_main_ip": "::",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(
select_vultr_ip(&resp.instances[0]),
Some("5.6.7.8".to_string())
);
}
#[test]
fn test_vultr_empty_v4_and_valid_v6() {
let json = r#"{
"instances": [{
"id": "a",
"label": "empty-v4-valid-v6",
"main_ip": "",
"v6_main_ip": "2001:db8::1",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(
select_vultr_ip(&resp.instances[0]),
Some("2001:db8::1".to_string())
);
}
#[test]
fn test_vultr_empty_v4_and_empty_v6() {
let json = r#"{
"instances": [{
"id": "a",
"label": "both-empty",
"main_ip": "",
"v6_main_ip": "",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_vultr_ip(&resp.instances[0]), None);
}
#[test]
fn test_vultr_multiple_tags() {
let json = r#"{
"instances": [{
"id": "a",
"label": "multi-tag",
"main_ip": "1.2.3.4",
"tags": ["web", "production", "us-east", "team-a"]
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances[0].tags.len(), 4);
}
#[test]
fn test_vultr_missing_next_link() {
let json = r#"{
"instances": [],
"meta": {"links": {}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert!(resp.meta.links.next.is_empty());
}
#[test]
fn test_vultr_v6_placeholder_only() {
let json = r#"{
"instances": [{
"id": "a",
"label": "no-ip",
"main_ip": "0.0.0.0",
"v6_main_ip": "::",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_vultr_ip(&resp.instances[0]), None);
}
#[test]
fn test_vultr_label_with_special_chars() {
let json = r#"{
"instances": [{
"id": "a",
"label": "web-server (prod) #1",
"main_ip": "1.2.3.4",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances[0].label, "web-server (prod) #1");
}
#[test]
fn test_vultr_v4_zero_not_empty() {
let json = r#"{
"instances": [{
"id": "a",
"label": "pending",
"main_ip": "0.0.0.0",
"v6_main_ip": "2001:db8::1",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(
select_vultr_ip(&resp.instances[0]),
Some("2001:db8::1".to_string())
);
}
#[test]
fn test_vultr_cursor_pagination_empty_next_stops() {
let json = r#"{
"instances": [{"id": "a", "label": "a", "main_ip": "1.2.3.4", "tags": []}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert!(resp.meta.links.next.is_empty());
}
#[test]
fn test_vultr_multiple_instances_parsed() {
let json = r#"{
"instances": [
{"id": "a", "label": "web-1", "main_ip": "1.1.1.1", "tags": ["web"]},
{"id": "b", "label": "web-2", "main_ip": "2.2.2.2", "tags": ["web"]},
{"id": "c", "label": "db-1", "main_ip": "3.3.3.3", "tags": ["db"]}
],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances.len(), 3);
for inst in &resp.instances {
assert!(select_vultr_ip(inst).is_some());
}
}
#[test]
fn test_vultr_placeholder_v4_no_v6_field() {
let json = r#"{
"instances": [{
"id": "a",
"label": "no-v6",
"main_ip": "0.0.0.0",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_vultr_ip(&resp.instances[0]), None);
}
#[test]
fn test_vultr_empty_label() {
let json = r#"{
"instances": [{
"id": "x",
"label": "",
"main_ip": "1.2.3.4",
"tags": []
}],
"meta": {"links": {"next": ""}}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances[0].label, "");
assert_eq!(
select_vultr_ip(&resp.instances[0]),
Some("1.2.3.4".to_string())
);
}
#[test]
fn test_vultr_extra_fields_ignored() {
let json = r#"{
"instances": [{
"id": "cb676a46-66fd-4dfb-b839-443f2e6c0b60",
"os": "Ubuntu 22.04 LTS x64",
"ram": 1024,
"disk": 25,
"main_ip": "45.76.1.1",
"vcpu_count": 1,
"region": "ewr",
"plan": "vc2-1c-1gb",
"date_created": "2024-01-01T00:00:00+00:00",
"status": "active",
"allowed_bandwidth": 1000,
"netmask_v4": "255.255.254.0",
"gateway_v4": "45.76.0.1",
"power_status": "running",
"server_status": "ok",
"v6_main_ip": "2001:19f0::1",
"v6_network": "2001:19f0::",
"v6_network_size": 64,
"label": "full-response",
"internal_ip": "",
"kvm": "https://my.vultr.com/subs/vps/novnc/...",
"hostname": "full-response",
"os_id": 1743,
"app_id": 0,
"image_id": "",
"firewall_group_id": "",
"features": ["auto_backups"],
"tags": ["web", "prod"],
"user_scheme": "root"
}],
"meta": {"links": {"next": "", "prev": ""}, "total": 1}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.instances[0].label, "full-response");
assert_eq!(resp.instances[0].main_ip, "45.76.1.1");
assert_eq!(resp.instances[0].v6_main_ip, "2001:19f0::1");
assert_eq!(resp.instances[0].tags, vec!["web", "prod"]);
}
#[test]
fn test_vultr_meta_extra_fields_ignored() {
let json = r#"{
"instances": [],
"meta": {"links": {"next": "", "prev": ""}, "total": 0}
}"#;
let resp: InstanceResponse = serde_json::from_str(json).unwrap();
assert!(resp.instances.is_empty());
assert!(resp.meta.links.next.is_empty());
}
#[test]
fn test_http_instances_roundtrip() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/v2/instances")
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"per_page".into(),
"500".into(),
)]))
.match_header("Authorization", "Bearer test-vultr-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"instances": [
{
"id": "cb676a46-66fd-4dfb-b839-443f2e6c0b60",
"label": "web-prod-1",
"main_ip": "149.28.100.10",
"v6_main_ip": "2001:19f0:5001::1",
"tags": ["prod", "web"],
"region": "ewr",
"plan": "vc2-2c-4gb",
"os": "Ubuntu 22.04 LTS x64",
"power_status": "running"
}
],
"meta": {"links": {"next": ""}}
}"#,
)
.create();
let agent = super::super::http_agent();
let url = format!("{}/v2/instances?per_page=500", server.url());
let resp: InstanceResponse = agent
.get(&url)
.header("Authorization", "Bearer test-vultr-token")
.call()
.unwrap()
.body_mut()
.read_json()
.unwrap();
assert_eq!(resp.instances.len(), 1);
let i = &resp.instances[0];
assert_eq!(i.id, "cb676a46-66fd-4dfb-b839-443f2e6c0b60");
assert_eq!(i.label, "web-prod-1");
assert_eq!(i.main_ip, "149.28.100.10");
assert_eq!(i.v6_main_ip, "2001:19f0:5001::1");
assert_eq!(i.region, "ewr");
assert_eq!(i.plan, "vc2-2c-4gb");
assert_eq!(i.os, "Ubuntu 22.04 LTS x64");
assert_eq!(i.power_status, "running");
assert_eq!(i.tags, vec!["prod", "web"]);
assert!(resp.meta.links.next.is_empty());
mock.assert();
}
#[test]
fn test_http_instances_pagination() {
let mut server = mockito::Server::new();
let page1 = server
.mock("GET", "/v2/instances")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("per_page".into(), "500".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"instances": [{"id": "aaa", "label": "web-1", "main_ip": "1.1.1.1", "tags": []}],
"meta": {"links": {"next": "bmV4dEN1cnNvcg=="}}
}"#,
)
.create();
let page2 = server
.mock("GET", "/v2/instances")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("per_page".into(), "500".into()),
mockito::Matcher::UrlEncoded("cursor".into(), "bmV4dEN1cnNvcg==".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"instances": [{"id": "bbb", "label": "web-2", "main_ip": "2.2.2.2", "tags": []}],
"meta": {"links": {"next": ""}}
}"#,
)
.create();
let agent = super::super::http_agent();
let r1: InstanceResponse = agent
.get(&format!("{}/v2/instances?per_page=500", server.url()))
.header("Authorization", "Bearer tk")
.call()
.unwrap()
.body_mut()
.read_json()
.unwrap();
assert_eq!(r1.instances.len(), 1);
assert_eq!(r1.instances[0].id, "aaa");
assert_eq!(r1.meta.links.next, "bmV4dEN1cnNvcg==");
let r2: InstanceResponse = agent
.get(&format!(
"{}/v2/instances?per_page=500&cursor=bmV4dEN1cnNvcg==",
server.url()
))
.header("Authorization", "Bearer tk")
.call()
.unwrap()
.body_mut()
.read_json()
.unwrap();
assert_eq!(r2.instances.len(), 1);
assert_eq!(r2.instances[0].id, "bbb");
assert!(r2.meta.links.next.is_empty());
page1.assert();
page2.assert();
}
#[test]
fn test_http_instances_auth_failure() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/v2/instances")
.match_query(mockito::Matcher::Any)
.with_status(401)
.with_body(r#"{"error": "Invalid API token", "status": 401}"#)
.create();
let agent = super::super::http_agent();
let result = agent
.get(&format!("{}/v2/instances?per_page=500", server.url()))
.header("Authorization", "Bearer bad-token")
.call();
match result {
Err(ureq::Error::StatusCode(401)) => {} other => panic!("expected 401 error, got {:?}", other),
}
mock.assert();
}
}