feat: add marketing site and expand Proxmox management features

This commit is contained in:
Matt
2026-08-10 14:33:06 +00:00
parent 27b9aeb45e
commit 20dd2dc449
70 changed files with 12092 additions and 1282 deletions
+42 -3
View File
@@ -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
View File
@@ -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();
+197 -7
View File
@@ -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();
+314 -6
View File
@@ -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]
+1 -1
View File
@@ -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"
);
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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);
}