Skip to content

Commit 665a280

Browse files
author
Paul C
committed
v25.0.2: AstroMando cluster-networking batch — WolfRun Docker VIP backend, bind-all LAN endpoint, reimage identity reconcile
#4 WolfRun Docker VIP dead off-orchestrator: deploy_docker's remote branch recorded wolfnet_ip:None and omitted it from the create payload, so the VIP load-balancer's DNAT backend list was empty. Now allocates a WolfNet IP, injects it into the create payload and records it on the instance (mirrors the local + LXC cross-node paths). add_instance persists to services.json before the next replica deploys, so sequential replicas can't collide. #1 wolfnet-sync wrote a bind-all (0.0.0.0) node's WAN IP as its endpoint on a single-NAT LAN, poisoning effective_site()/pick_wolfnet_endpoint() and silently breaking the mesh. Now prefers the detected LAN IP — but ONLY when it shares a /24 with another cluster node, so a genuine multi-DC cluster keeps the public IP and never trips the behind-NAT endpoint guard. #2 (peer blocks written with no endpoint) was a downstream symptom of #1 on this topology and is resolved by it. #3 reimaged node returning at a new address left its pre-reimage registry entry orphaned (offline, still holding its WolfRun instances) — WolfRun then reported workloads running on a dead id. Join-time reconciliation: add_node now reads the returning node's self_id+hostname from the StatusReport envelope and collapses a matching OFFLINE same-cluster stale entry (by self_id, or hostname for the reimage case), remapping its WolfRun instances onto the live id and tombstoning the orphan. Conservative — never touches a live entry, so distinct nodes can't be merged; warns when more than one stale entry matches. Unit test for the identity-match predicate; deliberately leaves the (pre-existing, dormant) wolfnet_ips conflict check untouched — activating it would run before reconciliation and reject the rejoin it's meant to support.
1 parent 94e34f1 commit 665a280

4 files changed

Lines changed: 228 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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 = "25.0.1"
3+
version = "25.0.2"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 184 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4123,6 +4123,11 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
41234123
let cluster_secret = crate::auth::load_cluster_secret();
41244124
let default_secret = crate::auth::default_cluster_secret();
41254125
let mut remote_wolfnet_ips: Vec<String> = Vec::new();
4126+
// Also capture the joining node's own identity (its self_id `ws-…` and
4127+
// hostname) so we can reconcile a reimaged node against any stale
4128+
// pre-reimage registry entry once it's added (see reconciliation below).
4129+
let mut remote_self_id = String::new();
4130+
let mut remote_hostname = String::new();
41264131
for url in &status_urls {
41274132
// Try the active cluster secret first, then the default (new node may not have received custom secret yet)
41284133
for secret in [&cluster_secret, &default_secret.to_string()] {
@@ -4133,11 +4138,37 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
41334138
{
41344139
if resp.status().is_success() {
41354140
if let Ok(data) = resp.json::<serde_json::Value>().await {
4141+
// NOTE: `wolfnet_ips` is read from the TOP level on
4142+
// purpose — `/api/agent/status` actually returns an
4143+
// externally-tagged AgentMessage ({"StatusReport": {…}}),
4144+
// so this lookup has always returned None and the
4145+
// wolfnet-IP conflict check below has been dormant since
4146+
// it was written. We deliberately leave that as-is here:
4147+
// activating it would run BEFORE the reimage
4148+
// reconciliation and could reject a returning node whose
4149+
// static WolfNet IP still matches its own stale entry's
4150+
// cached route — breaking the exact rejoin workflow #3
4151+
// supports. (Flagged separately; needs its own fix that
4152+
// excludes the returning node's own IPs.)
41364153
if let Some(ips) = data.get("wolfnet_ips").and_then(|v| v.as_array()) {
41374154
remote_wolfnet_ips = ips.iter()
41384155
.filter_map(|v| v.as_str().map(|s| s.to_string()))
41394156
.collect();
41404157
}
4158+
// For identity reconciliation we DO need the real
4159+
// values, so reach into the StatusReport envelope (with
4160+
// a raw-object fallback should the format ever change).
4161+
let sr = data.get("StatusReport").unwrap_or(&data);
4162+
if remote_self_id.is_empty()
4163+
&& let Some(s) = sr.get("node_id").and_then(|v| v.as_str())
4164+
{
4165+
remote_self_id = s.to_string();
4166+
}
4167+
if remote_hostname.is_empty()
4168+
&& let Some(h) = sr.get("hostname").and_then(|v| v.as_str())
4169+
{
4170+
remote_hostname = h.to_string();
4171+
}
41414172
}
41424173
break;
41434174
}
@@ -4185,6 +4216,46 @@ pub async fn add_node(req: HttpRequest, state: web::Data<AppState>, body: web::J
41854216

41864217
let id = state.cluster.add_server(body.address.clone(), port, cluster_name.clone());
41874218

4219+
// ── Join-time identity reconciliation (AstroMando #3, 2026-06-27) ──
4220+
// A reimaged node returns with a NEW self_id (ws-…). The duplicate-block
4221+
// above only rejects a SAME address:port re-add, so an operator re-adding
4222+
// the node at its new address leaves the pre-reimage entry orphaned:
4223+
// offline forever, yet still carrying this node's WolfRun instances.
4224+
// WolfRun would then report those workloads "running" on a node that no
4225+
// longer exists and schedule replacements against a dead id. Collapse any
4226+
// such stale entry into the one we just added.
4227+
//
4228+
// Conservative match — a candidate must be OFFLINE, in the SAME cluster,
4229+
// not the entry we just created, and the same physical node either by
4230+
// self_id (an unchanged identity that merely moved address) OR by hostname
4231+
// (the reimage case, where the self_id changed). A live entry is never
4232+
// touched, so two genuinely-distinct online nodes can't be merged here.
4233+
if !remote_hostname.is_empty() || !remote_self_id.is_empty() {
4234+
let new_cluster = cluster_name.clone()
4235+
.or_else(|| state.cluster.get_node(&id).and_then(|n| n.cluster_name))
4236+
.unwrap_or_else(|| "WolfStack".to_string());
4237+
let stale: Vec<crate::agent::Node> = state.cluster.get_all_nodes()
4238+
.into_iter()
4239+
.filter(|n| n.id != id && !n.is_self && !n.online)
4240+
.filter(|n| node_is_returning_identity(n, &remote_self_id, &remote_hostname, &new_cluster))
4241+
.collect();
4242+
if stale.len() > 1 {
4243+
// More than one stale match is a registry anomaly (e.g. two
4244+
// offline entries sharing this hostname) — collapse them all but
4245+
// make it visible so an operator can investigate a mis-set name.
4246+
tracing::warn!(target: "add_node",
4247+
"Returning node '{}' matched {} stale registry entries — collapsing all into {}",
4248+
remote_hostname, stale.len(), id);
4249+
}
4250+
for old in &stale {
4251+
let moved = state.wolfrun.remap_node_id(&old.id, &id);
4252+
state.cluster.remove_server(&old.id);
4253+
tracing::info!(target: "add_node",
4254+
"Reconciled returning node '{}' ({} → {}): migrated {} WolfRun instance(s), removed stale offline entry",
4255+
remote_hostname, old.id, id, moved);
4256+
}
4257+
}
4258+
41884259
// Push our cluster secret to the new node — UNCONDITIONALLY, even
41894260
// when we're still on the built-in default. The "always push"
41904261
// semantic closes the Stage-2 new-cluster-formation corner case:
@@ -4882,6 +4953,21 @@ fn hostnames_same_node(a: &str, b: &str) -> bool {
48824953
a_short == b_short && (a.contains('.') || b.contains('.'))
48834954
}
48844955

4956+
/// Does registry entry `n` look like the same physical node as one that just
4957+
/// (re)joined — for join-time identity reconciliation (AstroMando #3)? Matches
4958+
/// within the same cluster on EITHER an unchanged self_id (the node merely
4959+
/// moved address) OR the hostname (the reimage case, where the self_id is
4960+
/// regenerated). This is purely the identity match; the caller still applies
4961+
/// the offline / not-self / not-the-new-entry guards before collapsing.
4962+
fn node_is_returning_identity(n: &crate::agent::Node, self_id: &str, hostname: &str, cluster: &str) -> bool {
4963+
let same_cluster = n.cluster_name.as_deref().unwrap_or("WolfStack")
4964+
.eq_ignore_ascii_case(cluster);
4965+
if !same_cluster { return false; }
4966+
let sid_match = !self_id.is_empty() && n.self_id.as_deref() == Some(self_id);
4967+
let host_match = !hostname.is_empty() && hostnames_same_node(&n.hostname, hostname);
4968+
sid_match || host_match
4969+
}
4970+
48854971
pub async fn wolfnet_sync_cluster(req: HttpRequest, state: web::Data<AppState>, body: web::Json<WolfNetSyncRequest>) -> HttpResponse {
48864972
if let Err(resp) = require_auth(&req, &state) { return resp; }
48874973

@@ -4994,6 +5080,28 @@ pub async fn wolfnet_sync_cluster(req: HttpRequest, state: web::Data<AppState>,
49945080
let mut infos: Vec<NodeWnInfo> = Vec::new();
49955081
let mut errors: Vec<String> = Vec::new();
49965082

5083+
// Same-LAN guard for the bind-all (0.0.0.0) address resolution below.
5084+
// The RFC1918 /24 of every cluster node that already has a real address.
5085+
// A node bound to 0.0.0.0 only substitutes its detected LAN IP for its
5086+
// public IP when that LAN IP shares a /24 with another cluster node —
5087+
// i.e. this genuinely is a single-LAN cluster (AstroMando's topology).
5088+
// On a real multi-DC cluster no peer shares the bind-all node's private
5089+
// /24, so we leave the public-IP value untouched and never trip the
5090+
// behind-NAT endpoint guard for an inter-site link.
5091+
let lan_prefix = |addr: &str| -> Option<String> {
5092+
let ip: std::net::Ipv4Addr = addr.parse().ok()?;
5093+
let o = ip.octets();
5094+
let rfc1918 = o[0] == 10
5095+
|| (o[0] == 172 && (16..=31).contains(&o[1]))
5096+
|| (o[0] == 192 && o[1] == 168);
5097+
if !rfc1918 { return None; }
5098+
Some(format!("{}.{}.{}", o[0], o[1], o[2]))
5099+
};
5100+
let cluster_lan_prefixes: std::collections::HashSet<String> = node_ids.iter()
5101+
.filter_map(|nid| state.cluster.get_node(nid))
5102+
.filter_map(|n| lan_prefix(&n.address))
5103+
.collect();
5104+
49975105
// Scoped strictly to the cluster passed in `node_ids` — other clusters
49985106
// are never examined or touched.
49995107
for nid in node_ids {
@@ -5018,12 +5126,30 @@ pub async fn wolfnet_sync_cluster(req: HttpRequest, state: web::Data<AppState>,
50185126
errors.push(format!("{}: WolfNet not configured", node.hostname));
50195127
continue;
50205128
}
5021-
// If WolfStack is bound to 0.0.0.0 / 127.0.0.1, fall
5022-
// back to public_ip then LAN-detected IP so the
5023-
// address we record for this node is actually dialable.
5129+
// If WolfStack is bound to 0.0.0.0 / 127.0.0.1 we must
5130+
// resolve a real dialable address. On the common
5131+
// single-NAT topology (every node behind one public IP)
5132+
// the WAN address isn't reachable peer-to-peer, and
5133+
// recording it here poisons effective_site() +
5134+
// pick_wolfnet_endpoint() — the bind-all node's auto-site
5135+
// stops matching its LAN peers' /24, so the mesh falls to
5136+
// the (un-dialable) public path and the sync silently
5137+
// breaks WolfNet to/from that node (AstroMando,
5138+
// 2026-06-27). So prefer the detected LAN IP — but ONLY
5139+
// when it shares a /24 with another cluster node, i.e.
5140+
// this really is a single-LAN cluster. On a multi-DC
5141+
// cluster the detected private IP would differ from the
5142+
// node's public IP and trip the behind-NAT endpoint guard
5143+
// on peers, wiping a working inter-site link — so there we
5144+
// keep the public IP exactly as before. detect_lan_ip()
5145+
// only ever returns a PRIVATE address, so a genuinely
5146+
// public-only node yields None and falls back to
5147+
// public_ip regardless.
50245148
let effective_addr = if node.address == "0.0.0.0" || node.address == "127.0.0.1" {
5025-
node.public_ip.clone()
5026-
.or_else(|| networking::detect_lan_ip())
5149+
networking::detect_lan_ip()
5150+
.filter(|lan| lan_prefix(lan)
5151+
.is_some_and(|p| cluster_lan_prefixes.contains(&p)))
5152+
.or_else(|| node.public_ip.clone())
50275153
.unwrap_or_else(|| node.address.clone())
50285154
} else {
50295155
node.address.clone()
@@ -39141,6 +39267,59 @@ mod external_url_tests {
3914139267
assert!(!hostnames_same_node("", "immich"));
3914239268
assert!(!hostnames_same_node("immich", ""));
3914339269
}
39270+
39271+
#[test]
39272+
fn returning_identity_matches_reimage_and_address_move_but_not_distinct_nodes() {
39273+
use super::node_is_returning_identity;
39274+
// Build a registry Node from a small JSON object — most fields default.
39275+
fn node(over: serde_json::Value) -> crate::agent::Node {
39276+
let mut base = serde_json::json!({
39277+
"id": "node-old", "hostname": "ws-host", "address": "10.0.0.9",
39278+
"port": 8553, "last_seen": 0, "metrics": null,
39279+
"components": [], "online": false, "is_self": false
39280+
});
39281+
for (k, v) in over.as_object().unwrap() { base[k] = v.clone(); }
39282+
serde_json::from_value(base).unwrap()
39283+
}
39284+
39285+
// Reimage case: self_id regenerated (ws-new), hostname unchanged → match
39286+
// on hostname.
39287+
let stale = node(serde_json::json!({
39288+
"hostname": "ws-host", "self_id": "ws-old", "cluster_name": "WolfStack"
39289+
}));
39290+
assert!(node_is_returning_identity(&stale, "ws-new", "ws-host", "WolfStack"));
39291+
39292+
// Address-move case: same self_id, different hostname spelling → match
39293+
// on self_id.
39294+
let moved = node(serde_json::json!({
39295+
"hostname": "old-name", "self_id": "ws-keep", "cluster_name": "WolfStack"
39296+
}));
39297+
assert!(node_is_returning_identity(&moved, "ws-keep", "totally-different", "WolfStack"));
39298+
39299+
// Distinct node, different hostname AND different self_id → NO match,
39300+
// even in the same cluster (this is the merge we must never make).
39301+
let other = node(serde_json::json!({
39302+
"hostname": "ws-other", "self_id": "ws-other-id", "cluster_name": "WolfStack"
39303+
}));
39304+
assert!(!node_is_returning_identity(&other, "ws-new", "ws-host", "WolfStack"));
39305+
39306+
// Same hostname but a DIFFERENT cluster → never collapsed across clusters.
39307+
let cross = node(serde_json::json!({
39308+
"hostname": "ws-host", "self_id": "ws-x", "cluster_name": "OtherCluster"
39309+
}));
39310+
assert!(!node_is_returning_identity(&cross, "ws-new", "ws-host", "WolfStack"));
39311+
39312+
// Cluster comparison is case-insensitive (matches every other
39313+
// cluster-name comparison in the codebase).
39314+
let cased = node(serde_json::json!({
39315+
"hostname": "ws-host", "self_id": "ws-old", "cluster_name": "wolfstack"
39316+
}));
39317+
assert!(node_is_returning_identity(&cased, "ws-new", "ws-host", "WolfStack"));
39318+
39319+
// Empty inbound identity never matches (a failed status fetch must not
39320+
// collapse anything).
39321+
assert!(!node_is_returning_identity(&stale, "", "", "WolfStack"));
39322+
}
3914439323
}
3914539324

3914639325
#[cfg(test)]

src/wolfrun/mod.rs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,32 @@ impl WolfRunState {
319319
}
320320
}
321321

322+
/// Re-point every service instance recorded against `old_node_id` onto
323+
/// `new_node_id`, returning how many were moved. Used by join-time
324+
/// identity reconciliation: when a reimaged node returns under a new
325+
/// registry id, the instances it was running were recorded against its
326+
/// pre-reimage id. Without remapping, WolfRun would keep reporting those
327+
/// workloads "running" on a node that no longer exists and schedule
328+
/// replacements against a dead id (AstroMando #3, 2026-06-27).
329+
pub fn remap_node_id(&self, old_node_id: &str, new_node_id: &str) -> usize {
330+
if old_node_id == new_node_id || old_node_id.is_empty() { return 0; }
331+
let mut svcs = self.services.write().unwrap();
332+
let mut moved = 0usize;
333+
for svc in svcs.iter_mut() {
334+
for inst in svc.instances.iter_mut() {
335+
if inst.node_id == old_node_id {
336+
inst.node_id = new_node_id.to_string();
337+
moved += 1;
338+
}
339+
}
340+
}
341+
drop(svcs);
342+
if moved > 0 {
343+
self.save();
344+
}
345+
moved
346+
}
347+
322348
/// Rename all cluster references from old_name to new_name.
323349
pub fn rename_cluster(&self, old_name: &str, new_name: &str) -> usize {
324350
let mut svcs = self.services.write().unwrap();
@@ -1462,7 +1488,7 @@ async fn deploy_docker(
14621488
wolfrun: &WolfRunState,
14631489
node_id: &str,
14641490
) {
1465-
let payload = serde_json::json!({
1491+
let mut payload = serde_json::json!({
14661492
"name": container_name,
14671493
"image": service.image,
14681494
"ports": service.ports,
@@ -1491,6 +1517,20 @@ async fn deploy_docker(
14911517
Err(e) => warn!("WolfRun: failed to deploy {} locally: {}", container_name, e),
14921518
}
14931519
} else {
1520+
// Allocate a WolfNet IP for the remote container, mirroring the
1521+
// node.is_self branch above and the LXC cross-node path. The
1522+
// orchestrator has the global view (services.json + local state via
1523+
// wolfnet_used_ip_set), and add_instance persists each allocation to
1524+
// services.json before the next replica's deploy_docker runs, so
1525+
// sequential replicas never collide. Without this the VIP
1526+
// load-balancer's backend list (built from instance.wolfnet_ip) stays
1527+
// empty for any service scheduled off the orchestrator and the VIP
1528+
// routes to nothing (AstroMando, 2026-06-27).
1529+
let wolfnet_ip = crate::containers::next_available_wolfnet_ip();
1530+
if let Some(ref ip) = wolfnet_ip {
1531+
payload["wolfnet_ip"] = serde_json::json!(ip);
1532+
}
1533+
14941534
// Pull image on remote node
14951535
let pull_urls = crate::api::build_node_urls(&node.address, node.port, "/api/containers/docker/pull");
14961536
let pull_payload = serde_json::json!({ "image": service.image });
@@ -1548,7 +1588,7 @@ async fn deploy_docker(
15481588
wolfrun.add_instance(&service.id, ServiceInstance {
15491589
node_id: node_id.to_string(),
15501590
container_name: container_name.to_string(),
1551-
wolfnet_ip: None,
1591+
wolfnet_ip,
15521592
status: "running".to_string(),
15531593
last_seen: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(),
15541594
standby: false,

0 commit comments

Comments
 (0)