Add Proxmox Backup Server datastore management: overview, datastore detail, download/prune/verify/GC dialogs, usePbs hook, backend commands, and tests. Fix macOS keychain re-writes failing with 'item already exists': replace keyring 3 with keyring-core plus per-platform stores (macOS Keychain, Windows Credential Manager, Linux keyutils), recover by deleting the stale item and retrying once, and surface actionable messages for locked keychains.
758 lines
27 KiB
Rust
758 lines
27 KiB
Rust
//! Integration tests for the real snapshot, migration, and backup API calls.
|
|
//!
|
|
//! Every request goes through `Connection::request`, so these tests cover URL
|
|
//! construction, form encoding, auth header injection, response envelope
|
|
//! unwrapping, and mapping into the public serde structs. Token-mode
|
|
//! connections resolve the token from the config, so the methods can be called
|
|
//! directly on an added connection without `connect()`.
|
|
|
|
use httpmock::prelude::*;
|
|
use clustri::{
|
|
BackupJobConfig, ConnectionConfig, ConnectionManager, CreateSnapshotConfig, EndpointConfig,
|
|
Error,
|
|
};
|
|
|
|
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
|
/// primary endpoint points at the mock server. `node` pins the storage node.
|
|
async fn setup_manager(
|
|
url: &str,
|
|
token: &str,
|
|
node: Option<&str>,
|
|
) -> (ConnectionManager, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().expect("temp dir should be created");
|
|
let path = dir.path().join("connections.json");
|
|
let mut manager = ConnectionManager::new();
|
|
let config = ConnectionConfig {
|
|
id: "conn".to_string(),
|
|
name: "conn".to_string(),
|
|
primary: EndpointConfig {
|
|
url: url.to_string(),
|
|
node: node.map(str::to_string),
|
|
token: Some(token.to_string()),
|
|
},
|
|
fallbacks: vec![],
|
|
cert_fingerprint: None,
|
|
trusted: false,
|
|
accept_untrusted: true,
|
|
status: "disconnected".to_string(),
|
|
cluster_name: None,
|
|
is_cluster: false,
|
|
auth_mode: "token".to_string(),
|
|
username: None,
|
|
nodes: vec![],
|
|
cluster_id: None,
|
|
server_type: "pve".to_string(),
|
|
};
|
|
manager
|
|
.add_connection(config, &path)
|
|
.await
|
|
.expect("connection should be added");
|
|
(manager, dir)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_snapshots_maps_list() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!snap-token";
|
|
let mock = server.mock(|when, then| {
|
|
when.method(GET)
|
|
.path("/api2/json/nodes/pve1/qemu/100/snapshot")
|
|
.header("Authorization", format!("PVEAPIToken={}", token));
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(
|
|
serde_json::json!({
|
|
"data": [
|
|
{"name": "snap1", "description": "before upgrade",
|
|
"snaptime": 1700000000, "vmstate": 1, "parent": "current"},
|
|
{"name": "snap2", "snaptime": 1700000100, "vmstate": 0}
|
|
]
|
|
})
|
|
.to_string(),
|
|
);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
|
let snapshots = manager
|
|
.get_snapshots("conn", "pve1", 100, "qemu")
|
|
.await
|
|
.expect("snapshots should be fetched");
|
|
|
|
assert_eq!(snapshots.len(), 2);
|
|
assert_eq!(snapshots[0].name, "snap1");
|
|
assert_eq!(snapshots[0].description, "before upgrade");
|
|
assert_eq!(snapshots[0].snaptime, 1700000000);
|
|
assert_eq!(snapshots[0].vmstate, 1);
|
|
assert_eq!(snapshots[0].parent.as_deref(), Some("current"));
|
|
// Tolerance: the second snapshot omits optional fields.
|
|
assert_eq!(snapshots[1].name, "snap2");
|
|
assert_eq!(snapshots[1].description, "");
|
|
assert_eq!(snapshots[1].parent, None);
|
|
mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_snapshot_posts_form() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!snap-create-token";
|
|
let mock = server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/api2/json/nodes/pve1/qemu/100/snapshot")
|
|
.body_includes("snapname=snap1")
|
|
.body_includes("vmstate=1");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":null}"#);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
|
manager
|
|
.create_snapshot(
|
|
"conn",
|
|
"pve1",
|
|
100,
|
|
"qemu",
|
|
CreateSnapshotConfig {
|
|
name: "snap1".to_string(),
|
|
description: Some("before upgrade".to_string()),
|
|
vmstate: Some(true),
|
|
},
|
|
)
|
|
.await
|
|
.expect("snapshot should be created");
|
|
mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_snapshot_deletes_path() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!snap-delete-token";
|
|
let mock = server.mock(|when, then| {
|
|
when.method(DELETE)
|
|
.path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":null}"#);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
|
manager
|
|
.delete_snapshot("conn", "pve1", 100, "qemu", "snap1")
|
|
.await
|
|
.expect("snapshot should be deleted");
|
|
mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rollback_snapshot_posts_rollback() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!snap-rollback-token";
|
|
let mock = server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1/rollback");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":null}"#);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
|
manager
|
|
.rollback_snapshot("conn", "pve1", 100, "qemu", "snap1")
|
|
.await
|
|
.expect("rollback should succeed");
|
|
mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn migrate_vm_posts_migrate() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!migrate-token";
|
|
let online_mock = server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/api2/json/nodes/pve1/qemu/100/migrate")
|
|
.body_includes("target=pve2")
|
|
.body_includes("online=1");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":null}"#);
|
|
});
|
|
let offline_mock = server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/api2/json/nodes/pve1/qemu/100/migrate")
|
|
.body_includes("target=pve2")
|
|
.body_includes("online=0");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":null}"#);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
|
manager
|
|
.migrate_vm("conn", "pve1", 100, "qemu", "pve2", true)
|
|
.await
|
|
.expect("online migration should succeed");
|
|
manager
|
|
.migrate_vm("conn", "pve1", 100, "qemu", "pve2", false)
|
|
.await
|
|
.expect("offline migration should succeed");
|
|
online_mock.assert();
|
|
offline_mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_backup_jobs_maps_list() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!jobs-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": [
|
|
// 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},
|
|
// A vmid-selected job omits `all` entirely.
|
|
{"id": "backup-2", "storage": "local", "schedule": "30 3 * * 1",
|
|
"all": 0, "enabled": 0, "vmid": "100,101"}
|
|
]
|
|
})
|
|
.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(), 2);
|
|
assert_eq!(jobs[0].id, "backup-1");
|
|
assert_eq!(jobs[0].store, "backup");
|
|
assert_eq!(jobs[0].schedule, "0 2 * * *");
|
|
assert_eq!(jobs[0].all, 1);
|
|
assert_eq!(jobs[0].enabled, 1);
|
|
assert_eq!(jobs[0].node.as_deref(), Some("pve1"));
|
|
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 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);
|
|
assert_eq!(jobs[1].quiet, None);
|
|
assert_eq!(jobs[1].vmid.as_deref(), Some("100,101"));
|
|
assert_eq!(jobs[1].all, 0);
|
|
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();
|
|
let token = "root@pam!job-create-token";
|
|
let mock = server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/api2/json/cluster/backup")
|
|
.body_includes("schedule=0+2+*+*+*")
|
|
.body_includes("storage=backup")
|
|
.body_includes("mode=snapshot")
|
|
.body_includes("compress=zstd")
|
|
.body_includes("all=1")
|
|
.body_includes("enabled=1")
|
|
.body_includes("vmid=100%2C101")
|
|
.body_includes("node=pve1");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":null}"#);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
|
manager
|
|
.create_backup_job(
|
|
"conn",
|
|
BackupJobConfig {
|
|
id: None,
|
|
storage: "backup".to_string(),
|
|
schedule: "0 2 * * *".to_string(),
|
|
mode: "snapshot".to_string(),
|
|
compression: "zstd".to_string(),
|
|
all: true,
|
|
vmid: Some("100,101".to_string()),
|
|
enabled: true,
|
|
node: Some("pve1".to_string()),
|
|
},
|
|
)
|
|
.await
|
|
.expect("backup job should be created");
|
|
mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_backup_job_deletes_path() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!job-delete-token";
|
|
let mock = server.mock(|when, then| {
|
|
when.method(DELETE).path("/api2/json/cluster/backup/job-id");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":null}"#);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
|
manager
|
|
.delete_backup_job("conn", "job-id")
|
|
.await
|
|
.expect("backup job should be deleted");
|
|
mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_backups_filters_and_maps() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!backups-token";
|
|
let mock = 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(
|
|
serde_json::json!({
|
|
"data": [
|
|
{"volid": "local: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": "local",
|
|
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"},
|
|
{"volid": "local:iso/debian-12.iso", "content": "iso",
|
|
"ctime": 1700000002},
|
|
{"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl",
|
|
"ctime": 1700000003}
|
|
]
|
|
})
|
|
.to_string(),
|
|
);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
|
let backups = manager
|
|
.get_backups("conn", Some("local"))
|
|
.await
|
|
.expect("backups should be fetched");
|
|
|
|
assert_eq!(backups.len(), 1);
|
|
assert_eq!(
|
|
backups[0].volid,
|
|
"local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
|
);
|
|
assert_eq!(
|
|
backups[0].backupid,
|
|
"vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
|
);
|
|
assert_eq!(backups[0].backup_type, "qemu");
|
|
assert_eq!(backups[0].backup_id, "100");
|
|
assert_eq!(backups[0].backup_time, 1700000000);
|
|
assert_eq!(backups[0].storage, "local");
|
|
assert_eq!(backups[0].size, 1073741824u64);
|
|
assert_eq!(backups[0].ctime, 1700000001);
|
|
mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_backups_aggregates_over_single_backup_storage() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!backups-default-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": "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");
|
|
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 fetched");
|
|
|
|
assert!(backups.is_empty());
|
|
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]
|
|
async fn delete_backup_url_encodes_volid() {
|
|
let server = MockServer::start();
|
|
let token = "root@pam!backup-delete-token";
|
|
let mock = server.mock(|when, then| {
|
|
when.method(DELETE).path(
|
|
"/api2/json/nodes/pve1/storage/local/content/local%3Abackup%2Fvzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
|
);
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":null}"#);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
|
manager
|
|
.delete_backup(
|
|
"conn",
|
|
"local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
|
)
|
|
.await
|
|
.expect("backup should be deleted");
|
|
mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_vm_type_errors() {
|
|
let server = MockServer::start();
|
|
let probe = server.mock(|when, then| {
|
|
when.method(GET)
|
|
.path("/api2/json/nodes/pve1/kvm/100/snapshot");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(r#"{"data":[]}"#);
|
|
});
|
|
|
|
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
|
|
|
|
let error = manager
|
|
.get_snapshots("conn", "pve1", 100, "kvm")
|
|
.await
|
|
.expect_err("invalid vm type must be rejected");
|
|
assert!(
|
|
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
|
|
"expected InvalidUrl mentioning 'kvm', got: {}",
|
|
error
|
|
);
|
|
|
|
let error = manager
|
|
.migrate_vm("conn", "pve1", 100, "kvm", "pve2", true)
|
|
.await
|
|
.expect_err("invalid vm type must be rejected");
|
|
assert!(
|
|
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
|
|
"expected InvalidUrl mentioning 'kvm', got: {}",
|
|
error
|
|
);
|
|
|
|
assert_eq!(
|
|
probe.calls(),
|
|
0,
|
|
"no HTTP request should be made for an invalid vm type"
|
|
);
|
|
}
|