feat: add marketing site and expand Proxmox management features
This commit is contained in:
@@ -127,7 +127,7 @@ async fn server_error_message_is_mapped_to_api_error() {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(500)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"message":"boom"}"#);
|
||||
.body(r#"{"errors":"specific failure","message":"boom"}"#);
|
||||
});
|
||||
|
||||
let error = api_request(
|
||||
@@ -142,9 +142,48 @@ async fn server_error_message_is_mapped_to_api_error() {
|
||||
.await
|
||||
.expect_err("request should fail");
|
||||
|
||||
// A string `errors` field takes precedence over the generic `message`.
|
||||
assert!(
|
||||
matches!(error, Error::ApiError(ref message) if message == "boom"),
|
||||
"expected ApiError with message 'boom', got: {}",
|
||||
matches!(error, Error::ApiError(ref message) if message == "specific failure"),
|
||||
"expected ApiError with message 'specific failure', got: {}",
|
||||
error
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_error_errors_object_first_pair_is_surfaced() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
r#"{"errors":{"limit":"property is not defined in schema"},"message":"Parameter verification failed."}"#,
|
||||
);
|
||||
});
|
||||
|
||||
let error = api_request(
|
||||
&Client::new(),
|
||||
&server.base_url(),
|
||||
RMethod::GET,
|
||||
"/version",
|
||||
&token_auth(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("request should fail");
|
||||
|
||||
// An `errors` object surfaces its first `key: value` pair, not the generic
|
||||
// `message` fallback.
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
Error::ApiError(ref message)
|
||||
if message == "limit: property is not defined in schema"
|
||||
),
|
||||
"expected ApiError with 'limit: property is not defined in schema', got: {}",
|
||||
error
|
||||
);
|
||||
mock.assert();
|
||||
|
||||
+141
-19
@@ -8,7 +8,7 @@
|
||||
//! called directly on an added connection without `connect()`.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
|
||||
use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig, Error, UpdateVMConfig};
|
||||
|
||||
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
||||
/// primary endpoint points at the mock server. `node` pins the storage node.
|
||||
@@ -168,14 +168,17 @@ async fn get_storage_maps_cluster_resources() {
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
// The real cluster-resource shape: usage is `disk`/`maxdisk`
|
||||
// and liveness is the `status` string. There are no
|
||||
// `used`/`total`/`avail`/`enabled`/`active` keys.
|
||||
{"storage": "local", "node": "pve1", "type": "dir",
|
||||
"content": "iso,vztmpl", "enabled": 1, "shared": 0, "active": 1,
|
||||
"total": 858993459200u64, "used": 429496729600u64, "avail": 429496729600u64,
|
||||
"content": "iso,vztmpl", "shared": 0, "plugintype": "dir",
|
||||
"disk": 429496729600u64, "maxdisk": 858993459200u64,
|
||||
"status": "available"},
|
||||
{"storage": "backup", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "enabled": 1, "shared": 1, "active": 1,
|
||||
"total": 1717986918400u64, "used": 644245094400u64,
|
||||
"avail": 1073741824000u64, "status": "available"}
|
||||
{"storage": "backup", "node": "pve2", "type": "nfs",
|
||||
"content": "backup", "shared": 1, "plugintype": "nfs",
|
||||
"disk": 644245094400u64, "maxdisk": 1717986918400u64,
|
||||
"status": "unavailable"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
@@ -192,14 +195,22 @@ async fn get_storage_maps_cluster_resources() {
|
||||
assert_eq!(storages[0].storage, "local");
|
||||
assert_eq!(storages[0].r#type, "dir");
|
||||
assert_eq!(storages[0].content, "iso,vztmpl");
|
||||
assert_eq!(storages[0].active, 1);
|
||||
assert_eq!(storages[0].enabled, 1);
|
||||
assert_eq!(storages[0].shared, 0);
|
||||
// `disk`/`maxdisk`/`status` map onto used/total/avail/enabled/active.
|
||||
assert_eq!(storages[0].used, 429496729600u64);
|
||||
assert_eq!(storages[0].total, 858993459200u64);
|
||||
assert_eq!(storages[0].avail, 429496729600u64);
|
||||
assert_eq!(storages[0].enabled, 1);
|
||||
assert_eq!(storages[0].active, 1);
|
||||
assert_eq!(storages[0].shared, 0);
|
||||
assert_eq!(storages[0].node, "pve1");
|
||||
// A storage whose status is not "available" reports enabled/active 0.
|
||||
assert_eq!(storages[1].enabled, 0);
|
||||
assert_eq!(storages[1].active, 0);
|
||||
assert_eq!(storages[1].used, 644245094400u64);
|
||||
assert_eq!(storages[1].total, 1717986918400u64);
|
||||
assert_eq!(storages[1].avail, 1073741824000u64);
|
||||
assert_eq!(storages[1].shared, 1);
|
||||
assert_eq!(storages[1].node, "pve2");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
@@ -227,7 +238,7 @@ async fn get_storage_content_uses_configured_node() {
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local")
|
||||
.get_storage_content("conn", "local", Some("pve1"))
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
@@ -282,7 +293,7 @@ async fn get_storage_content_falls_back_to_online_node() {
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local")
|
||||
.get_storage_content("conn", "local", None)
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
@@ -292,6 +303,36 @@ async fn get_storage_content_falls_back_to_online_node() {
|
||||
content_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_content_uses_explicit_node() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!explicit-node-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve2/storage/local/content");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [{"volid": "local:iso/explicit.iso", "content": "iso",
|
||||
"ctime": 1700000000}]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
// The connection pins `pve1`, but an explicitly requested node must win.
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local", Some("pve2"))
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
assert_eq!(contents.len(), 1);
|
||||
assert_eq!(contents[0].volid, "local:iso/explicit.iso");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_detail_maps_status() {
|
||||
let server = MockServer::start();
|
||||
@@ -303,9 +344,11 @@ async fn get_storage_detail_maps_status() {
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"storage": "local", "type": "dir", "content": "iso,vztmpl",
|
||||
// The real status response has no `storage` or `node` keys;
|
||||
// both fields must default to empty strings.
|
||||
"data": {"type": "dir", "content": "iso,vztmpl",
|
||||
"active": 1, "enabled": 1, "shared": 0, "used": 429496729600u64,
|
||||
"total": 858993459200u64, "avail": 429496729600u64, "node": "pve1"}
|
||||
"total": 858993459200u64, "avail": 429496729600u64}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
@@ -317,7 +360,10 @@ async fn get_storage_detail_maps_status() {
|
||||
.await
|
||||
.expect("detail should be fetched");
|
||||
|
||||
assert_eq!(detail.storage, "local");
|
||||
// The storage and node are not part of the status response, so they
|
||||
// default to empty strings.
|
||||
assert_eq!(detail.storage, "");
|
||||
assert_eq!(detail.node, "");
|
||||
assert_eq!(detail.r#type, "dir");
|
||||
assert_eq!(detail.content, "iso,vztmpl");
|
||||
assert_eq!(detail.active, 1);
|
||||
@@ -325,7 +371,6 @@ async fn get_storage_detail_maps_status() {
|
||||
assert_eq!(detail.used, 429496729600u64);
|
||||
assert_eq!(detail.total, 858993459200u64);
|
||||
assert_eq!(detail.avail, 429496729600u64);
|
||||
assert_eq!(detail.node, "pve1");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
@@ -335,8 +380,7 @@ async fn get_tasks_maps_cluster_tasks() {
|
||||
let token = "root@pam!tasks-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/tasks")
|
||||
.query_param("limit", "50");
|
||||
.path("/api2/json/cluster/tasks");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
@@ -527,12 +571,90 @@ async fn lifecycle_with_invalid_type_errors() {
|
||||
error
|
||||
);
|
||||
assert_eq!(
|
||||
probe.hits(),
|
||||
probe.calls(),
|
||||
0,
|
||||
"no HTTP request should be made for an invalid vm type"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_vm_config_posts_only_present_fields() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!update-config-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token))
|
||||
// The form body carries exactly the present fields.
|
||||
.body_includes("name=web01")
|
||||
.body_includes("cores=4")
|
||||
.body_includes("memory=8192")
|
||||
.body_excludes("description");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.update_vm_config(
|
||||
"conn",
|
||||
"pve1",
|
||||
100,
|
||||
"qemu",
|
||||
UpdateVMConfig {
|
||||
name: Some("web01".to_string()),
|
||||
cores: Some(4),
|
||||
memory: Some(8192),
|
||||
description: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("config update should succeed");
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_vm_config_with_all_none_errors_without_request() {
|
||||
let server = MockServer::start();
|
||||
let probe = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!empty-config-token", None).await;
|
||||
let error = manager
|
||||
.update_vm_config(
|
||||
"conn",
|
||||
"pve1",
|
||||
100,
|
||||
"qemu",
|
||||
UpdateVMConfig {
|
||||
name: None,
|
||||
cores: None,
|
||||
memory: None,
|
||||
description: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("an empty config must be rejected");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::ApiError(ref message) if message == "Nothing to update"),
|
||||
"expected ApiError 'Nothing to update', got: {}",
|
||||
error
|
||||
);
|
||||
assert_eq!(
|
||||
probe.calls(),
|
||||
0,
|
||||
"no HTTP request should be made for an empty config"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_methods_error_with_connection_not_found() {
|
||||
let server = MockServer::start();
|
||||
|
||||
@@ -334,6 +334,50 @@ async fn edit_nic_preserves_model_and_mac() {
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_nic_tag_clear_preserves_other_attributes() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!edit-nic-tag-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=10,link_down=1"}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
// urlencoded: `=` and `,` become `%3D` and `%2C`; the tag is gone
|
||||
// while the unmodeled `link_down` attribute is carried over.
|
||||
.body_includes("net0=virtio%3DAA%3ABB%3ACC%3ADD%3AEE%3AFF%2Cbridge%3Dvmbr0%2Clink_down%3D1")
|
||||
.body_excludes("tag");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = EditNICConfig {
|
||||
bridge: None,
|
||||
model: None,
|
||||
tag: Some(0),
|
||||
firewall: None,
|
||||
};
|
||||
manager
|
||||
.edit_nic("conn", "pve1", 100, "qemu", "net0", config)
|
||||
.await
|
||||
.expect("nic should be edited");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_nic_posts_delete() {
|
||||
let server = MockServer::start();
|
||||
@@ -388,17 +432,26 @@ async fn lxc_vm_types_use_lxc_path() {
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
|
||||
// Containers use `rootfs`/`mpN` keys rather than `scsiN`/`virtioN`; they
|
||||
// are not returned by get_disks, and network keys without an explicit
|
||||
// model are still parsed.
|
||||
// Containers use `rootfs`/`mpN` keys; they map onto Disk entries with the
|
||||
// container-specific device names, and LXC network keys without an
|
||||
// explicit model are still parsed.
|
||||
let disks = manager
|
||||
.get_disks("conn", "pve1", 201, "lxc")
|
||||
.await
|
||||
.expect("disks should be fetched");
|
||||
assert!(
|
||||
disks.is_empty(),
|
||||
"container disks are not in the scsi/virtio/... key set"
|
||||
);
|
||||
assert_eq!(disks.len(), 2);
|
||||
let rootfs = disks
|
||||
.iter()
|
||||
.find(|d| d.device == "rootfs")
|
||||
.expect("rootfs disk should be parsed");
|
||||
assert_eq!(rootfs.storage, "local");
|
||||
assert_eq!(rootfs.size, 8589934592); // 8G
|
||||
let mp0 = disks
|
||||
.iter()
|
||||
.find(|d| d.device == "mp0")
|
||||
.expect("mp0 disk should be parsed");
|
||||
assert_eq!(mp0.storage, "local");
|
||||
assert_eq!(mp0.size, 4294967296); // 4G
|
||||
|
||||
let nics = manager
|
||||
.get_network_interfaces("conn", "pve1", 201, "lxc")
|
||||
@@ -407,6 +460,9 @@ async fn lxc_vm_types_use_lxc_path() {
|
||||
assert_eq!(nics.len(), 1);
|
||||
assert_eq!(nics[0].name, "net0");
|
||||
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
|
||||
// LXC format: `name=eth0` lead means no QEMU model=mac first segment.
|
||||
assert_eq!(nics[0].model, "");
|
||||
assert_eq!(nics[0].macaddr, "");
|
||||
|
||||
manager
|
||||
.resize_disk("conn", "pve1", 201, "lxc", "rootfs", 17179869184)
|
||||
@@ -417,6 +473,140 @@ async fn lxc_vm_types_use_lxc_path() {
|
||||
resize_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_nic_lxc_writes_lxc_net_format() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!add-nic-lxc-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"memory": 2048}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config")
|
||||
// urlencoded: `name=eth0,type=veth,bridge=vmbr0,hwaddr=BC:24:11:8D:DF:95,firewall=1`
|
||||
.body_includes(
|
||||
"net0=name%3Deth0%2Ctype%3Dveth%2Cbridge%3Dvmbr0%2Chwaddr%3DBC%3A24%3A11%3A8D%3ADF%3A95%2Cfirewall%3D1",
|
||||
);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = AddNICConfig {
|
||||
bridge: "vmbr0".to_string(),
|
||||
// The model is ignored for LXC (containers always use veth), so an
|
||||
// otherwise-invalid model still succeeds.
|
||||
model: "veth".to_string(),
|
||||
macaddr: Some("BC:24:11:8D:DF:95".to_string()),
|
||||
tag: None,
|
||||
firewall: Some(true),
|
||||
};
|
||||
manager
|
||||
.add_nic("conn", "pve1", 201, "lxc", config)
|
||||
.await
|
||||
.expect("nic should be added");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_nic_lxc_random_mac_and_no_firewall_omits_hwaddr_and_firewall() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!add-nic-lxc-plain-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{"memory":2048}}"#);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config")
|
||||
.body_includes("net0=name%3Deth0%2Ctype%3Dveth%2Cbridge%3Dvmbr0")
|
||||
.body_excludes("hwaddr")
|
||||
.body_excludes("firewall");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = AddNICConfig {
|
||||
bridge: "vmbr0".to_string(),
|
||||
model: "veth".to_string(),
|
||||
// `random` (or a missing MAC) means "let Proxmox assign one".
|
||||
macaddr: Some("random".to_string()),
|
||||
tag: None,
|
||||
firewall: None,
|
||||
};
|
||||
manager
|
||||
.add_nic("conn", "pve1", 201, "lxc", config)
|
||||
.await
|
||||
.expect("nic should be added");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_nic_lxc_reencodes_lxc_format() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!edit-nic-lxc-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"net0": "name=eth0,type=veth,hwaddr=BC:24:11:8D:DF:95,bridge=vmbr0,ip=dhcp,firewall=0"}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config")
|
||||
// urlencoded; the LXC form is rebuilt (`name=eth0,type=veth,
|
||||
// hwaddr=...`) with the new bridge/firewall and the unknown
|
||||
// `ip=dhcp` attribute carried over, but no tag.
|
||||
.body_includes(
|
||||
"net0=name%3Deth0%2Ctype%3Dveth%2Chwaddr%3DBC%3A24%3A11%3A8D%3ADF%3A95%2Cbridge%3Dvmbr1%2Cfirewall%3D1%2Cip%3Ddhcp",
|
||||
)
|
||||
.body_excludes("tag");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = EditNICConfig {
|
||||
bridge: Some("vmbr1".to_string()),
|
||||
model: None,
|
||||
tag: None,
|
||||
firewall: Some(true),
|
||||
};
|
||||
manager
|
||||
.edit_nic("conn", "pve1", 201, "lxc", "net0", config)
|
||||
.await
|
||||
.expect("nic should be edited");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_vm_type_errors() {
|
||||
let server = MockServer::start();
|
||||
|
||||
@@ -211,10 +211,12 @@ async fn get_backup_jobs_maps_list() {
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"id": "backup-1", "store": "backup", "schedule": "0 2 * * *",
|
||||
// The server sends `storage` (not `store`).
|
||||
{"id": "backup-1", "storage": "backup", "schedule": "0 2 * * *",
|
||||
"all": 1, "enabled": 1, "node": "pve1", "compress": "zstd",
|
||||
"mode": "snapshot", "quiet": 0},
|
||||
{"id": "backup-2", "store": "local", "schedule": "30 3 * * 1",
|
||||
// A vmid-selected job omits `all` entirely.
|
||||
{"id": "backup-2", "storage": "local", "schedule": "30 3 * * 1",
|
||||
"all": 0, "enabled": 0, "vmid": "100,101"}
|
||||
]
|
||||
})
|
||||
@@ -238,7 +240,9 @@ async fn get_backup_jobs_maps_list() {
|
||||
assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
|
||||
assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
|
||||
assert_eq!(jobs[0].quiet, Some(0));
|
||||
// Tolerance: the second job omits the optional node/mode fields.
|
||||
// Tolerance: the second job omits the optional node/mode fields and has no
|
||||
// `all` key, which defaults to 0.
|
||||
assert_eq!(jobs[1].store, "local");
|
||||
assert_eq!(jobs[1].node, None);
|
||||
assert_eq!(jobs[1].compress, None);
|
||||
assert_eq!(jobs[1].mode, None);
|
||||
@@ -248,6 +252,72 @@ async fn get_backup_jobs_maps_list() {
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_job_parses_realistic_pve_91_shape() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!jobs-real-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/backup")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
// A job as PVE 9.1 actually emits it: `storage` key,
|
||||
// no `all`, and a pile of fields the struct does not
|
||||
// model (pool, notes-template, prune-backups, fleecing,
|
||||
// next-run, notification-mode, ...).
|
||||
{
|
||||
"id": "backup-pve-1",
|
||||
"storage": "kashyyk",
|
||||
"schedule": "0 2 * * *",
|
||||
"enabled": 1,
|
||||
"node": "pve1",
|
||||
"mode": "snapshot",
|
||||
"compress": "zstd",
|
||||
"vmid": "100,101,102",
|
||||
"pool": "prod",
|
||||
"notes-template": "{{guestname}}",
|
||||
"prune-backups": {"keep-last": 3, "keep-daily": 7},
|
||||
"fleecing": {"enabled": 1, "storage": "local-lvm"},
|
||||
"next-run": 1760000000,
|
||||
"notification-mode": "auto",
|
||||
"bwlimit": 0,
|
||||
"quiet": 0,
|
||||
"starttime": "2026-08-01 02:00:00",
|
||||
"stdexcludes": 0,
|
||||
"remove": 0
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let jobs = manager
|
||||
.get_backup_jobs("conn")
|
||||
.await
|
||||
.expect("backup jobs should be fetched");
|
||||
|
||||
assert_eq!(jobs.len(), 1);
|
||||
assert_eq!(jobs[0].id, "backup-pve-1");
|
||||
// `storage` on the wire maps onto `store`.
|
||||
assert_eq!(jobs[0].store, "kashyyk");
|
||||
assert_eq!(jobs[0].schedule, "0 2 * * *");
|
||||
// `all` is never sent for a vmid-selected job and defaults to 0.
|
||||
assert_eq!(jobs[0].all, 0);
|
||||
assert_eq!(jobs[0].enabled, 1);
|
||||
assert_eq!(jobs[0].node.as_deref(), Some("pve1"));
|
||||
assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
|
||||
assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
|
||||
assert_eq!(jobs[0].vmid.as_deref(), Some("100,101,102"));
|
||||
assert_eq!(jobs[0].quiet, Some(0));
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_backup_job_posts_form() {
|
||||
let server = MockServer::start();
|
||||
@@ -361,10 +431,27 @@ async fn get_backups_filters_and_maps() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_defaults_to_local_storage() {
|
||||
async fn get_backups_aggregates_over_single_backup_storage() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-default-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
let resources_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"storage": "local", "node": "pve1", "type": "dir",
|
||||
"content": "backup,iso", "shared": 0,
|
||||
"status": "available"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let content_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content")
|
||||
.query_param("content", "backup");
|
||||
@@ -380,7 +467,228 @@ async fn get_backups_defaults_to_local_storage() {
|
||||
.expect("backups should be fetched");
|
||||
|
||||
assert!(backups.is_empty());
|
||||
mock.assert();
|
||||
resources_mock.assert();
|
||||
content_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_aggregates_all_backup_storages_when_none_specified() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-aggregate-token";
|
||||
let resources_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"storage": "backup1", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "shared": 1,
|
||||
"status": "available"},
|
||||
{"storage": "backup2", "node": "pve2", "type": "nfs",
|
||||
"content": "backup,iso", "shared": 1,
|
||||
"status": "available"},
|
||||
{"storage": "local", "node": "pve1", "type": "dir",
|
||||
"content": "iso,vztmpl", "shared": 0,
|
||||
"status": "available"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let backup1_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/backup1/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "100",
|
||||
"backup-time": 1700000000, "storage": "backup1",
|
||||
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let backup2_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/backup2/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "backup2:backup/vzdump-qemu-201-2024_01_02-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-201-2024_01_02-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "201",
|
||||
"backup-time": 1700000100, "storage": "backup2",
|
||||
"size": 2147483648u64, "ctime": 1700000101, "content": "backup"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
// A storage without `backup` content must never be queried.
|
||||
let local_probe = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let backups = manager
|
||||
.get_backups("conn", None)
|
||||
.await
|
||||
.expect("backups should be aggregated");
|
||||
|
||||
assert_eq!(backups.len(), 2);
|
||||
assert_eq!(
|
||||
backups[0].volid,
|
||||
"backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
);
|
||||
assert_eq!(
|
||||
backups[1].volid,
|
||||
"backup2:backup/vzdump-qemu-201-2024_01_02-00_00_00.vma.zst"
|
||||
);
|
||||
resources_mock.assert();
|
||||
backup1_mock.assert();
|
||||
backup2_mock.assert();
|
||||
assert_eq!(
|
||||
local_probe.calls(),
|
||||
0,
|
||||
"a storage without backup content must not be queried"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_specific_storage_only() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-specific-token";
|
||||
let content_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/backup1/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "100",
|
||||
"backup-time": 1700000000, "storage": "backup1",
|
||||
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
// The storage list must not be consulted when a specific storage is given.
|
||||
let resources_probe = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let backups = manager
|
||||
.get_backups("conn", Some("backup1"))
|
||||
.await
|
||||
.expect("backups should be fetched");
|
||||
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(
|
||||
backups[0].volid,
|
||||
"backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
);
|
||||
content_mock.assert();
|
||||
assert_eq!(
|
||||
resources_probe.calls(),
|
||||
0,
|
||||
"the storage list must not be queried for a specific storage"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_aggregation_skips_erroring_storage() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-skip-token";
|
||||
let resources_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"storage": "bad", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "shared": 1,
|
||||
"status": "available"},
|
||||
{"storage": "good", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "shared": 1,
|
||||
"status": "available"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let bad_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/bad/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(500)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
let good_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/good/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "good:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "100",
|
||||
"backup-time": 1700000000, "storage": "good",
|
||||
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let backups = manager
|
||||
.get_backups("conn", None)
|
||||
.await
|
||||
.expect("aggregation should succeed despite one erroring storage");
|
||||
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(
|
||||
backups[0].volid,
|
||||
"good:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
);
|
||||
resources_mock.assert();
|
||||
bad_mock.assert();
|
||||
good_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -426,7 +426,7 @@ async fn status_info_when_disconnected() {
|
||||
assert_eq!(info.current_endpoint_url, server.base_url());
|
||||
assert!(info.nodes.is_empty());
|
||||
assert_eq!(
|
||||
version_mock.hits(),
|
||||
version_mock.calls(),
|
||||
0,
|
||||
"a disconnected connection must not hit the network"
|
||||
);
|
||||
|
||||
@@ -169,7 +169,7 @@ async fn non_transport_error_does_not_rotate() {
|
||||
);
|
||||
primary_mock.assert();
|
||||
assert_eq!(
|
||||
fallback_mock.hits(),
|
||||
fallback_mock.calls(),
|
||||
0,
|
||||
"a non-transport error must not trigger failover"
|
||||
);
|
||||
@@ -192,7 +192,7 @@ async fn request_succeeds_on_primary_again_when_it_returns() {
|
||||
|
||||
assert!(vms.is_empty());
|
||||
primary_mock.assert();
|
||||
assert_eq!(fallback_mock.hits(), 0);
|
||||
assert_eq!(fallback_mock.calls(), 0);
|
||||
assert_eq!(
|
||||
manager
|
||||
.runtime_status("conn")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -485,5 +485,5 @@ async fn disconnect_clears_session_and_status() {
|
||||
.await
|
||||
.expect("reconnect after disconnect should succeed");
|
||||
assert_eq!(result.status, "connected");
|
||||
mock.assert_hits(2);
|
||||
mock.assert_calls(2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user