Skip to content

Commit bfd0ed4

Browse files
author
Paul C
committed
v20.8.0: Stable cluster_id — WolfRouter survives renames
Introduces a stable sha256-based cluster identifier so features that must live-match nodes against a chosen cluster (WolfRouter topology + preflight) stop breaking when cluster_name is renamed or inconsistent across peers. cluster_name becomes a pure display label; cluster_id is the filter key. Bootstrap is coordination-free: every existing node upgrading in place hashes its current cluster_name, producing the same ID independently — no UUID race, no leader election. First boot priority: in-memory → /etc/wolfstack/self_cluster_id.json → adopt from any online peer with a matching cluster_name → sha256(cluster_name) fallback. Backward compatible both directions: - AgentMessage.cluster_id is #[serde(default)] so old and new peers interoperate transparently during rollout. - Router filters accept both ?cluster_id= (preferred) and ?cluster= (legacy); per-peer matching prefers cluster_id, falls back to cluster_name when the peer hasn't upgraded yet. - Existing nodes.json deserialises with cluster_id = None. Moving a node between clusters is now a distinct first-class action: - New POST /api/nodes/{id}/move-to-cluster writes both cluster_id and cluster_name on a single node (unlike the rename cascade triggered by PATCH /settings when cluster_name changes). - Remote-target moves fan out to the target's own /api/agent/move-self-to-cluster so the target writes its canonical state — without this the self-gossip guard would revert the change on the admin node's next poll. - Node settings modal replaces freeform cluster-name input with a display + picker ("Move to different cluster…") that lists existing clusters by cluster_id and derives a fresh id via SubtleCrypto SHA-256 when creating a new one, matching the backend byte-for-byte. Other fixes wrapped in: - Self-gossip adoption of cluster_name is now gated on cluster_id agreement — a peer with a stale view of us can no longer revert our state by gossiping back our old cluster_name. - Add-server form: removed the implicit "WolfStack" default on an empty cluster field so new nodes adopt via gossip; join-token panel now stays visible in both WolfStack and Proxmox modes.
1 parent 1450224 commit bfd0ed4

10 files changed

Lines changed: 677 additions & 90 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "20.7.2"
3+
version = "20.8.0"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/agent/mod.rs

Lines changed: 212 additions & 14 deletions
Large diffs are not rendered by default.

src/api/mod.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,6 +1726,115 @@ pub async fn update_node_settings(req: HttpRequest, state: web::Data<AppState>,
17261726
}
17271727
}
17281728

1729+
/// POST /api/nodes/{id}/move-to-cluster — move a single node into a
1730+
/// different WolfStack cluster. Takes the TARGET cluster's stable
1731+
/// `cluster_id` and its display `cluster_name`. Unlike the PATCH
1732+
/// /settings endpoint (which, when cluster_name changes, cascades a
1733+
/// rename across every node with the old name), this touches exactly
1734+
/// one node. That's the intent distinction: renaming a cluster keeps
1735+
/// all its members together under a new label; moving a node changes
1736+
/// which cluster it belongs to.
1737+
#[derive(Deserialize)]
1738+
pub struct MoveNodeToClusterRequest {
1739+
pub cluster_id: String,
1740+
pub cluster_name: String,
1741+
}
1742+
1743+
pub async fn move_node_to_cluster(
1744+
req: HttpRequest,
1745+
state: web::Data<AppState>,
1746+
path: web::Path<String>,
1747+
body: web::Json<MoveNodeToClusterRequest>,
1748+
) -> HttpResponse {
1749+
if let Err(resp) = require_auth(&req, &state) { return resp; }
1750+
let id = path.into_inner();
1751+
if body.cluster_id.trim().is_empty() || body.cluster_name.trim().is_empty() {
1752+
return HttpResponse::BadRequest().json(serde_json::json!({
1753+
"error": "cluster_id and cluster_name are both required"
1754+
}));
1755+
}
1756+
let cid = body.cluster_id.trim().to_string();
1757+
let cname = body.cluster_name.trim().to_string();
1758+
1759+
// 1) Update our local view immediately so the admin's sidebar
1760+
// reflects the move on the very next fetchNodes — no waiting on
1761+
// gossip convergence for UI feedback.
1762+
let node = state.cluster.get_node(&id);
1763+
if !state.cluster.move_node_to_cluster(&id, &cid, &cname) {
1764+
return HttpResponse::NotFound().json(serde_json::json!({ "error": "Node not found" }));
1765+
}
1766+
1767+
// 2) If we're moving a REMOTE WolfStack node, forward the move to
1768+
// its own backend. Without this, the remote's self-gossip guard
1769+
// (which now refuses to adopt cluster_name from a peer whose
1770+
// cluster_id disagrees with ours) would reject our gossip-based
1771+
// claim that it's moved, and its next poll would revert our
1772+
// local record to the stale data. Having the target write its
1773+
// own state closes that loop cleanly.
1774+
if let Some(node) = node {
1775+
if !node.is_self && node.node_type == "wolfstack" {
1776+
let secret = state.cluster_secret.clone();
1777+
let address = node.address.clone();
1778+
let port = node.port;
1779+
let cid2 = cid.clone();
1780+
let cname2 = cname.clone();
1781+
tokio::spawn(async move {
1782+
let client = &*API_HTTP_CLIENT;
1783+
let urls = build_node_urls(&address, port, "/api/agent/move-self-to-cluster");
1784+
let payload = serde_json::json!({ "cluster_id": cid2, "cluster_name": cname2 });
1785+
for url in &urls {
1786+
if let Ok(resp) = client.post(url)
1787+
.timeout(std::time::Duration::from_secs(5))
1788+
.header("X-WolfStack-Secret", &secret)
1789+
.json(&payload)
1790+
.send()
1791+
.await
1792+
{
1793+
let _ = resp.bytes().await;
1794+
break;
1795+
}
1796+
}
1797+
});
1798+
}
1799+
}
1800+
1801+
HttpResponse::Ok().json(serde_json::json!({
1802+
"moved": true,
1803+
"cluster_id": cid,
1804+
"cluster_name": cname,
1805+
}))
1806+
}
1807+
1808+
/// POST /api/agent/move-self-to-cluster — inter-node call that tells
1809+
/// the receiving node to move ITSELF to a different cluster. Authenticated
1810+
/// via cluster_secret (not session cookie), so only peers with the shared
1811+
/// secret can trigger a move. Used by `move_node_to_cluster` when the
1812+
/// target node is remote — the admin's node updates its own record for
1813+
/// instant UI feedback, then fires this at the target so the target
1814+
/// writes its own canonical state and gossip converges.
1815+
pub async fn agent_move_self_to_cluster(
1816+
req: HttpRequest,
1817+
state: web::Data<AppState>,
1818+
body: web::Json<MoveNodeToClusterRequest>,
1819+
) -> HttpResponse {
1820+
if let Err(e) = require_cluster_auth(&req, &state) { return e; }
1821+
if body.cluster_id.trim().is_empty() || body.cluster_name.trim().is_empty() {
1822+
return HttpResponse::BadRequest().json(serde_json::json!({
1823+
"error": "cluster_id and cluster_name are both required"
1824+
}));
1825+
}
1826+
let self_id = state.cluster.self_id.clone();
1827+
if state.cluster.move_node_to_cluster(&self_id, body.cluster_id.trim(), body.cluster_name.trim()) {
1828+
HttpResponse::Ok().json(serde_json::json!({
1829+
"moved": true,
1830+
"cluster_id": body.cluster_id,
1831+
"cluster_name": body.cluster_name,
1832+
}))
1833+
} else {
1834+
HttpResponse::InternalServerError().json(serde_json::json!({ "error": "Move failed" }))
1835+
}
1836+
}
1837+
17291838
/// POST /api/agent/cluster-name — accept cluster name update from admin node
17301839
pub async fn agent_set_cluster_name(req: HttpRequest, state: web::Data<AppState>, body: web::Json<serde_json::Value>) -> HttpResponse {
17311840
if let Err(e) = require_cluster_auth(&req, &state) { return e; }
@@ -2964,6 +3073,7 @@ pub async fn agent_status(req: HttpRequest, state: web::Data<AppState>) -> HttpR
29643073
license_key: if crate::compat::platform_ready() {
29653074
std::fs::read_to_string(crate::compat::dm_path()).ok().map(|s| s.trim().to_string())
29663075
} else { None },
3076+
cluster_id: state.cluster.get_self_cluster_id(),
29673077
};
29683078
HttpResponse::Ok().json(msg)
29693079
}
@@ -5084,6 +5194,7 @@ async fn lxc_remote_clone(
50845194
pve_node_name: None,
50855195
pve_cluster_name: None,
50865196
cluster_name: None,
5197+
cluster_id: None,
50875198
join_verified: false,
50885199
has_docker: false,
50895200
has_lxc: false,
@@ -19926,6 +20037,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1992620037
.route("/api/nodes/{id}", web::get().to(get_node))
1992720038
.route("/api/nodes/{id}", web::delete().to(remove_node))
1992820039
.route("/api/nodes/{id}/settings", web::patch().to(update_node_settings))
20040+
.route("/api/nodes/{id}/move-to-cluster", web::post().to(move_node_to_cluster))
20041+
.route("/api/agent/move-self-to-cluster", web::post().to(agent_move_self_to_cluster))
1992920042
// Proxmox integration
1993020043
.route("/api/nodes/{id}/pve/resources", web::get().to(get_pve_resources))
1993120044
.route("/api/nodes/{id}/pve/test", web::post().to(pve_test_connection))

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,7 @@ async fn main() -> std::io::Result<()> {
577577
license_key: if crate::compat::platform_ready() {
578578
std::fs::read_to_string(crate::compat::dm_path()).ok().map(|s| s.trim().to_string())
579579
} else { None },
580+
cluster_id: cluster_clone.get_self_cluster_id(),
580581
};
581582
if let Ok(json) = serde_json::to_value(&msg) {
582583
if let Ok(mut cache) = cached_status_bg.write() {

0 commit comments

Comments
 (0)