2026-08-09 23:01:51 +00:00
|
|
|
//! 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;
|
2026-08-10 00:00:59 +00:00
|
|
|
use clustri::{derive_node_url, ConnectionConfig, ConnectionManager, EndpointConfig};
|
2026-08-09 23:01:51 +00:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
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();
|
|
|
|
|
}
|