use std::sync::atomic::{AtomicBool, Ordering};
use serde::Deserialize;
use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
pub struct DigitalOcean;
#[derive(Deserialize)]
struct DropletResponse {
droplets: Vec<Droplet>,
meta: Meta,
}
#[derive(Deserialize)]
struct Droplet {
id: u64,
name: String,
networks: Networks,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
size_slug: String,
#[serde(default)]
region: Option<Region>,
#[serde(default)]
status: String,
#[serde(default)]
image: Option<DropletImage>,
}
#[derive(Deserialize)]
struct DropletImage {
#[serde(default)]
name: Option<String>,
}
#[derive(Deserialize)]
struct Region {
slug: String,
}
#[derive(Deserialize)]
struct Networks {
v4: Vec<NetworkIp>,
#[serde(default)]
v6: Vec<NetworkIp>,
}
#[derive(Deserialize)]
struct NetworkIp {
ip_address: String,
#[serde(rename = "type")]
net_type: String,
}
#[derive(Deserialize)]
struct Meta {
total: u64,
}
impl Provider for DigitalOcean {
fn name(&self) -> &str {
"digitalocean"
}
fn short_label(&self) -> &str {
"do"
}
fn fetch_hosts_cancellable(
&self,
token: &str,
cancel: &AtomicBool,
) -> Result<Vec<ProviderHost>, ProviderError> {
let mut all_hosts = Vec::new();
let mut page = 1u64;
let per_page = 200;
let agent = super::http_agent();
loop {
if cancel.load(Ordering::Relaxed) {
return Err(ProviderError::Cancelled);
}
let url = format!(
"https://api.digitalocean.com/v2/droplets?page={}&per_page={}",
page, per_page
);
let resp: DropletResponse = agent
.get(&url)
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(map_ureq_error)?
.into_json()
.map_err(|e| ProviderError::Parse(e.to_string()))?;
if resp.droplets.is_empty() {
break;
}
for droplet in &resp.droplets {
let ip = droplet
.networks
.v4
.iter()
.find(|n| n.net_type == "public")
.or_else(|| {
droplet
.networks
.v6
.iter()
.find(|n| n.net_type == "public")
})
.map(|n| n.ip_address.clone());
if let Some(ip) = ip {
let mut metadata = Vec::new();
if let Some(ref region) = droplet.region {
if !region.slug.is_empty() {
metadata.push(("region".to_string(), region.slug.clone()));
}
}
if !droplet.size_slug.is_empty() {
metadata.push(("plan".to_string(), droplet.size_slug.clone()));
}
if let Some(ref image) = droplet.image {
if let Some(ref name) = image.name {
if !name.is_empty() {
metadata.push(("os".to_string(), name.clone()));
}
}
}
if !droplet.status.is_empty() {
metadata.push(("status".to_string(), droplet.status.clone()));
}
all_hosts.push(ProviderHost {
server_id: droplet.id.to_string(),
name: droplet.name.clone(),
ip,
tags: droplet.tags.clone(),
metadata,
});
}
}
let fetched = page * per_page;
if fetched >= resp.meta.total {
break;
}
page += 1;
if page > 500 {
break;
}
}
Ok(all_hosts)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_droplet_response() {
let json = r#"{
"droplets": [
{
"id": 12345,
"name": "web-1",
"networks": {
"v4": [
{"ip_address": "10.0.0.1", "type": "private"},
{"ip_address": "1.2.3.4", "type": "public"}
]
},
"tags": ["production"]
},
{
"id": 67890,
"name": "db-1",
"networks": {
"v4": [
{"ip_address": "10.0.0.2", "type": "private"}
]
},
"tags": []
}
],
"meta": {"total": 2}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.droplets.len(), 2);
assert_eq!(resp.droplets[0].name, "web-1");
let public_ip = resp.droplets[0]
.networks
.v4
.iter()
.find(|n| n.net_type == "public");
assert!(public_ip.is_some());
assert_eq!(public_ip.unwrap().ip_address, "1.2.3.4");
let public_ip = resp.droplets[1]
.networks
.v4
.iter()
.find(|n| n.net_type == "public");
assert!(public_ip.is_none());
}
fn select_droplet_ip(droplet: &Droplet) -> Option<String> {
droplet
.networks
.v4
.iter()
.find(|n| n.net_type == "public")
.or_else(|| droplet.networks.v6.iter().find(|n| n.net_type == "public"))
.map(|n| n.ip_address.clone())
}
#[test]
fn test_droplet_private_only_skipped() {
let json = r#"{
"droplets": [
{
"id": 99,
"name": "private-only",
"networks": {
"v4": [{"ip_address": "10.132.0.2", "type": "private"}]
},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_droplet_ip(&resp.droplets[0]), None);
}
#[test]
fn test_droplet_empty_networks_skipped() {
let json = r#"{
"droplets": [
{
"id": 100,
"name": "no-networks",
"networks": {"v4": []},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_droplet_ip(&resp.droplets[0]), None);
}
#[test]
fn test_droplet_prefers_v4_over_v6() {
let json = r#"{
"droplets": [
{
"id": 101,
"name": "dual-stack",
"networks": {
"v4": [{"ip_address": "1.2.3.4", "type": "public"}],
"v6": [{"ip_address": "2604:a880::1", "type": "public"}]
},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_droplet_ip(&resp.droplets[0]), Some("1.2.3.4".to_string()));
}
#[test]
fn test_droplet_tags_preserved() {
let json = r#"{
"droplets": [
{
"id": 102,
"name": "tagged",
"networks": {"v4": [{"ip_address": "1.2.3.4", "type": "public"}]},
"tags": ["web", "production", "us-east"]
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.droplets[0].tags, vec!["web", "production", "us-east"]);
}
#[test]
fn test_droplet_id_is_u64() {
let json = r#"{
"droplets": [{"id": 999999999, "name": "big-id", "networks": {"v4": []}, "tags": []}],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.droplets[0].id, 999999999);
}
#[test]
fn test_droplet_v6_default_empty() {
let json = r#"{
"droplets": [
{
"id": 103,
"name": "no-v6",
"networks": {"v4": [{"ip_address": "5.6.7.8", "type": "public"}]},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert!(resp.droplets[0].networks.v6.is_empty());
}
#[test]
fn test_pagination_continues_when_total_exceeds_fetched() {
let json = r#"{
"droplets": [{"id": 1, "name": "a", "networks": {"v4": []}, "tags": []}],
"meta": {"total": 500}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
let page = 1u64;
let per_page = 200u64;
let fetched = page * per_page;
assert!(fetched < resp.meta.total);
}
#[test]
fn test_pagination_stops_when_fetched_reaches_total() {
let json = r#"{
"droplets": [{"id": 1, "name": "a", "networks": {"v4": []}, "tags": []}],
"meta": {"total": 200}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
let page = 1u64;
let per_page = 200u64;
let fetched = page * per_page;
assert!(fetched >= resp.meta.total);
}
#[test]
fn test_empty_droplet_list_stops_pagination() {
let json = r#"{
"droplets": [],
"meta": {"total": 0}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert!(resp.droplets.is_empty());
}
#[test]
fn test_droplet_multiple_public_v4_uses_first() {
let json = r#"{
"droplets": [
{
"id": 104,
"name": "multi-public",
"networks": {
"v4": [
{"ip_address": "1.2.3.4", "type": "public"},
{"ip_address": "5.6.7.8", "type": "public"}
]
},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_droplet_ip(&resp.droplets[0]), Some("1.2.3.4".to_string()));
}
#[test]
fn test_droplet_private_v4_public_v6_uses_v6() {
let json = r#"{
"droplets": [
{
"id": 105,
"name": "private-v4-public-v6",
"networks": {
"v4": [{"ip_address": "10.132.0.5", "type": "private"}],
"v6": [{"ip_address": "2604:a880::1", "type": "public"}]
},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_droplet_ip(&resp.droplets[0]), Some("2604:a880::1".to_string()));
}
#[test]
fn test_droplet_default_tags_empty() {
let json = r#"{
"droplets": [
{"id": 106, "name": "no-tags-key", "networks": {"v4": [{"ip_address": "1.2.3.4", "type": "public"}]}}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert!(resp.droplets[0].tags.is_empty());
}
#[test]
fn test_droplet_private_v6_not_used() {
let json = r#"{
"droplets": [
{
"id": 107,
"name": "private-v6",
"networks": {
"v4": [],
"v6": [{"ip_address": "fd00::1", "type": "private"}]
},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_droplet_ip(&resp.droplets[0]), None);
}
#[test]
fn test_droplet_large_id() {
let json = r#"{
"droplets": [{"id": 999999999999, "name": "big", "networks": {"v4": [{"ip_address": "1.2.3.4", "type": "public"}]}, "tags": []}],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.droplets[0].id, 999999999999);
}
#[test]
fn test_droplet_multiple_private_v4_no_public() {
let json = r#"{
"droplets": [
{
"id": 108,
"name": "multi-private",
"networks": {
"v4": [
{"ip_address": "10.132.0.1", "type": "private"},
{"ip_address": "10.132.0.2", "type": "private"}
]
},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(select_droplet_ip(&resp.droplets[0]), None);
}
#[test]
fn test_droplet_extra_fields_ignored() {
let json = r#"{
"droplets": [
{
"id": 200,
"name": "full-response",
"status": "active",
"size_slug": "s-1vcpu-1gb",
"region": {"slug": "nyc3", "name": "New York 3"},
"image": {"id": 12345, "name": "Ubuntu 22.04"},
"created_at": "2024-01-01T00:00:00Z",
"disk": 25,
"memory": 1024,
"vcpus": 1,
"networks": {
"v4": [{"ip_address": "1.2.3.4", "type": "public", "netmask": "255.255.240.0", "gateway": "1.2.0.1"}],
"v6": [{"ip_address": "2604::1", "type": "public", "netmask": 64, "gateway": "2604::"}]
},
"tags": ["web"],
"volume_ids": ["abc"],
"features": ["backups"]
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.droplets[0].name, "full-response");
assert_eq!(select_droplet_ip(&resp.droplets[0]), Some("1.2.3.4".to_string()));
}
#[test]
fn test_network_ip_extra_fields_ignored() {
let json = r#"{
"droplets": [{
"id": 201,
"name": "extra-net",
"networks": {
"v4": [{"ip_address": "5.6.7.8", "type": "public", "netmask": "255.255.240.0", "gateway": "5.6.0.1"}]
},
"tags": []
}],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.droplets[0].networks.v4[0].ip_address, "5.6.7.8");
}
#[test]
fn test_meta_extra_fields_ignored() {
let json = r#"{
"droplets": [],
"meta": {"total": 0},
"links": {"pages": {}}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.meta.total, 0);
}
#[test]
fn test_ipv6_only_droplet_uses_v6() {
let json = r#"{
"droplets": [
{
"id": 11111,
"name": "v6-only",
"networks": {
"v4": [],
"v6": [
{"ip_address": "2604:a880::1", "type": "public"}
]
},
"tags": []
}
],
"meta": {"total": 1}
}"#;
let resp: DropletResponse = serde_json::from_str(json).unwrap();
let droplet = &resp.droplets[0];
let ip = droplet
.networks
.v4
.iter()
.find(|n| n.net_type == "public")
.or_else(|| droplet.networks.v6.iter().find(|n| n.net_type == "public"))
.map(|n| n.ip_address.clone());
assert_eq!(ip, Some("2604:a880::1".to_string()));
}
}