fix: tolerate Proxmox numeric string APIs and refine VM list
This commit is contained in:
@@ -659,10 +659,12 @@ async fn get_stored_credentials(
|
||||
}
|
||||
|
||||
// Console proxy types
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VNCProxyResponse {
|
||||
pub ticket: String,
|
||||
#[serde(deserialize_with = "proxmox::de_u32_lenient")]
|
||||
pub port: u32,
|
||||
pub cert: String,
|
||||
}
|
||||
@@ -671,6 +673,7 @@ pub struct VNCProxyResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TermProxyResponse {
|
||||
pub ticket: String,
|
||||
#[serde(deserialize_with = "proxmox::de_u32_lenient")]
|
||||
pub port: u32,
|
||||
}
|
||||
|
||||
|
||||
+127
-10
@@ -1,5 +1,122 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Deserializes an integer that Proxmox reports either as a JSON number
|
||||
/// (integer or integral float) or as a numeric string, inconsistently across
|
||||
/// versions and endpoints.
|
||||
pub(crate) fn de_u32_lenient<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct U32Visitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for U32Visitor {
|
||||
type Value = u32;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("an integer, an integral number, or a numeric string")
|
||||
}
|
||||
|
||||
fn visit_u64<E: serde::de::Error>(self, value: u64) -> std::result::Result<u32, E> {
|
||||
u32::try_from(value).map_err(|_| E::custom(format!("value {} out of range", value)))
|
||||
}
|
||||
|
||||
fn visit_i64<E: serde::de::Error>(self, value: i64) -> std::result::Result<u32, E> {
|
||||
u32::try_from(value).map_err(|_| E::custom(format!("value {} out of range", value)))
|
||||
}
|
||||
|
||||
fn visit_f64<E: serde::de::Error>(self, value: f64) -> std::result::Result<u32, E> {
|
||||
if value.fract() == 0.0 && value >= 0.0 && value <= u32::MAX as f64 {
|
||||
Ok(value as u32)
|
||||
} else {
|
||||
Err(E::custom(format!("value {} is not a valid integer", value)))
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<u32, E> {
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| E::custom(format!("invalid integer string '{}'", value)))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(U32Visitor)
|
||||
}
|
||||
|
||||
/// Deserializes a `u64` that Proxmox reports either as a JSON number (integer
|
||||
/// or integral float) or as a numeric string.
|
||||
pub(crate) fn de_u64_lenient<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct U64Visitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for U64Visitor {
|
||||
type Value = u64;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("an integer, an integral number, or a numeric string")
|
||||
}
|
||||
|
||||
fn visit_u64<E: serde::de::Error>(self, value: u64) -> std::result::Result<u64, E> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn visit_i64<E: serde::de::Error>(self, value: i64) -> std::result::Result<u64, E> {
|
||||
u64::try_from(value).map_err(|_| E::custom(format!("value {} out of range", value)))
|
||||
}
|
||||
|
||||
fn visit_f64<E: serde::de::Error>(self, value: f64) -> std::result::Result<u64, E> {
|
||||
if value.fract() == 0.0 && value >= 0.0 && value <= u64::MAX as f64 {
|
||||
Ok(value as u64)
|
||||
} else {
|
||||
Err(E::custom(format!("value {} is not a valid integer", value)))
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<u64, E> {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| E::custom(format!("invalid integer string '{}'", value)))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(U64Visitor)
|
||||
}
|
||||
|
||||
/// Deserializes an optional `u64` that may be absent, null, a JSON number
|
||||
/// (integer or integral float), or a numeric string.
|
||||
pub(crate) fn de_u64_opt_lenient<'de, D>(deserializer: D) -> std::result::Result<Option<u64>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct OptU64Visitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for OptU64Visitor {
|
||||
type Value = Option<u64>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("an integer, an integral number, a numeric string, or null")
|
||||
}
|
||||
|
||||
fn visit_none<E: serde::de::Error>(self) -> std::result::Result<Option<u64>, E> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Option<u64>, E> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_some<D>(self, inner: D) -> std::result::Result<Option<u64>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
de_u64_lenient(inner).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_option(OptU64Visitor)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Disk {
|
||||
@@ -28,7 +145,7 @@ pub struct Node {
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub cpu: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u32_lenient")]
|
||||
pub maxcpu: u32,
|
||||
#[serde(default)]
|
||||
pub mem: u64,
|
||||
@@ -63,7 +180,7 @@ pub struct VM {
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub cpu: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u32_lenient")]
|
||||
pub cpus: u32,
|
||||
#[serde(default)]
|
||||
pub mem: u64,
|
||||
@@ -315,11 +432,11 @@ pub struct RestoreConfig {
|
||||
pub struct StorageContent {
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u64_lenient")]
|
||||
pub ctime: u64,
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u64_opt_lenient")]
|
||||
pub size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub subtype: Option<String>,
|
||||
@@ -334,17 +451,17 @@ pub struct StorageDetail {
|
||||
pub storage: String,
|
||||
pub r#type: String,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u32_lenient")]
|
||||
pub active: u32,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u32_lenient")]
|
||||
pub enabled: u32,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u32_lenient")]
|
||||
pub shared: u32,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u64_lenient")]
|
||||
pub used: u64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u64_lenient")]
|
||||
pub total: u64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "de_u64_lenient")]
|
||||
pub avail: u64,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
|
||||
@@ -155,6 +155,128 @@ async fn get_vms_uses_cluster_resources() {
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_nodes_accepts_string_and_float_maxcpu() {
|
||||
// Some Proxmox versions report maxcpu as a numeric string or an integral
|
||||
// float instead of an integer.
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!nodes-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"node": "pve1", "status": "online", "cpu": 0.12, "maxcpu": "16",
|
||||
"mem": 8589934592u64, "maxmem": 68719476736u64, "disk": 214748364800u64,
|
||||
"maxdisk": 858993459200u64, "uptime": 123456, "level": "",
|
||||
"id": "node/pve1", "type": "node"},
|
||||
{"node": "pve2", "status": "online", "cpu": 0.0, "maxcpu": 8.0,
|
||||
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
|
||||
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
|
||||
"id": "node/pve2", "type": "node"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let nodes = manager
|
||||
.get_nodes("conn")
|
||||
.await
|
||||
.expect("nodes should be fetched");
|
||||
|
||||
assert_eq!(nodes[0].maxcpu, 16);
|
||||
assert_eq!(nodes[1].maxcpu, 8);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_vms_accepts_string_and_float_cpus() {
|
||||
// Some Proxmox versions report cpus as a numeric string or an integral
|
||||
// float instead of an integer.
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!vms-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "vm")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"vmid": 100, "name": "web01", "type": "qemu", "status": "running",
|
||||
"node": "pve1", "cpu": 0.03, "cpus": "2", "maxcpu": "2",
|
||||
"mem": 2147483648u64, "maxmem": 4294967296u64, "disk": 107374182400u64,
|
||||
"maxdisk": 34359738368u64, "uptime": 99999, "netin": 123, "netout": 456,
|
||||
"diskread": 789, "diskwrite": 101112, "template": 0, "tags": "prod",
|
||||
"pid": 1234},
|
||||
{"vmid": 201, "name": "ct01", "type": "lxc", "status": "running",
|
||||
"node": "pve2", "cpu": 0.01, "cpus": 4.0, "maxcpu": 4.0,
|
||||
"mem": 1073741824u64, "maxmem": 2147483648u64, "disk": 107374182400u64,
|
||||
"maxdisk": 34359738368u64, "uptime": 123, "netin": 1, "netout": 2,
|
||||
"diskread": 3, "diskwrite": 4, "template": 0, "tags": "", "pid": 5678}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let vms = manager
|
||||
.get_vms("conn")
|
||||
.await
|
||||
.expect("VMs should be fetched");
|
||||
|
||||
assert_eq!(vms[0].cpus, 2);
|
||||
assert_eq!(vms[1].cpus, 4);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_vms_rejects_non_numeric_cpus() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!vms-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "vm")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"vmid": 100, "name": "web01", "type": "qemu", "status": "running",
|
||||
"node": "pve1", "cpu": 0.03, "cpus": "many", "maxcpu": 2,
|
||||
"mem": 2147483648u64, "maxmem": 4294967296u64, "disk": 107374182400u64,
|
||||
"maxdisk": 34359738368u64, "uptime": 99999, "netin": 0, "netout": 0,
|
||||
"diskread": 0, "diskwrite": 0, "template": 0, "tags": "prod"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let error = manager
|
||||
.get_vms("conn")
|
||||
.await
|
||||
.expect_err("non-numeric cpus must be rejected");
|
||||
assert!(
|
||||
matches!(error, Error::SerializationError(ref message) if message.contains("many")),
|
||||
"expected SerializationError mentioning the cpus value, got: {}",
|
||||
error
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_maps_cluster_resources() {
|
||||
let server = MockServer::start();
|
||||
@@ -253,6 +375,45 @@ async fn get_storage_content_uses_configured_node() {
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_content_accepts_string_metadata() {
|
||||
// Some Proxmox versions report ctime/size as numeric strings instead of
|
||||
// integers; the list must still parse.
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!content-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "local:iso/debian-12.iso", "content": "iso",
|
||||
"ctime": "1700000000", "format": "iso", "size": "68719476736"},
|
||||
{"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl",
|
||||
"ctime": 1700000001.0, "format": "tgz"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local", Some("pve1"))
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
assert_eq!(contents.len(), 2);
|
||||
assert_eq!(contents[0].ctime, 1700000000);
|
||||
assert_eq!(contents[0].size, Some(68719476736u64));
|
||||
// Integral floats are accepted too; missing size stays None.
|
||||
assert_eq!(contents[1].ctime, 1700000001);
|
||||
assert_eq!(contents[1].size, None);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_content_falls_back_to_online_node() {
|
||||
let server = MockServer::start();
|
||||
|
||||
@@ -110,6 +110,108 @@ async fn create_term_proxy_maps_response() {
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_vnc_proxy_accepts_string_port() {
|
||||
// Some Proxmox versions report the proxy port as a JSON string.
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!vnc-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/tatooine/qemu/106/vncproxy")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"ticket": "PVE:vnc:abc",
|
||||
"port": "5901",
|
||||
"cert": "MIIB..."
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
|
||||
let proxy = manager
|
||||
.create_vnc_proxy("conn", "tatooine", 106)
|
||||
.await
|
||||
.expect("vnc proxy should be created");
|
||||
|
||||
assert_eq!(proxy.ticket, "PVE:vnc:abc");
|
||||
assert_eq!(proxy.port, 5901);
|
||||
assert_eq!(proxy.cert, "MIIB...");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_term_proxy_accepts_string_port() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!term-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/termproxy")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"ticket": "PVE:term:xyz",
|
||||
"port": "6100"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
|
||||
let proxy = manager
|
||||
.create_term_proxy("conn", "pve1", 201)
|
||||
.await
|
||||
.expect("term proxy should be created");
|
||||
|
||||
assert_eq!(proxy.ticket, "PVE:term:xyz");
|
||||
assert_eq!(proxy.port, 6100);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_vnc_proxy_rejects_invalid_port_string() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!vnc-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/vncproxy")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"ticket": "PVE:vnc:abc",
|
||||
"port": "not-a-port",
|
||||
"cert": "MIIB..."
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
|
||||
let error = manager
|
||||
.create_vnc_proxy("conn", "pve1", 100)
|
||||
.await
|
||||
.expect_err("non-numeric port string must be rejected");
|
||||
assert!(
|
||||
matches!(error, Error::SerializationError(ref message) if message.contains("not-a-port")),
|
||||
"expected Serialization mentioning the port string, got: {}",
|
||||
error
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_websocket_url_converts_https_to_wss() {
|
||||
// Explicit port on https.
|
||||
|
||||
Reference in New Issue
Block a user