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.
291 lines
9.4 KiB
Rust
291 lines
9.4 KiB
Rust
//! Integration tests for cluster node auto-discovery.
|
|
//!
|
|
//! `discover_nodes` combines `/nodes` (the node list and statuses) with
|
|
//! `/cluster/status` (cluster IPs, local flags, and cluster identity) and
|
|
//! derives an endpoint URL per node from the connection's primary URL. These
|
|
//! tests cover the URL derivation rules, primary-node marking, the config
|
|
//! mutation, and the persist -> load round-trip.
|
|
|
|
use httpmock::prelude::*;
|
|
use httpmock::Mock;
|
|
use clustri::{derive_node_url, ConnectionConfig, ConnectionManager, EndpointConfig};
|
|
|
|
const TOKEN: &str = "root@pam!discovery-token";
|
|
|
|
/// A minimal `/nodes` entry. The `Node` struct requires every field, so all
|
|
/// are included.
|
|
fn node_json(name: &str, status: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"node": name,
|
|
"status": status,
|
|
"cpu": 0.0,
|
|
"maxcpu": 8,
|
|
"mem": 0,
|
|
"maxmem": 34359738368u64,
|
|
"disk": 0,
|
|
"maxdisk": 536870912000u64,
|
|
"uptime": 0,
|
|
"level": "",
|
|
"id": format!("node/{}", name),
|
|
"type": "node"
|
|
})
|
|
}
|
|
|
|
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
|
/// primary endpoint is the mock server. `node` pins `primary.node`.
|
|
async fn setup_manager(
|
|
server: &MockServer,
|
|
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: server.base_url(),
|
|
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)
|
|
}
|
|
|
|
/// Stubs `GET /api2/json/nodes` returning `nodes` (entries from `node_json`).
|
|
fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> {
|
|
server.mock(|when, then| {
|
|
when.method(GET).path("/api2/json/nodes");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(serde_json::json!({ "data": nodes }).to_string());
|
|
})
|
|
}
|
|
|
|
/// Builds the `/cluster/status` response body: one `cluster` entry followed by
|
|
/// the given node entries.
|
|
fn cluster_status_body(
|
|
cluster_id: &str,
|
|
cluster_name: &str,
|
|
nodes: &[serde_json::Value],
|
|
) -> String {
|
|
let mut entries = Vec::with_capacity(nodes.len() + 1);
|
|
entries.push(serde_json::json!({
|
|
"type": "cluster",
|
|
"id": cluster_id,
|
|
"name": cluster_name,
|
|
"nodes": nodes.len()
|
|
}));
|
|
entries.extend_from_slice(nodes);
|
|
serde_json::json!({ "data": entries }).to_string()
|
|
}
|
|
|
|
/// Stubs `GET /api2/json/cluster/status` returning the cluster entry plus the
|
|
/// given node entries.
|
|
fn stub_cluster_status<'a>(
|
|
server: &'a MockServer,
|
|
cluster_id: &str,
|
|
cluster_name: &str,
|
|
nodes: &[serde_json::Value],
|
|
) -> Mock<'a> {
|
|
server.mock(|when, then| {
|
|
when.method(GET).path("/api2/json/cluster/status");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(cluster_status_body(cluster_id, cluster_name, nodes));
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn derive_node_url_keeps_scheme_and_port() {
|
|
assert_eq!(
|
|
derive_node_url("https://10.0.0.5:8006", Some("10.0.0.6"), "pve1"),
|
|
"https://10.0.0.6:8006"
|
|
);
|
|
// An empty IP falls back to the node name.
|
|
assert_eq!(
|
|
derive_node_url("http://pve.lan:8006", Some(""), "pve.lan"),
|
|
"http://pve.lan:8006"
|
|
);
|
|
// A missing port defaults to pveproxy's 8006 for both schemes.
|
|
assert_eq!(
|
|
derive_node_url("https://pve.lan", Some("10.0.0.7"), "pve1"),
|
|
"https://10.0.0.7:8006"
|
|
);
|
|
// An unparseable primary URL falls back to a best-effort https URL.
|
|
assert_eq!(
|
|
derive_node_url("not a url", Some("10.0.0.8"), "pve1"),
|
|
"https://10.0.0.8:8006"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_nodes_builds_and_marks_primary() {
|
|
let server = MockServer::start();
|
|
let port = server.port();
|
|
|
|
let nodes_mock = stub_nodes(
|
|
&server,
|
|
&[node_json("pve1", "online"), node_json("pve2", "online")],
|
|
);
|
|
let cluster_mock = stub_cluster_status(
|
|
&server,
|
|
"cluster/lab",
|
|
"lab",
|
|
&[
|
|
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
|
"online": 1, "local": 1, "ip": "10.0.0.5"}),
|
|
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
|
|
"online": 1, "local": 0, "ip": "10.0.0.6"}),
|
|
],
|
|
);
|
|
|
|
let (mut manager, _dir) = setup_manager(&server, Some("pve1")).await;
|
|
let discovered = manager
|
|
.discover_nodes("conn")
|
|
.await
|
|
.expect("discovery should succeed");
|
|
|
|
assert_eq!(discovered.len(), 2);
|
|
assert_eq!(
|
|
discovered[0].name, "pve1",
|
|
"the primary node must sort first"
|
|
);
|
|
assert!(discovered[0].is_primary);
|
|
assert!(discovered[0].local);
|
|
assert_eq!(discovered[0].status, "online");
|
|
assert_eq!(
|
|
discovered[0].url,
|
|
format!("http://10.0.0.5:{}", port),
|
|
"pve1's URL keeps the scheme and port but uses the cluster IP"
|
|
);
|
|
assert_eq!(discovered[1].name, "pve2");
|
|
assert!(!discovered[1].is_primary);
|
|
assert!(!discovered[1].local);
|
|
assert_eq!(discovered[1].url, format!("http://10.0.0.6:{}", port));
|
|
|
|
let config = manager
|
|
.connection_config("conn")
|
|
.expect("config should be readable");
|
|
assert_eq!(config.cluster_name.as_deref(), Some("lab"));
|
|
assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab"));
|
|
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
|
|
assert_eq!(
|
|
config.nodes, discovered,
|
|
"the discovered list must be stored"
|
|
);
|
|
|
|
nodes_mock.assert();
|
|
cluster_mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_nodes_persists_and_round_trips() {
|
|
let server = MockServer::start();
|
|
|
|
let nodes_mock = stub_nodes(
|
|
&server,
|
|
&[node_json("pve1", "online"), node_json("pve2", "offline")],
|
|
);
|
|
let cluster_mock = stub_cluster_status(
|
|
&server,
|
|
"cluster/lab",
|
|
"lab",
|
|
&[
|
|
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
|
"online": 1, "local": 1, "ip": "10.0.0.5"}),
|
|
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
|
|
"online": 0, "local": 0, "ip": "10.0.0.6"}),
|
|
],
|
|
);
|
|
|
|
let (mut manager, dir) = setup_manager(&server, Some("pve1")).await;
|
|
let path = dir.path().join("connections.json");
|
|
let discovered = manager
|
|
.discover_nodes("conn")
|
|
.await
|
|
.expect("discovery should succeed");
|
|
|
|
// discover_nodes stores on the config but does not persist by itself; any
|
|
// later persist (here via set_active_connection) writes the new fields.
|
|
manager
|
|
.set_active_connection("conn".to_string(), &path)
|
|
.await
|
|
.expect("active connection should be set");
|
|
|
|
let mut reloaded = ConnectionManager::new();
|
|
let result = reloaded
|
|
.load_connections(&path)
|
|
.await
|
|
.expect("connections should load");
|
|
assert_eq!(result.connections.len(), 1);
|
|
let loaded = &result.connections[0];
|
|
assert_eq!(loaded.nodes, discovered, "discovered nodes must round-trip");
|
|
assert_eq!(loaded.cluster_id.as_deref(), Some("cluster/lab"));
|
|
assert_eq!(loaded.cluster_name.as_deref(), Some("lab"));
|
|
|
|
nodes_mock.assert();
|
|
cluster_mock.assert();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_nodes_sets_primary_node_from_url_match() {
|
|
let server = MockServer::start();
|
|
// The mock server's host is used as the node's cluster IP so the derived
|
|
// URL equals the primary URL.
|
|
let host = server.host();
|
|
|
|
let nodes_mock = stub_nodes(&server, &[node_json("pve1", "online")]);
|
|
let cluster_mock = stub_cluster_status(
|
|
&server,
|
|
"cluster/lab",
|
|
"lab",
|
|
&[
|
|
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
|
"online": 1, "local": 1, "ip": host}),
|
|
],
|
|
);
|
|
|
|
let (mut manager, _dir) = setup_manager(&server, None).await;
|
|
let discovered = manager
|
|
.discover_nodes("conn")
|
|
.await
|
|
.expect("discovery should succeed");
|
|
|
|
assert_eq!(discovered.len(), 1);
|
|
assert_eq!(
|
|
discovered[0].url,
|
|
server.base_url(),
|
|
"the derived URL must equal the primary URL"
|
|
);
|
|
assert!(
|
|
discovered[0].is_primary,
|
|
"a node whose URL matches the primary URL must be marked primary"
|
|
);
|
|
|
|
// With primary.node unset, discovery pins it to the primary node.
|
|
let config = manager
|
|
.connection_config("conn")
|
|
.expect("config should be readable");
|
|
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
|
|
|
|
nodes_mock.assert();
|
|
cluster_mock.assert();
|
|
}
|