use std::sync::atomic::{AtomicBool, Ordering};
use serde::Deserialize;
use sha1::{Digest, Sha1};
use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
pub const OVH_ENDPOINTS: &[(&str, &str)] = &[
("eu", "Europe (eu.api.ovh.com)"),
("ca", "Canada (ca.api.ovh.com)"),
("us", "US (api.us.ovhcloud.com)"),
];
pub const OVH_ENDPOINT_GROUPS: &[(&str, usize, usize)] = &[("API Endpoint", 0, 3)];
pub struct Ovh {
pub project: String,
pub endpoint: String,
}
fn endpoint_url(endpoint: &str) -> &'static str {
match endpoint {
"ca" => "https://ca.api.ovh.com/1.0",
"us" => "https://api.us.ovhcloud.com/1.0",
_ => "https://eu.api.ovh.com/1.0",
}
}
#[derive(Deserialize)]
struct OvhInstance {
id: String,
name: String,
status: String,
#[serde(default)]
region: String,
#[serde(rename = "ipAddresses", default)]
ip_addresses: Vec<OvhIpAddress>,
#[serde(default)]
flavor: Option<OvhFlavor>,
#[serde(default)]
image: Option<OvhImage>,
}
#[derive(Deserialize)]
struct OvhIpAddress {
ip: String,
#[serde(rename = "type")]
ip_type: String,
version: u8,
}
#[derive(Deserialize)]
struct OvhFlavor {
#[serde(default)]
name: String,
}
#[derive(Deserialize)]
struct OvhImage {
#[serde(default)]
name: Option<String>,
}
fn parse_token(token: &str) -> Result<(&str, &str, &str), ProviderError> {
let parts: Vec<&str> = token.splitn(3, ':').collect();
if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) {
return Err(ProviderError::AuthFailed);
}
Ok((parts[0], parts[1], parts[2]))
}
fn sign_request(
app_secret: &str,
consumer_key: &str,
method: &str,
url: &str,
body: &str,
timestamp: u64,
) -> String {
let pre_hash = format!(
"{}+{}+{}+{}+{}+{}",
app_secret, consumer_key, method, url, body, timestamp
);
let mut hasher = Sha1::new();
hasher.update(pre_hash.as_bytes());
let hash = hasher.finalize();
format!("$1${}", hex_encode(&hash))
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn select_ip(addresses: &[OvhIpAddress]) -> Option<String> {
addresses
.iter()
.find(|a| a.ip_type == "public" && a.version == 4)
.or_else(|| {
addresses
.iter()
.find(|a| a.ip_type == "public" && a.version == 6)
})
.or_else(|| {
addresses
.iter()
.find(|a| a.ip_type == "private" && a.version == 4)
})
.map(|a| super::strip_cidr(&a.ip).to_string())
}
impl Provider for Ovh {
fn name(&self) -> &str {
"ovh"
}
fn short_label(&self) -> &str {
"ovh"
}
fn fetch_hosts_cancellable(
&self,
token: &str,
cancel: &AtomicBool,
) -> Result<Vec<ProviderHost>, ProviderError> {
let (app_key, app_secret, consumer_key) = parse_token(token)?;
let agent = super::http_agent();
let base = endpoint_url(&self.endpoint);
if self.project.is_empty() {
return Err(ProviderError::Execute(
"OVH project ID is required. Set it in the provider config.".to_string(),
));
}
if cancel.load(Ordering::Relaxed) {
return Err(ProviderError::Cancelled);
}
let time_url = format!("{}/auth/time", base);
let server_time: u64 = agent
.get(&time_url)
.call()
.map_err(map_ureq_error)?
.body_mut()
.read_json()
.map_err(|e| ProviderError::Parse(e.to_string()))?;
if cancel.load(Ordering::Relaxed) {
return Err(ProviderError::Cancelled);
}
let instances_url = format!(
"{}/cloud/project/{}/instance",
base,
super::percent_encode(&self.project)
);
let signature = sign_request(
app_secret,
consumer_key,
"GET",
&instances_url,
"",
server_time,
);
let instances: Vec<OvhInstance> = agent
.get(&instances_url)
.header("X-Ovh-Application", app_key)
.header("X-Ovh-Timestamp", &server_time.to_string())
.header("X-Ovh-Consumer", consumer_key)
.header("X-Ovh-Signature", &signature)
.header("Content-Type", "application/json;charset=utf-8")
.call()
.map_err(map_ureq_error)?
.body_mut()
.read_json()
.map_err(|e| ProviderError::Parse(e.to_string()))?;
let mut hosts = Vec::with_capacity(instances.len());
for instance in &instances {
if let Some(ip) = select_ip(&instance.ip_addresses) {
let mut metadata = Vec::with_capacity(4);
if !instance.region.is_empty() {
metadata.push(("region".to_string(), instance.region.clone()));
}
if let Some(ref flavor) = instance.flavor {
if !flavor.name.is_empty() {
metadata.push(("type".to_string(), flavor.name.clone()));
}
}
if let Some(ref image) = instance.image {
if let Some(ref name) = image.name {
if !name.is_empty() {
metadata.push(("image".to_string(), name.clone()));
}
}
}
if !instance.status.is_empty() {
metadata.push(("status".to_string(), instance.status.clone()));
}
hosts.push(ProviderHost {
server_id: instance.id.clone(),
name: instance.name.clone(),
ip,
tags: Vec::new(),
metadata,
});
}
}
Ok(hosts)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_token_valid() {
let (ak, as_, ck) = parse_token("app-key:app-secret:consumer-key").unwrap();
assert_eq!(ak, "app-key");
assert_eq!(as_, "app-secret");
assert_eq!(ck, "consumer-key");
}
#[test]
fn test_parse_token_missing_part() {
assert!(parse_token("key:secret").is_err());
}
#[test]
fn test_parse_token_empty_part() {
assert!(parse_token("key::consumer").is_err());
assert!(parse_token(":secret:consumer").is_err());
}
#[test]
fn test_parse_token_colon_in_consumer_key() {
let (ak, as_, ck) = parse_token("key:secret:consumer:with:colons").unwrap();
assert_eq!(ak, "key");
assert_eq!(as_, "secret");
assert_eq!(ck, "consumer:with:colons");
}
#[test]
fn test_sign_request_format() {
let sig = sign_request(
"EgWIz07P0HYwtQDs",
"MtSwSrPpNjqfVSmJhLbPyr2i45lSwPU1",
"GET",
"https://eu.api.ovh.com/1.0/cloud/project/abc/instance",
"",
1366560945,
);
assert!(sig.starts_with("$1$"), "signature must start with $1$");
assert_eq!(sig.len(), 3 + 40, "should be $1$ + 40 hex chars");
assert!(sig[3..].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_sign_request_deterministic() {
let sig1 = sign_request("s", "c", "GET", "https://example.com", "", 12345);
let sig2 = sign_request("s", "c", "GET", "https://example.com", "", 12345);
assert_eq!(sig1, sig2);
}
#[test]
fn test_sign_request_different_timestamps() {
let sig1 = sign_request("s", "c", "GET", "https://example.com", "", 1);
let sig2 = sign_request("s", "c", "GET", "https://example.com", "", 2);
assert_ne!(sig1, sig2);
}
#[test]
fn test_hex_encode() {
assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
assert_eq!(hex_encode(&[0x00, 0xff]), "00ff");
}
#[test]
fn test_parse_instance_response() {
let json = r#"[
{
"id": "uuid-123",
"name": "web-1",
"status": "ACTIVE",
"region": "GRA11",
"ipAddresses": [
{"ip": "1.2.3.4", "type": "public", "version": 4},
{"ip": "10.0.0.1", "type": "private", "version": 4}
],
"flavor": {"name": "b2-7"},
"image": {"name": "Ubuntu 22.04"}
}
]"#;
let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
assert_eq!(instances.len(), 1);
assert_eq!(instances[0].id, "uuid-123");
assert_eq!(instances[0].name, "web-1");
assert_eq!(instances[0].status, "ACTIVE");
assert_eq!(instances[0].region, "GRA11");
assert_eq!(instances[0].ip_addresses.len(), 2);
assert_eq!(instances[0].flavor.as_ref().unwrap().name, "b2-7");
assert_eq!(
instances[0].image.as_ref().unwrap().name.as_deref(),
Some("Ubuntu 22.04")
);
}
#[test]
fn test_parse_instance_minimal_fields() {
let json = r#"[{"id": "x", "name": "y", "status": "BUILD"}]"#;
let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
assert_eq!(instances.len(), 1);
assert!(instances[0].ip_addresses.is_empty());
assert!(instances[0].flavor.is_none());
assert!(instances[0].image.is_none());
}
#[test]
fn test_select_ip_prefers_public_ipv4() {
let addrs = vec![
OvhIpAddress {
ip: "10.0.0.1".into(),
ip_type: "private".into(),
version: 4,
},
OvhIpAddress {
ip: "1.2.3.4".into(),
ip_type: "public".into(),
version: 4,
},
OvhIpAddress {
ip: "2001:db8::1".into(),
ip_type: "public".into(),
version: 6,
},
];
assert_eq!(select_ip(&addrs).unwrap(), "1.2.3.4");
}
#[test]
fn test_select_ip_falls_back_to_public_ipv6() {
let addrs = vec![
OvhIpAddress {
ip: "10.0.0.1".into(),
ip_type: "private".into(),
version: 4,
},
OvhIpAddress {
ip: "2001:db8::1/64".into(),
ip_type: "public".into(),
version: 6,
},
];
assert_eq!(select_ip(&addrs).unwrap(), "2001:db8::1");
}
#[test]
fn test_select_ip_falls_back_to_private_ipv4() {
let addrs = vec![OvhIpAddress {
ip: "10.0.0.1".into(),
ip_type: "private".into(),
version: 4,
}];
assert_eq!(select_ip(&addrs).unwrap(), "10.0.0.1");
}
#[test]
fn test_select_ip_empty() {
assert!(select_ip(&[]).is_none());
}
#[test]
fn test_http_instances_roundtrip() {
let mut server = mockito::Server::new();
let time_mock = server
.mock("GET", "/1.0/auth/time")
.with_status(200)
.with_body("1700000000")
.create();
let instances_mock = server
.mock("GET", "/1.0/cloud/project/proj-123/instance")
.match_header("X-Ovh-Application", "app-key")
.match_header("X-Ovh-Consumer", "consumer-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{
"id": "i-1",
"name": "web-1",
"status": "ACTIVE",
"region": "GRA11",
"ipAddresses": [{"ip": "1.2.3.4", "type": "public", "version": 4}],
"flavor": {"name": "b2-7"},
"image": {"name": "Ubuntu 22.04"}
}]"#,
)
.create();
let base_url = server.url();
let token = "app-key:app-secret:consumer-key";
let (app_key, app_secret, consumer_key) = parse_token(token).unwrap();
let agent = super::super::http_agent();
let time_url = format!("{}/1.0/auth/time", base_url);
let server_time: u64 = agent
.get(&time_url)
.call()
.unwrap()
.body_mut()
.read_json()
.unwrap();
let instances_url = format!("{}/1.0/cloud/project/proj-123/instance", base_url);
let sig = sign_request(
app_secret,
consumer_key,
"GET",
&instances_url,
"",
server_time,
);
let instances: Vec<OvhInstance> = agent
.get(&instances_url)
.header("X-Ovh-Application", app_key)
.header("X-Ovh-Timestamp", &server_time.to_string())
.header("X-Ovh-Consumer", consumer_key)
.header("X-Ovh-Signature", &sig)
.call()
.unwrap()
.body_mut()
.read_json()
.unwrap();
assert_eq!(instances.len(), 1);
assert_eq!(instances[0].name, "web-1");
assert_eq!(select_ip(&instances[0].ip_addresses).unwrap(), "1.2.3.4");
time_mock.assert();
instances_mock.assert();
}
#[test]
fn test_http_instances_auth_failure() {
let mut server = mockito::Server::new();
let time_mock = server
.mock("GET", "/1.0/auth/time")
.with_status(200)
.with_body("1700000000")
.create();
let instances_mock = server
.mock("GET", "/1.0/cloud/project/proj-123/instance")
.with_status(401)
.with_body(r#"{"message": "Invalid credentials"}"#)
.create();
let agent = super::super::http_agent();
let base_url = server.url();
let _: u64 = agent
.get(&format!("{}/1.0/auth/time", base_url))
.call()
.unwrap()
.body_mut()
.read_json()
.unwrap();
let result = agent
.get(&format!("{}/1.0/cloud/project/proj-123/instance", base_url))
.call();
assert!(result.is_err());
let err = super::map_ureq_error(result.unwrap_err());
assert!(matches!(err, ProviderError::AuthFailed));
time_mock.assert();
instances_mock.assert();
}
#[test]
fn test_rejects_empty_project() {
let ovh = Ovh {
project: String::new(),
endpoint: String::new(),
};
let cancel = AtomicBool::new(false);
let result = ovh.fetch_hosts_cancellable("ak:as:ck", &cancel);
let msg = result.unwrap_err().to_string();
assert!(msg.contains("project ID is required"));
}
#[test]
fn test_rejects_invalid_token_before_network() {
let ovh = Ovh {
project: "proj".to_string(),
endpoint: String::new(),
};
let cancel = AtomicBool::new(false);
let result = ovh.fetch_hosts_cancellable("bad-token", &cancel);
assert!(matches!(result.unwrap_err(), ProviderError::AuthFailed));
}
#[test]
fn test_endpoint_url_eu() {
assert_eq!(endpoint_url("eu"), "https://eu.api.ovh.com/1.0");
assert_eq!(endpoint_url(""), "https://eu.api.ovh.com/1.0");
assert_eq!(endpoint_url("unknown"), "https://eu.api.ovh.com/1.0");
}
#[test]
fn test_endpoint_url_ca() {
assert_eq!(endpoint_url("ca"), "https://ca.api.ovh.com/1.0");
}
#[test]
fn test_endpoint_url_us() {
assert_eq!(endpoint_url("us"), "https://api.us.ovhcloud.com/1.0");
}
#[test]
fn test_sign_request_known_vector() {
let sig = sign_request(
"EgWIz07P0HYwtQDs",
"MtSwSrPpNjqfVSmJhLbPyr2i45lSwPU1",
"GET",
"https://eu.api.ovh.com/1.0/auth/time",
"",
1366560945,
);
assert_eq!(sig, "$1$069f8fd9c1fbec55d67f24f80e65cb1a14f09dce");
}
#[test]
fn test_sign_request_with_body() {
let sig_empty = sign_request("s", "c", "GET", "https://x.com", "", 1);
let sig_body = sign_request("s", "c", "POST", "https://x.com", r#"{"key":"val"}"#, 1);
assert_ne!(sig_empty, sig_body);
}
#[test]
fn test_sign_request_different_methods() {
let get = sign_request("s", "c", "GET", "https://x.com", "", 1);
let post = sign_request("s", "c", "POST", "https://x.com", "", 1);
assert_ne!(get, post);
}
#[test]
fn test_parse_token_empty_string() {
assert!(parse_token("").is_err());
}
#[test]
fn test_parse_token_only_colons() {
assert!(parse_token("::").is_err());
}
#[test]
fn test_parse_token_trailing_colon() {
assert!(parse_token("key:secret:").is_err());
}
#[test]
fn test_parse_instance_extra_fields_ignored() {
let json = r#"[{
"id": "uuid-123",
"name": "web-1",
"status": "ACTIVE",
"created": "2024-01-15T10:30:00Z",
"planCode": "d2-2.runabove",
"monthlyBilling": null,
"sshKey": {"id": "key-1"},
"currentMonthOutgoingTraffic": 12345,
"operationIds": [],
"ipAddresses": [{"ip": "1.2.3.4", "type": "public", "version": 4, "gatewayIp": "1.2.3.1", "networkId": "net-1"}],
"flavor": {"name": "b2-7", "available": true, "disk": 50, "ram": 7168, "vcpus": 2},
"image": {"name": "Ubuntu 22.04", "type": "linux", "user": "ubuntu", "visibility": "public"}
}]"#;
let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
assert_eq!(instances.len(), 1);
assert_eq!(instances[0].name, "web-1");
}
#[test]
fn test_parse_instance_null_flavor_and_image() {
let json =
r#"[{"id": "x", "name": "y", "status": "BUILD", "flavor": null, "image": null}]"#;
let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
assert!(instances[0].flavor.is_none());
assert!(instances[0].image.is_none());
}
#[test]
fn test_parse_empty_instance_list() {
let instances: Vec<OvhInstance> = serde_json::from_str("[]").unwrap();
assert!(instances.is_empty());
}
#[test]
fn test_select_ip_private_ipv6_only_returns_none() {
let addrs = vec![OvhIpAddress {
ip: "fd00::1".into(),
ip_type: "private".into(),
version: 6,
}];
assert!(select_ip(&addrs).is_none());
}
#[test]
fn test_select_ip_unknown_type_returns_none() {
let addrs = vec![OvhIpAddress {
ip: "1.2.3.4".into(),
ip_type: "floating".into(),
version: 4,
}];
assert!(select_ip(&addrs).is_none());
}
#[test]
fn test_select_ip_public_ipv4_with_cidr() {
let addrs = vec![OvhIpAddress {
ip: "1.2.3.4/32".into(),
ip_type: "public".into(),
version: 4,
}];
assert_eq!(select_ip(&addrs).unwrap(), "1.2.3.4");
}
#[test]
fn test_select_ip_multiple_public_ipv4_uses_first() {
let addrs = vec![
OvhIpAddress {
ip: "1.1.1.1".into(),
ip_type: "public".into(),
version: 4,
},
OvhIpAddress {
ip: "2.2.2.2".into(),
ip_type: "public".into(),
version: 4,
},
];
assert_eq!(select_ip(&addrs).unwrap(), "1.1.1.1");
}
#[test]
fn test_metadata_all_fields_present() {
let json = r#"[{
"id": "i-1", "name": "web", "status": "ACTIVE", "region": "GRA11",
"ipAddresses": [{"ip": "1.2.3.4", "type": "public", "version": 4}],
"flavor": {"name": "b2-7"},
"image": {"name": "Ubuntu 22.04"}
}]"#;
let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
let inst = &instances[0];
let mut metadata = Vec::with_capacity(4);
if !inst.region.is_empty() {
metadata.push(("region".to_string(), inst.region.clone()));
}
if let Some(ref flavor) = inst.flavor {
if !flavor.name.is_empty() {
metadata.push(("type".to_string(), flavor.name.clone()));
}
}
if let Some(ref image) = inst.image {
if let Some(ref name) = image.name {
if !name.is_empty() {
metadata.push(("image".to_string(), name.clone()));
}
}
}
if !inst.status.is_empty() {
metadata.push(("status".to_string(), inst.status.clone()));
}
assert_eq!(metadata.len(), 4);
assert_eq!(metadata[0], ("region".to_string(), "GRA11".to_string()));
assert_eq!(metadata[1], ("type".to_string(), "b2-7".to_string()));
assert_eq!(
metadata[2],
("image".to_string(), "Ubuntu 22.04".to_string())
);
assert_eq!(metadata[3], ("status".to_string(), "ACTIVE".to_string()));
}
#[test]
fn test_metadata_no_optional_fields() {
let json = r#"[{"id": "i-1", "name": "web", "status": "", "region": ""}]"#;
let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
let inst = &instances[0];
let mut metadata = Vec::new();
if !inst.region.is_empty() {
metadata.push(("region".to_string(), inst.region.clone()));
}
if let Some(ref flavor) = inst.flavor {
if !flavor.name.is_empty() {
metadata.push(("type".to_string(), flavor.name.clone()));
}
}
if !inst.status.is_empty() {
metadata.push(("status".to_string(), inst.status.clone()));
}
assert!(metadata.is_empty());
}
#[test]
fn test_instance_no_ip_skipped() {
let json = r#"[
{"id": "i-1", "name": "has-ip", "status": "ACTIVE", "ipAddresses": [{"ip": "1.2.3.4", "type": "public", "version": 4}]},
{"id": "i-2", "name": "no-ip", "status": "ACTIVE", "ipAddresses": []},
{"id": "i-3", "name": "private-v6-only", "status": "ACTIVE", "ipAddresses": [{"ip": "fd00::1", "type": "private", "version": 6}]}
]"#;
let instances: Vec<OvhInstance> = serde_json::from_str(json).unwrap();
let hosts: Vec<_> = instances
.iter()
.filter_map(|inst| select_ip(&inst.ip_addresses).map(|ip| (inst.name.clone(), ip)))
.collect();
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].0, "has-ip");
}
#[test]
fn test_name_and_short_label() {
let ovh = Ovh {
project: String::new(),
endpoint: String::new(),
};
assert_eq!(ovh.name(), "ovh");
assert_eq!(ovh.short_label(), "ovh");
}
#[test]
fn test_cancellation_returns_cancelled() {
let cancel = AtomicBool::new(true);
let ovh = Ovh {
project: "test-project".to_string(),
endpoint: String::new(),
};
let result = ovh.fetch_hosts_cancellable("AK:AS:CK", &cancel);
assert!(matches!(result, Err(ProviderError::Cancelled)));
}
}