1use std::collections::{HashMap, HashSet};
14use std::path::{Path, PathBuf};
15#[cfg(any(feature = "exochain", all(feature = "mesh", feature = "native")))]
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use chrono::{DateTime, Utc};
20use dashmap::DashMap;
21use serde::{Deserialize, Serialize};
22use tracing::{debug, info, warn};
23
24pub type NodeId = String;
26
27#[non_exhaustive]
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub enum NodePlatform {
31 CloudNative,
33 Edge,
35 Browser,
37 Wasi,
39 Custom(String),
41}
42
43impl std::fmt::Display for NodePlatform {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 NodePlatform::CloudNative => write!(f, "cloud-native"),
47 NodePlatform::Edge => write!(f, "edge"),
48 NodePlatform::Browser => write!(f, "browser"),
49 NodePlatform::Wasi => write!(f, "wasi"),
50 NodePlatform::Custom(name) => write!(f, "custom({name})"),
51 }
52 }
53}
54
55pub const UNVERIFIED_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
58pub const UNVERIFIED_REAP_EVERY: std::time::Duration = std::time::Duration::from_secs(60);
60
61#[non_exhaustive]
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub enum NodeState {
65 Joining,
67 Active,
69 Suspect,
71 Unreachable,
73 Leaving,
75 Left,
77 Unverified,
82}
83
84impl std::fmt::Display for NodeState {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 NodeState::Joining => write!(f, "joining"),
88 NodeState::Active => write!(f, "active"),
89 NodeState::Suspect => write!(f, "suspect"),
90 NodeState::Unreachable => write!(f, "unreachable"),
91 NodeState::Leaving => write!(f, "leaving"),
92 NodeState::Left => write!(f, "left"),
93 NodeState::Unverified => write!(f, "unverified"),
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PeerNode {
101 pub id: NodeId,
103
104 pub name: String,
106
107 pub platform: NodePlatform,
109
110 pub state: NodeState,
112
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub address: Option<String>,
116
117 pub first_seen: DateTime<Utc>,
119
120 pub last_heartbeat: DateTime<Utc>,
122
123 #[serde(default)]
125 pub capabilities: Vec<String>,
126
127 #[serde(default)]
129 pub labels: HashMap<String, String>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ClusterConfig {
135 pub node_id: NodeId,
137
138 #[serde(default = "default_node_name")]
140 pub node_name: String,
141
142 #[serde(default = "default_platform")]
144 pub platform: NodePlatform,
145
146 #[serde(default = "default_heartbeat_interval")]
148 pub heartbeat_interval_secs: u64,
149
150 #[serde(default = "default_suspect_threshold")]
152 pub suspect_threshold: u32,
153
154 #[serde(default = "default_unreachable_threshold")]
156 pub unreachable_threshold: u32,
157
158 #[serde(default)]
160 pub max_nodes: u32,
161
162 #[serde(default)]
164 pub bind_address: Option<String>,
165
166 #[serde(default)]
168 pub seed_peers: Vec<String>,
169
170 #[serde(default)]
172 pub identity_key_path: Option<std::path::PathBuf>,
173
174 #[serde(default = "default_peer_add_max_per_source")]
179 pub peer_add_max_per_source: u32,
180
181 #[serde(default = "default_peer_add_window_ms")]
184 pub peer_add_window_ms: u64,
185}
186
187fn default_node_name() -> String {
188 "local".into()
189}
190
191fn default_platform() -> NodePlatform {
192 NodePlatform::CloudNative
193}
194
195fn default_heartbeat_interval() -> u64 {
196 5
197}
198
199fn default_suspect_threshold() -> u32 {
200 3
201}
202
203fn default_unreachable_threshold() -> u32 {
204 10
205}
206
207fn default_peer_add_max_per_source() -> u32 {
208 crate::rate_limit::DEFAULT_PEER_ADD_RATE.max_per_window
209}
210
211fn default_peer_add_window_ms() -> u64 {
212 crate::rate_limit::DEFAULT_PEER_ADD_RATE.window.as_millis() as u64
213}
214
215impl Default for ClusterConfig {
216 fn default() -> Self {
217 Self {
218 node_id: uuid::Uuid::new_v4().to_string(),
219 node_name: default_node_name(),
220 platform: default_platform(),
221 heartbeat_interval_secs: default_heartbeat_interval(),
222 suspect_threshold: default_suspect_threshold(),
223 unreachable_threshold: default_unreachable_threshold(),
224 max_nodes: 0,
225 bind_address: None,
226 seed_peers: Vec::new(),
227 identity_key_path: None,
228 peer_add_max_per_source: default_peer_add_max_per_source(),
229 peer_add_window_ms: default_peer_add_window_ms(),
230 }
231 }
232}
233
234impl ClusterConfig {
235 pub fn peer_add_rate_config(&self) -> crate::rate_limit::RateLimitConfig {
238 crate::rate_limit::RateLimitConfig {
239 max_per_window: self.peer_add_max_per_source,
240 window: Duration::from_millis(self.peer_add_window_ms),
241 }
242 }
243
244 #[cfg(feature = "ecc")]
263 pub fn recommended_heartbeat_secs(
264 &self,
265 model: Option<&crate::eml_kernel::GossipTimingModel>,
266 peer_count: usize,
267 network_latency_ms: f64,
268 update_frequency_hz: f64,
269 ) -> u64 {
270 match model {
271 Some(m) if m.is_trained() => {
272 let secs = m.predict(peer_count, network_latency_ms, update_frequency_hz);
273 secs.round().max(1.0) as u64
274 }
275 _ => self.heartbeat_interval_secs,
276 }
277 }
278
279 #[cfg(not(feature = "ecc"))]
281 pub fn recommended_heartbeat_secs(&self) -> u64 {
282 self.heartbeat_interval_secs
283 }
284}
285
286#[cfg(feature = "ecc")]
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct NodeEccCapability {
295 pub tick_interval_ms: u32,
297 pub compute_p95_us: u32,
299 pub headroom_ratio: f32,
301 pub hnsw_vector_count: u32,
303 pub causal_edge_count: u32,
305 pub spectral_capable: bool,
307 pub calibrated_at: u64,
309}
310
311#[cfg(any(feature = "mesh", feature = "exochain"))]
318pub struct NodeIdentity {
319 keypair: ed25519_dalek::SigningKey,
321 node_id: String,
323}
324
325#[cfg(any(feature = "mesh", feature = "exochain"))]
326impl NodeIdentity {
327 pub fn generate() -> Self {
329 let mut csprng = rand::thread_rng();
330 Self::from_signing_key(ed25519_dalek::SigningKey::generate(&mut csprng))
331 }
332
333 pub fn from_signing_key(keypair: ed25519_dalek::SigningKey) -> Self {
337 let node_id = crate::node_id::node_id_from_pubkey(&keypair.verifying_key().to_bytes());
338 Self { keypair, node_id }
339 }
340
341 pub fn node_id(&self) -> &str {
343 &self.node_id
344 }
345
346 pub fn public_key(&self) -> ed25519_dalek::VerifyingKey {
348 self.keypair.verifying_key()
349 }
350
351 pub fn sign(&self, data: &[u8]) -> ed25519_dalek::Signature {
353 use ed25519_dalek::Signer;
354 self.keypair.sign(data)
355 }
356}
357
358#[non_exhaustive]
360#[derive(Debug, thiserror::Error)]
361pub enum ClusterError {
362 #[error("node already exists: '{node_id}'")]
364 NodeAlreadyExists {
365 node_id: NodeId,
367 },
368
369 #[error("node not found: '{node_id}'")]
371 NodeNotFound {
372 node_id: NodeId,
374 },
375
376 #[error("cluster full: max {max} nodes")]
378 ClusterFull {
379 max: u32,
381 },
382
383 #[error("mesh error: {0}")]
385 Mesh(String),
386
387 #[error("authentication failed: {0}")]
389 AuthFailed(String),
390
391 #[error("invalid node state transition: {from} -> {to}")]
393 InvalidTransition {
394 from: String,
396 to: String,
398 },
399
400 #[error("rate limited: peer additions too frequent")]
402 RateLimited,
403}
404
405const KNOWN_CAPABILITIES: &[&str] = &[
412 "ipc",
413 "chain",
414 "tree",
415 "governance",
416 "ecc",
417 "wasm",
418 "containers",
419 "apps",
420 "mesh",
421 "discovery",
422 "heartbeat",
423 "compute",
424];
425
426fn validate_capabilities(capabilities: &[String]) -> Vec<String> {
429 capabilities
430 .iter()
431 .filter(|c| !KNOWN_CAPABILITIES.contains(&c.as_str()))
432 .cloned()
433 .collect()
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, Default)]
438pub struct ClusterPeersFile {
439 #[serde(default = "default_peers_file_version")]
441 pub version: u32,
442 #[serde(default)]
444 pub peers: Vec<PeerNode>,
445}
446
447fn default_peers_file_version() -> u32 {
448 1
449}
450
451pub struct ClusterMembership {
452 config: ClusterConfig,
453 peers: DashMap<NodeId, PeerNode>,
454 peer_add_limiter: crate::rate_limit::KeyedRateLimiter,
456 persist_path: std::sync::Mutex<Option<PathBuf>>,
462 #[cfg(feature = "exochain")]
464 chain: Option<Arc<crate::chain::ChainManager>>,
465 #[cfg(feature = "exochain")]
467 gate: Option<Arc<crate::gate::GovernanceGate>>,
468 #[cfg(all(feature = "native", any(feature = "mesh", feature = "exochain")))]
470 facts: crate::node_facts::NodeFactsCache,
471}
472
473impl ClusterMembership {
474 pub fn new(config: ClusterConfig) -> Self {
480 let peer_add_limiter =
481 crate::rate_limit::KeyedRateLimiter::new(config.peer_add_rate_config());
482 Self {
483 config,
484 peers: DashMap::new(),
485 peer_add_limiter,
486 persist_path: std::sync::Mutex::new(None),
487 #[cfg(feature = "exochain")]
488 chain: None,
489 #[cfg(feature = "exochain")]
490 gate: None,
491 #[cfg(all(feature = "native", any(feature = "mesh", feature = "exochain")))]
492 facts: crate::node_facts::NodeFactsCache::new(),
493 }
494 }
495
496 #[cfg(all(feature = "native", any(feature = "mesh", feature = "exochain")))]
498 pub fn facts(&self) -> &crate::node_facts::NodeFactsCache {
499 &self.facts
500 }
501
502 #[cfg(feature = "exochain")]
504 pub fn with_chain(mut self, cm: Arc<crate::chain::ChainManager>) -> Self {
505 self.chain = Some(cm);
506 self
507 }
508
509 #[cfg(feature = "exochain")]
511 pub fn with_gate(mut self, gate: Arc<crate::gate::GovernanceGate>) -> Self {
512 self.gate = Some(gate);
513 self
514 }
515
516 pub fn with_min_peer_interval(self, interval: std::time::Duration) -> Self {
524 if interval.is_zero() {
525 self.with_peer_add_rate_limit(crate::rate_limit::RateLimitConfig::unlimited())
526 } else {
527 self.with_peer_add_rate_limit(crate::rate_limit::RateLimitConfig {
528 max_per_window: 1,
529 window: interval,
530 })
531 }
532 }
533
534 pub fn with_peer_add_rate_limit(
536 self,
537 config: crate::rate_limit::RateLimitConfig,
538 ) -> Self {
539 self.peer_add_limiter.set_config(config);
540 self
541 }
542
543 pub fn peer_add_rate_limit(&self) -> crate::rate_limit::RateLimitConfig {
545 self.peer_add_limiter.config()
546 }
547
548 pub fn peer_add_source_key(peer: &PeerNode) -> &str {
553 match &peer.address {
554 Some(addr) if !addr.is_empty() => addr.as_str(),
555 _ => peer.id.as_str(),
556 }
557 }
558
559 pub fn with_persist_path(self, path: impl AsRef<Path>) -> Self {
566 let path = path.as_ref().to_path_buf();
567 let mut prune_file = false;
568 if path.exists() {
569 match std::fs::read_to_string(&path) {
570 Ok(data) => match serde_json::from_str::<ClusterPeersFile>(&data) {
571 Ok(file) => {
572 let mut legacy = Vec::new();
573 for peer in file.peers {
574 if is_legacy_uuid_id(&peer.id) {
578 legacy.push(peer.id);
579 continue;
580 }
581 self.peers.insert(peer.id.clone(), peer);
583 }
584 info!(
585 count = self.peers.len(),
586 path = %path.display(),
587 "rehydrated cluster peers from disk"
588 );
589 if !legacy.is_empty() {
590 warn!(
591 dropped = legacy.len(),
592 ids = ?legacy,
593 path = %path.display(),
594 "dropped legacy UUID peer ids from the cluster peers file"
595 );
596 prune_file = true;
597 }
598 }
599 Err(e) => {
600 warn!(
601 error = %e,
602 path = %path.display(),
603 "failed to parse cluster peers file; starting empty"
604 );
605 }
606 },
607 Err(e) => {
608 warn!(
609 error = %e,
610 path = %path.display(),
611 "failed to read cluster peers file; starting empty"
612 );
613 }
614 }
615 }
616 *self
617 .persist_path
618 .lock()
619 .expect("persist_path lock poisoned") = Some(path);
620 if prune_file {
621 self.persist();
623 }
624 self
625 }
626
627 fn persist(&self) {
633 let path_opt = self
634 .persist_path
635 .lock()
636 .expect("persist_path lock poisoned")
637 .clone();
638 let Some(path) = path_opt else {
639 return;
640 };
641
642 let peers: Vec<PeerNode> = self.peers.iter().map(|e| e.value().clone()).collect();
643 let file = ClusterPeersFile {
644 version: default_peers_file_version(),
645 peers,
646 };
647 let json = match serde_json::to_string_pretty(&file) {
648 Ok(j) => j,
649 Err(e) => {
650 warn!(error = %e, "failed to serialise cluster peers");
651 return;
652 }
653 };
654
655 if let Some(parent) = path.parent()
656 && let Err(e) = std::fs::create_dir_all(parent)
657 {
658 warn!(error = %e, path = %parent.display(), "failed to create persist parent");
659 return;
660 }
661
662 let tmp = path.with_extension("json.tmp");
663 if let Err(e) = std::fs::write(&tmp, &json) {
664 warn!(error = %e, path = %tmp.display(), "failed to write cluster peers tmp");
665 return;
666 }
667 if let Err(e) = std::fs::rename(&tmp, &path) {
668 warn!(error = %e, path = %path.display(), "failed to rename cluster peers file");
669 let _ = std::fs::remove_file(&tmp);
670 }
671 }
672
673 pub fn config(&self) -> &ClusterConfig {
675 &self.config
676 }
677
678 pub fn local_node_id(&self) -> &str {
680 &self.config.node_id
681 }
682
683 pub fn add_peer(&self, peer: PeerNode) -> Result<(), ClusterError> {
691 #[cfg(feature = "exochain")]
693 if let Some(ref gate) = self.gate {
694 use crate::gate::GateBackend;
695 let decision = gate.check(
696 &self.config.node_id,
697 "cluster.peer.add",
698 &serde_json::json!({
699 "peer_id": peer.id,
700 "peer_name": peer.name,
701 "platform": peer.platform.to_string(),
702 "effect": { "risk": 0.4, "security": 0.3 },
703 }),
704 );
705 if decision.is_deny() {
706 return Err(ClusterError::AuthFailed(
707 "governance denied peer addition".into(),
708 ));
709 }
710 }
711
712 let source = Self::peer_add_source_key(&peer);
714 if !self.peer_add_limiter.check(source) {
715 return Err(ClusterError::RateLimited);
716 }
717
718 if self.peers.contains_key(&peer.id) {
719 return Err(ClusterError::NodeAlreadyExists { node_id: peer.id });
720 }
721
722 if self.config.max_nodes > 0 && self.peers.len() as u32 >= self.config.max_nodes {
723 return Err(ClusterError::ClusterFull {
724 max: self.config.max_nodes,
725 });
726 }
727
728 let unknown = validate_capabilities(&peer.capabilities);
730 if !unknown.is_empty() {
731 warn!(
732 node_id = %peer.id,
733 unknown_capabilities = ?unknown,
734 "peer advertises unknown capabilities"
735 );
736 }
737
738 #[cfg(feature = "exochain")]
740 if let Some(ref cm) = self.chain {
741 cm.append(
742 "cluster",
743 crate::chain::EVENT_KIND_CLUSTER_PEER_ADD,
744 Some(serde_json::json!({
745 "node_id": peer.id,
746 "name": peer.name,
747 "platform": peer.platform.to_string(),
748 })),
749 );
750 }
751
752 debug!(node_id = %peer.id, name = %peer.name, "adding peer to cluster");
753 self.peers.insert(peer.id.clone(), peer);
754 self.persist();
755 Ok(())
756 }
757
758 pub fn remove_peer(&self, node_id: &str) -> Result<PeerNode, ClusterError> {
760 #[cfg(feature = "exochain")]
762 if let Some(ref gate) = self.gate {
763 use crate::gate::GateBackend;
764 let decision = gate.check(
765 &self.config.node_id,
766 "cluster.peer.remove",
767 &serde_json::json!({
768 "peer_id": node_id,
769 "effect": { "risk": 0.3, "security": 0.2 },
770 }),
771 );
772 if decision.is_deny() {
773 return Err(ClusterError::AuthFailed(
774 "governance denied peer removal".into(),
775 ));
776 }
777 }
778
779 let result = self
780 .peers
781 .remove(node_id)
782 .map(|(_, peer)| peer)
783 .ok_or_else(|| ClusterError::NodeNotFound {
784 node_id: node_id.to_owned(),
785 });
786
787 if result.is_ok() {
788 self.persist();
789 }
790
791 #[cfg(feature = "exochain")]
793 if let (Ok(peer), Some(cm)) = (&result, &self.chain) {
794 cm.append(
795 "cluster",
796 crate::chain::EVENT_KIND_CLUSTER_PEER_REMOVE,
797 Some(serde_json::json!({
798 "node_id": peer.id,
799 "name": peer.name,
800 })),
801 );
802 }
803
804 result
805 }
806
807 pub fn update_state(&self, node_id: &str, new_state: NodeState) -> Result<(), ClusterError> {
809 let mut entry = self
810 .peers
811 .get_mut(node_id)
812 .ok_or_else(|| ClusterError::NodeNotFound {
813 node_id: node_id.to_owned(),
814 })?;
815
816 #[cfg(feature = "exochain")]
818 if let Some(ref cm) = self.chain {
819 cm.append(
820 "cluster",
821 crate::chain::EVENT_KIND_CLUSTER_PEER_STATE,
822 Some(serde_json::json!({
823 "node_id": node_id,
824 "from": entry.state.to_string(),
825 "to": new_state.to_string(),
826 })),
827 );
828 }
829
830 entry.state = new_state;
831 drop(entry);
832 self.persist();
833 Ok(())
834 }
835
836 pub fn heartbeat(&self, node_id: &str) -> Result<(), ClusterError> {
838 let mut entry = self
839 .peers
840 .get_mut(node_id)
841 .ok_or_else(|| ClusterError::NodeNotFound {
842 node_id: node_id.to_owned(),
843 })?;
844 entry.last_heartbeat = Utc::now();
845 if entry.state == NodeState::Suspect {
846 entry.state = NodeState::Active;
847 }
848 Ok(())
849 }
850
851 pub fn get_peer(&self, node_id: &str) -> Option<PeerNode> {
853 self.peers.get(node_id).map(|e| e.value().clone())
854 }
855
856 pub fn list_peers(&self) -> Vec<(NodeId, NodeState, NodePlatform)> {
858 self.peers
859 .iter()
860 .map(|e| (e.key().clone(), e.state.clone(), e.platform.clone()))
861 .collect()
862 }
863
864 pub fn count_by_state(&self, state: &NodeState) -> usize {
866 self.peers.iter().filter(|e| &e.state == state).count()
867 }
868
869 pub fn len(&self) -> usize {
871 self.peers.len()
872 }
873
874 pub fn is_empty(&self) -> bool {
876 self.peers.is_empty()
877 }
878
879 #[cfg(feature = "exochain")]
881 pub fn add_peer_with_tree(
882 &self,
883 peer: PeerNode,
884 tree: &std::sync::Mutex<exo_resource_tree::ResourceTree>,
885 ) -> Result<(), ClusterError> {
886 let peer_name = peer.name.clone();
887 self.add_peer(peer)?;
888
889 let mut tree = tree.lock().unwrap();
891 let peer_id = exo_resource_tree::ResourceId::new(format!("/network/peers/{peer_name}"));
892 let parent = exo_resource_tree::ResourceId::new("/network/peers");
893 if let Err(e) = tree.insert(peer_id, exo_resource_tree::ResourceKind::Device, parent) {
894 tracing::debug!(peer = %peer_name, error = %e, "failed to create tree node for peer");
895 }
896
897 Ok(())
898 }
899
900 pub fn add_peer_checked(
905 &self,
906 peer: PeerNode,
907 revocation_list: &crate::revocation::RevocationList,
908 ) -> Result<(), ClusterError> {
909 if revocation_list.is_revoked(&peer.id) {
910 return Err(ClusterError::AuthFailed(format!(
911 "host '{}' is revoked",
912 peer.id,
913 )));
914 }
915 self.add_peer(peer)
916 }
917
918 pub fn active_peers(&self) -> Vec<NodeId> {
920 self.peers
921 .iter()
922 .filter(|e| e.state == NodeState::Active)
923 .map(|e| e.key().clone())
924 .collect()
925 }
926
927 pub fn expire_unverified(&self, max_age: std::time::Duration) -> usize {
932 let cutoff = Utc::now() - chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::MAX);
933 let stale: Vec<NodeId> = self
934 .peers
935 .iter()
936 .filter(|e| e.state == NodeState::Unverified && e.last_heartbeat < cutoff)
937 .map(|e| e.key().clone())
938 .collect();
939 stale.iter().filter(|id| self.remove_peer(id).is_ok()).count()
940 }
941
942 #[cfg(feature = "mesh")]
951 pub fn apply_mesh_peer_event(
952 &self,
953 event: &crate::mesh_discovery::MeshPeerEvent,
954 ) -> Result<bool, ClusterError> {
955 use crate::mesh_discovery::MeshPeerEvent;
956 match event {
957 MeshPeerEvent::Joined {
958 node_id,
959 address,
960 platform,
961 verified,
962 } => self.mesh_upsert(node_id, address.as_deref(), platform.as_deref(), *verified),
963 MeshPeerEvent::Recovered {
964 node_id,
965 address,
966 verified,
967 } => self.mesh_upsert(node_id, address.as_deref(), None, *verified),
968 MeshPeerEvent::Alive { node_id } => {
969 if self
971 .get_peer(node_id)
972 .is_some_and(|p| p.state == NodeState::Unverified)
973 {
974 return Ok(false);
975 }
976 if self.peers.contains_key(node_id) {
977 let _ = self.heartbeat(node_id);
978 if self
979 .get_peer(node_id)
980 .map(|p| !matches!(p.state, NodeState::Active | NodeState::Unverified))
981 .unwrap_or(false)
982 {
983 let _ = self.update_state(node_id, NodeState::Active);
984 return Ok(true);
985 }
986 Ok(true)
988 } else {
989 self.mesh_upsert(node_id, None, None, false)
992 }
993 }
994 MeshPeerEvent::Suspect { node_id } => {
997 if self.peers.get(node_id).is_some_and(|p| p.state != NodeState::Unverified) {
998 self.update_state(node_id, NodeState::Suspect)?;
999 Ok(true)
1000 } else {
1001 Ok(false)
1002 }
1003 }
1004 MeshPeerEvent::Unreachable { node_id } => {
1005 match self.peers.get(node_id).map(|p| p.state.clone()) {
1006 Some(NodeState::Unverified) => {
1009 let _ = self.remove_peer(node_id);
1010 Ok(true)
1011 }
1012 Some(_) => {
1013 self.update_state(node_id, NodeState::Unreachable)?;
1014 Ok(true)
1015 }
1016 None => Ok(false),
1017 }
1018 }
1019 MeshPeerEvent::Left { node_id } => {
1020 if self.peers.contains_key(node_id) {
1021 let _ = self.update_state(node_id, NodeState::Left);
1023 let _ = self.remove_peer(node_id);
1024 Ok(true)
1025 } else {
1026 Ok(false)
1027 }
1028 }
1029 }
1030 }
1031
1032 #[cfg(feature = "mesh")]
1039 fn mesh_upsert(
1040 &self,
1041 node_id: &str,
1042 address: Option<&str>,
1043 platform: Option<&str>,
1044 verified: bool,
1045 ) -> Result<bool, ClusterError> {
1046 if let Some(mut entry) = self.peers.get_mut(node_id) {
1047 let mut changed = false;
1048 let to = match (verified, &entry.state) {
1049 (true, s) if *s != NodeState::Active => Some(NodeState::Active),
1050 (false, NodeState::Active | NodeState::Unverified) => None,
1051 (false, _) => Some(NodeState::Unverified),
1052 _ => None,
1053 };
1054 if let Some(to) = to {
1055 entry.state = to;
1056 changed = true;
1057 }
1058 let may_refresh = verified || entry.state == NodeState::Unverified;
1062 if may_refresh {
1063 entry.last_heartbeat = Utc::now();
1064 }
1065 if may_refresh
1066 && let Some(addr) = address
1067 && entry.address.as_deref() != Some(addr)
1068 {
1069 entry.address = Some(addr.to_owned());
1070 changed = true;
1071 }
1072 drop(entry);
1073 if changed {
1074 self.persist();
1075 }
1076 return Ok(true);
1077 }
1078
1079 if self.config.max_nodes > 0 && self.peers.len() as u32 >= self.config.max_nodes {
1080 return Err(ClusterError::ClusterFull {
1081 max: self.config.max_nodes,
1082 });
1083 }
1084
1085 let platform = match platform {
1086 Some("edge") => NodePlatform::Edge,
1087 Some("browser") => NodePlatform::Browser,
1088 Some("wasi") => NodePlatform::Wasi,
1089 Some(other) if other.starts_with("custom:") => {
1090 NodePlatform::Custom(other.trim_start_matches("custom:").to_owned())
1091 }
1092 Some(other) if other != "cloud-native" && other != "linux" => {
1093 NodePlatform::Custom(other.to_owned())
1094 }
1095 _ => NodePlatform::CloudNative,
1096 };
1097
1098 let now = Utc::now();
1099 let peer = PeerNode {
1100 id: node_id.to_owned(),
1101 name: node_id.to_owned(),
1102 platform,
1103 state: if verified { NodeState::Active } else { NodeState::Unverified },
1104 address: address.map(|s| s.to_owned()),
1105 first_seen: now,
1106 last_heartbeat: now,
1107 capabilities: vec!["mesh".into()],
1108 labels: HashMap::from([("source".into(), "mesh".into())]),
1109 };
1110
1111 debug!(node_id = %node_id, "mesh peer join → cluster membership");
1112 self.peers.insert(node_id.to_owned(), peer);
1113 self.persist();
1114 Ok(true)
1115 }
1116
1117 #[cfg(all(feature = "mesh", feature = "native"))]
1124 pub fn spawn_mesh_peer_listener(
1125 self: &Arc<Self>,
1126 mut rx: tokio::sync::broadcast::Receiver<crate::mesh_discovery::MeshPeerEvent>,
1127 ) {
1128 let membership = Arc::clone(self);
1129 tokio::spawn(async move {
1130 let mut reap = tokio::time::interval(UNVERIFIED_REAP_EVERY);
1131 loop {
1132 let received = tokio::select! {
1133 _ = reap.tick() => {
1134 membership.expire_unverified(UNVERIFIED_TTL);
1135 continue;
1136 }
1137 r = rx.recv() => r,
1138 };
1139 match received {
1140 Ok(event) => {
1141 if let Err(e) = membership.apply_mesh_peer_event(&event) {
1142 debug!(
1143 error = %e,
1144 node = %event.node_id(),
1145 "cluster membership: mesh peer event apply failed"
1146 );
1147 }
1148 }
1149 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1150 warn!(skipped = n, "cluster membership: mesh peer event bus lagged");
1151 }
1152 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1153 info!("cluster membership: mesh peer event bus closed");
1154 break;
1155 }
1156 }
1157 }
1158 });
1159 }
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize)]
1172pub struct PairingWindowResult {
1173 pub window_secs: u64,
1175 pub already_paired: Vec<String>,
1177}
1178
1179#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1181pub struct PairedHostsFile {
1182 pub hosts: Vec<PairedHost>,
1184}
1185
1186#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub struct PairedHost {
1189 pub peer_id: String,
1191 pub paired_at: DateTime<Utc>,
1193}
1194
1195pub struct PairingGate {
1197 window_until: std::sync::Mutex<Option<Instant>>,
1199 pending_pairs: std::sync::Mutex<Vec<String>>,
1201 paired_hosts: std::sync::Mutex<HashSet<String>>,
1203 persist_path: PathBuf,
1205}
1206
1207impl PairingGate {
1208 pub fn new(persist_path: PathBuf) -> Self {
1212 Self {
1213 window_until: std::sync::Mutex::new(None),
1214 pending_pairs: std::sync::Mutex::new(Vec::new()),
1215 paired_hosts: std::sync::Mutex::new(HashSet::new()),
1216 persist_path,
1217 }
1218 }
1219
1220 pub fn load(&self) -> Result<usize, std::io::Error> {
1222 if !self.persist_path.exists() {
1223 if let Some(parent) = self.persist_path.parent() {
1224 std::fs::create_dir_all(parent)?;
1225 }
1226 self.save()?;
1227 return Ok(0);
1228 }
1229
1230 let data = std::fs::read_to_string(&self.persist_path)?;
1231 let file: PairedHostsFile = serde_json::from_str(&data)
1232 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1233
1234 let mut hosts = self
1235 .paired_hosts
1236 .lock()
1237 .expect("paired_hosts lock poisoned");
1238 for h in &file.hosts {
1239 hosts.insert(h.peer_id.clone());
1240 }
1241
1242 let count = hosts.len();
1243 info!(count, "loaded paired hosts from disk");
1244 Ok(count)
1245 }
1246
1247 pub fn save(&self) -> Result<(), std::io::Error> {
1249 let hosts = self
1250 .paired_hosts
1251 .lock()
1252 .expect("paired_hosts lock poisoned");
1253 let file = PairedHostsFile {
1254 hosts: hosts
1255 .iter()
1256 .map(|id| PairedHost {
1257 peer_id: id.clone(),
1258 paired_at: Utc::now(),
1259 })
1260 .collect(),
1261 };
1262 let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?;
1263 if let Some(parent) = self.persist_path.parent() {
1264 std::fs::create_dir_all(parent)?;
1265 }
1266 std::fs::write(&self.persist_path, json)
1267 }
1268
1269 pub fn open_pairing_window(&self, duration: Duration) -> PairingWindowResult {
1271 let mut until = self.window_until.lock().expect("window lock poisoned");
1272 *until = Some(Instant::now() + duration);
1273
1274 let mut pending = self.pending_pairs.lock().expect("pending lock poisoned");
1275 pending.clear();
1276
1277 let already: Vec<String> = self
1278 .paired_hosts
1279 .lock()
1280 .expect("paired_hosts lock poisoned")
1281 .iter()
1282 .cloned()
1283 .collect();
1284
1285 info!(window_secs = duration.as_secs(), "pairing window opened");
1286
1287 PairingWindowResult {
1288 window_secs: duration.as_secs(),
1289 already_paired: already,
1290 }
1291 }
1292
1293 pub fn is_pairing_open(&self) -> bool {
1295 let until = self.window_until.lock().expect("window lock poisoned");
1296 match *until {
1297 Some(deadline) => Instant::now() < deadline,
1298 None => false,
1299 }
1300 }
1301
1302 pub fn should_accept_peer(&self, peer_id: &str) -> bool {
1308 {
1310 let hosts = self
1311 .paired_hosts
1312 .lock()
1313 .expect("paired_hosts lock poisoned");
1314 if hosts.contains(peer_id) {
1315 return true;
1316 }
1317 }
1318
1319 if self.is_pairing_open() {
1321 let mut hosts = self
1323 .paired_hosts
1324 .lock()
1325 .expect("paired_hosts lock poisoned");
1326 hosts.insert(peer_id.to_owned());
1327
1328 let mut pending = self.pending_pairs.lock().expect("pending lock poisoned");
1329 pending.push(peer_id.to_owned());
1330
1331 info!(peer_id, "new peer paired during open window");
1332
1333 drop(hosts);
1335 drop(pending);
1336 if let Err(e) = self.save() {
1337 warn!(error = %e, "failed to persist paired hosts");
1338 }
1339
1340 return true;
1341 }
1342
1343 debug!(
1344 peer_id,
1345 "rejecting peer: pairing window closed and not previously paired"
1346 );
1347 false
1348 }
1349
1350 pub fn paired_hosts(&self) -> Vec<String> {
1352 self.paired_hosts
1353 .lock()
1354 .expect("paired_hosts lock poisoned")
1355 .iter()
1356 .cloned()
1357 .collect()
1358 }
1359
1360 pub fn unpair(&self, peer_id: &str) -> bool {
1362 let mut hosts = self
1363 .paired_hosts
1364 .lock()
1365 .expect("paired_hosts lock poisoned");
1366 let removed = hosts.remove(peer_id);
1367 if removed {
1368 drop(hosts);
1369 if let Err(e) = self.save() {
1370 warn!(error = %e, "failed to persist after unpair");
1371 }
1372 info!(peer_id, "unpaired host");
1373 }
1374 removed
1375 }
1376
1377 pub fn persist_path(&self) -> &Path {
1379 &self.persist_path
1380 }
1381}
1382
1383impl std::fmt::Debug for PairingGate {
1384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1385 let count = self.paired_hosts.lock().map(|h| h.len()).unwrap_or(0);
1386 f.debug_struct("PairingGate")
1387 .field("open", &self.is_pairing_open())
1388 .field("paired_hosts", &count)
1389 .field("persist_path", &self.persist_path)
1390 .finish()
1391 }
1392}
1393
1394#[cfg(feature = "cluster")]
1401mod cluster_service {
1402 use std::sync::Arc;
1403 use std::time::Duration;
1404
1405 use async_trait::async_trait;
1406 use tracing::{debug, info};
1407
1408 use ruvector_cluster::{
1409 ClusterConfig as RuvectorClusterConfig, ClusterManager, ClusterNode, DiscoveryService,
1410 NodeStatus, ShardInfo, StaticDiscovery,
1411 };
1412
1413 use crate::cluster::{ClusterMembership, NodePlatform, NodeState, PeerNode};
1414 use crate::health::HealthStatus;
1415 use crate::service::{ServiceType, SystemService};
1416 use clawft_types::config::ClusterNetworkConfig;
1417
1418 pub struct ClusterService {
1423 manager: ClusterManager,
1424 membership: Arc<ClusterMembership>,
1425 config: ClusterNetworkConfig,
1426 mesh_listener_started: std::sync::atomic::AtomicBool,
1428 }
1429
1430 impl ClusterService {
1431 pub fn new(
1437 config: ClusterNetworkConfig,
1438 node_id: String,
1439 discovery: Box<dyn DiscoveryService>,
1440 membership: Arc<ClusterMembership>,
1441 ) -> Result<Self, ruvector_cluster::ClusterError> {
1442 let ruvector_config = RuvectorClusterConfig {
1443 replication_factor: config.replication_factor,
1444 shard_count: config.shard_count,
1445 heartbeat_interval: Duration::from_secs(config.heartbeat_interval_secs),
1446 node_timeout: Duration::from_secs(config.node_timeout_secs),
1447 enable_consensus: config.enable_consensus,
1448 min_quorum_size: config.min_quorum_size,
1449 };
1450
1451 let manager = ClusterManager::new(ruvector_config, node_id, discovery)?;
1452
1453 Ok(Self {
1454 manager,
1455 membership,
1456 config,
1457 mesh_listener_started: std::sync::atomic::AtomicBool::new(false),
1458 })
1459 }
1460
1461 pub fn with_defaults(
1463 node_id: String,
1464 membership: Arc<ClusterMembership>,
1465 ) -> Result<Self, ruvector_cluster::ClusterError> {
1466 let config = ClusterNetworkConfig::default();
1467 let discovery = Box::new(StaticDiscovery::new(vec![]));
1468 Self::new(config, node_id, discovery, membership)
1469 }
1470
1471 pub fn sync_to_membership(&self) {
1482 let nodes = self.manager.list_nodes();
1483 for node in &nodes {
1484 let peer = Self::cluster_node_to_peer(node);
1485 if self.membership.get_peer(&peer.id).is_some() {
1486 let new_state = Self::map_status(node.status);
1488 let _ = self.membership.update_state(&peer.id, new_state);
1489 let _ = self.membership.heartbeat(&peer.id);
1490 } else {
1491 if let Err(e) = self.membership.add_peer(peer) {
1493 debug!(error = %e, "failed to sync node to membership");
1494 }
1495 }
1496 }
1497 }
1498
1499 #[cfg(feature = "mesh")]
1506 pub fn apply_mesh_peer_event(
1507 &self,
1508 event: &crate::mesh_discovery::MeshPeerEvent,
1509 ) -> Result<bool, crate::cluster::ClusterError> {
1510 self.membership.apply_mesh_peer_event(event)
1511 }
1512
1513 #[cfg(feature = "mesh")]
1515 pub async fn apply_mesh_peer_event_async(
1516 &self,
1517 event: &crate::mesh_discovery::MeshPeerEvent,
1518 ) -> Result<bool, crate::cluster::ClusterError> {
1519 use crate::mesh_discovery::MeshPeerEvent;
1520 let changed = self.membership.apply_mesh_peer_event(event)?;
1521 match event {
1522 MeshPeerEvent::Joined {
1525 node_id,
1526 address,
1527 verified: true,
1528 ..
1529 }
1530 | MeshPeerEvent::Recovered {
1531 node_id,
1532 address,
1533 verified: true,
1534 } => {
1535 let addr: std::net::SocketAddr = address
1536 .as_deref()
1537 .and_then(|s| s.parse().ok())
1538 .unwrap_or_else(|| "0.0.0.0:0".parse().unwrap());
1539 let node = ClusterNode::new(node_id.clone(), addr);
1540 if let Err(e) = self.manager.add_node(node).await {
1541 debug!(error = %e, node = %node_id, "cluster manager: add_node from mesh");
1542 }
1543 }
1544 MeshPeerEvent::Left { node_id } | MeshPeerEvent::Unreachable { node_id } => {
1545 if let Err(e) = self.manager.remove_node(node_id).await {
1546 debug!(
1547 error = %e,
1548 node = %node_id,
1549 "cluster manager: remove_node from mesh"
1550 );
1551 }
1552 }
1553 MeshPeerEvent::Alive { .. }
1554 | MeshPeerEvent::Suspect { .. }
1555 | MeshPeerEvent::Joined { .. }
1556 | MeshPeerEvent::Recovered { .. } => {}
1557 }
1558 Ok(changed)
1559 }
1560
1561 #[cfg(feature = "mesh")]
1566 pub fn spawn_mesh_peer_listener(
1567 self: &Arc<Self>,
1568 mut rx: tokio::sync::broadcast::Receiver<crate::mesh_discovery::MeshPeerEvent>,
1569 ) {
1570 use std::sync::atomic::Ordering;
1571 if self
1572 .mesh_listener_started
1573 .swap(true, Ordering::SeqCst)
1574 {
1575 debug!("cluster service mesh peer listener already started");
1576 return;
1577 }
1578 let svc = Arc::clone(self);
1579 tokio::spawn(async move {
1580 info!("cluster service subscribed to mesh peer-event stream");
1581 let mut reap = tokio::time::interval(super::UNVERIFIED_REAP_EVERY);
1582 loop {
1583 let received = tokio::select! {
1584 _ = reap.tick() => {
1585 svc.membership.expire_unverified(super::UNVERIFIED_TTL);
1586 continue;
1587 }
1588 r = rx.recv() => r,
1589 };
1590 match received {
1591 Ok(event) => {
1592 if let Err(e) = svc.apply_mesh_peer_event_async(&event).await {
1593 debug!(
1594 error = %e,
1595 node = %event.node_id(),
1596 "cluster service: mesh peer event apply failed"
1597 );
1598 }
1599 }
1600 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
1601 tracing::warn!(
1602 skipped = n,
1603 "cluster service: mesh peer event bus lagged"
1604 );
1605 }
1606 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1607 info!("cluster service: mesh peer event bus closed");
1608 break;
1609 }
1610 }
1611 }
1612 });
1613 }
1614
1615 #[cfg(feature = "mesh")]
1617 pub fn mesh_listener_started(&self) -> bool {
1618 self.mesh_listener_started
1619 .load(std::sync::atomic::Ordering::SeqCst)
1620 }
1621
1622 pub fn membership(&self) -> &Arc<ClusterMembership> {
1624 &self.membership
1625 }
1626
1627 pub fn config(&self) -> &ClusterNetworkConfig {
1629 &self.config
1630 }
1631
1632 pub fn manager(&self) -> &ClusterManager {
1634 &self.manager
1635 }
1636
1637 pub fn stats(&self) -> ruvector_cluster::ClusterStats {
1639 self.manager.get_stats()
1640 }
1641
1642 pub fn list_shards(&self) -> Vec<ShardInfo> {
1644 self.manager.list_shards()
1645 }
1646
1647 pub fn list_nodes(&self) -> Vec<ClusterNode> {
1649 self.manager.list_nodes()
1650 }
1651
1652 fn map_status(status: NodeStatus) -> NodeState {
1654 match status {
1655 NodeStatus::Leader | NodeStatus::Follower | NodeStatus::Candidate => {
1656 NodeState::Active
1657 }
1658 NodeStatus::Offline => NodeState::Unreachable,
1659 }
1660 }
1661
1662 fn cluster_node_to_peer(node: &ClusterNode) -> PeerNode {
1664 PeerNode {
1665 id: node.node_id.clone(),
1666 name: node
1667 .metadata
1668 .get("name")
1669 .cloned()
1670 .unwrap_or_else(|| node.node_id.clone()),
1671 platform: NodePlatform::CloudNative,
1672 state: Self::map_status(node.status),
1673 address: Some(node.address.to_string()),
1674 first_seen: node.last_seen, last_heartbeat: node.last_seen,
1676 capabilities: Vec::new(),
1677 labels: node.metadata.clone(),
1678 }
1679 }
1680 }
1681
1682 #[async_trait]
1683 impl SystemService for ClusterService {
1684 fn name(&self) -> &str {
1685 "cluster"
1686 }
1687
1688 fn service_type(&self) -> ServiceType {
1689 ServiceType::Core
1690 }
1691
1692 async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1693 info!("starting cluster service");
1694
1695 let local_id = self.membership.local_node_id().to_owned();
1712 let placeholder_addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
1713 let self_node = ClusterNode::new(local_id, placeholder_addr);
1714 if let Err(e) = self.manager.add_node(self_node).await {
1715 debug!(error = %e, "cluster: failed to pre-register self (continuing)");
1716 }
1717
1718 self.manager
1719 .start()
1720 .await
1721 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
1722 self.sync_to_membership();
1723 info!(
1724 nodes = self.manager.list_nodes().len(),
1725 shards = self.manager.list_shards().len(),
1726 "cluster service started"
1727 );
1728 Ok(())
1729 }
1730
1731 async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1732 info!("stopping cluster service");
1733 let local_id = self.membership.local_node_id().to_owned();
1735 let _ = self.membership.update_state(&local_id, NodeState::Left);
1736 Ok(())
1737 }
1738
1739 async fn health_check(&self) -> HealthStatus {
1740 let stats = self.manager.get_stats();
1741 if stats.healthy_nodes > 0 {
1742 HealthStatus::Healthy
1743 } else {
1744 HealthStatus::Degraded("no healthy cluster nodes".into())
1745 }
1746 }
1747 }
1748}
1749
1750#[cfg(feature = "cluster")]
1751pub use cluster_service::ClusterService;
1752
1753
1754fn is_legacy_uuid_id(id: &str) -> bool {
1756 let parts: Vec<&str> = id.split('-').collect();
1757 parts.len() == 5
1758 && parts.iter().zip([8, 4, 4, 4, 12]).all(|(p, n)| p.len() == n && p.bytes().all(|b| b.is_ascii_hexdigit()))
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763 use super::*;
1764
1765 fn make_peer(id: &str, name: &str) -> PeerNode {
1766 PeerNode {
1767 id: id.into(),
1768 name: name.into(),
1769 platform: NodePlatform::CloudNative,
1770 state: NodeState::Active,
1771 address: Some("10.0.0.1:8080".into()),
1772 first_seen: Utc::now(),
1773 last_heartbeat: Utc::now(),
1774 capabilities: vec!["compute".into()],
1775 labels: HashMap::from([("region".into(), "us-east".into())]),
1776 }
1777 }
1778
1779 #[test]
1780 fn default_config() {
1781 let config = ClusterConfig::default();
1782 assert_eq!(config.node_name, "local");
1783 assert_eq!(config.heartbeat_interval_secs, 5);
1784 assert_eq!(config.suspect_threshold, 3);
1785 }
1786
1787 #[test]
1788 #[cfg(feature = "ecc")]
1789 fn recommended_heartbeat_falls_back_when_no_model() {
1790 let config = ClusterConfig {
1793 heartbeat_interval_secs: 7,
1794 ..Default::default()
1795 };
1796 assert_eq!(config.recommended_heartbeat_secs(None, 0, 0.0, 0.0), 7);
1797 }
1798
1799 #[test]
1800 #[cfg(feature = "ecc")]
1801 fn recommended_heartbeat_falls_back_when_untrained() {
1802 let config = ClusterConfig {
1804 heartbeat_interval_secs: 9,
1805 ..Default::default()
1806 };
1807 let model = crate::eml_kernel::GossipTimingModel::new();
1808 assert!(!model.is_trained());
1809 assert_eq!(
1810 config.recommended_heartbeat_secs(Some(&model), 5, 50.0, 1.0),
1811 9,
1812 );
1813 }
1814
1815 #[test]
1816 #[cfg(feature = "ecc")]
1817 fn recommended_heartbeat_uses_trained_model() {
1818 let model = crate::eml_kernel::GossipTimingModel::new();
1821 let mut json = serde_json::to_value(&model).unwrap();
1822 if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
1823 inner.insert("trained".into(), serde_json::Value::Bool(true));
1824 }
1825 let forced: crate::eml_kernel::GossipTimingModel = serde_json::from_value(json).unwrap();
1826 assert!(forced.is_trained());
1827
1828 let config = ClusterConfig {
1829 heartbeat_interval_secs: 5,
1830 ..Default::default()
1831 };
1832 let recommended = config.recommended_heartbeat_secs(Some(&forced), 10, 50.0, 1.0);
1833 assert!((1..=60).contains(&recommended));
1835 }
1836
1837 #[test]
1838 fn config_serde_roundtrip() {
1839 let config = ClusterConfig {
1840 node_id: "node-1".into(),
1841 node_name: "primary".into(),
1842 platform: NodePlatform::Edge,
1843 heartbeat_interval_secs: 10,
1844 suspect_threshold: 5,
1845 unreachable_threshold: 15,
1846 max_nodes: 100,
1847 ..Default::default()
1848 };
1849 let json = serde_json::to_string(&config).unwrap();
1850 let restored: ClusterConfig = serde_json::from_str(&json).unwrap();
1851 assert_eq!(restored.node_name, "primary");
1852 assert_eq!(restored.max_nodes, 100);
1853 }
1854
1855 #[test]
1856 fn node_platform_display() {
1857 assert_eq!(NodePlatform::CloudNative.to_string(), "cloud-native");
1858 assert_eq!(NodePlatform::Browser.to_string(), "browser");
1859 assert_eq!(
1860 NodePlatform::Custom("k8s".into()).to_string(),
1861 "custom(k8s)"
1862 );
1863 }
1864
1865 #[test]
1866 fn node_state_display() {
1867 assert_eq!(NodeState::Active.to_string(), "active");
1868 assert_eq!(NodeState::Suspect.to_string(), "suspect");
1869 assert_eq!(NodeState::Unreachable.to_string(), "unreachable");
1870 }
1871
1872 fn make_cluster(config: ClusterConfig) -> ClusterMembership {
1874 ClusterMembership::new(config).with_min_peer_interval(std::time::Duration::ZERO)
1875 }
1876
1877 #[test]
1878 fn add_and_list_peers() {
1879 let cluster = make_cluster(ClusterConfig::default());
1880 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1881 cluster.add_peer(make_peer("node-2", "beta")).unwrap();
1882
1883 let peers = cluster.list_peers();
1884 assert_eq!(peers.len(), 2);
1885 }
1886
1887 #[test]
1888 fn add_duplicate_fails() {
1889 let cluster = make_cluster(ClusterConfig::default());
1890 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1891 assert!(matches!(
1892 cluster.add_peer(make_peer("node-1", "alpha-dup")),
1893 Err(ClusterError::NodeAlreadyExists { .. })
1894 ));
1895 }
1896
1897 #[test]
1898 fn cluster_full() {
1899 let config = ClusterConfig {
1900 max_nodes: 1,
1901 ..Default::default()
1902 };
1903 let cluster = make_cluster(config);
1904 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1905 assert!(matches!(
1906 cluster.add_peer(make_peer("node-2", "beta")),
1907 Err(ClusterError::ClusterFull { .. })
1908 ));
1909 }
1910
1911 #[test]
1912 fn remove_peer() {
1913 let cluster = make_cluster(ClusterConfig::default());
1914 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1915 let removed = cluster.remove_peer("node-1").unwrap();
1916 assert_eq!(removed.name, "alpha");
1917 assert!(cluster.is_empty());
1918 }
1919
1920 #[test]
1921 fn remove_nonexistent_fails() {
1922 let cluster = make_cluster(ClusterConfig::default());
1923 assert!(matches!(
1924 cluster.remove_peer("nope"),
1925 Err(ClusterError::NodeNotFound { .. })
1926 ));
1927 }
1928
1929 #[test]
1930 fn update_state() {
1931 let cluster = make_cluster(ClusterConfig::default());
1932 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1933 cluster.update_state("node-1", NodeState::Suspect).unwrap();
1934 let peer = cluster.get_peer("node-1").unwrap();
1935 assert_eq!(peer.state, NodeState::Suspect);
1936 }
1937
1938 #[test]
1939 fn heartbeat_clears_suspect() {
1940 let cluster = make_cluster(ClusterConfig::default());
1941 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1942 cluster.update_state("node-1", NodeState::Suspect).unwrap();
1943 cluster.heartbeat("node-1").unwrap();
1944 let peer = cluster.get_peer("node-1").unwrap();
1945 assert_eq!(peer.state, NodeState::Active);
1946 }
1947
1948 #[test]
1949 fn count_by_state() {
1950 let cluster = make_cluster(ClusterConfig::default());
1951 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1952 cluster.add_peer(make_peer("node-2", "beta")).unwrap();
1953 cluster.update_state("node-2", NodeState::Suspect).unwrap();
1954 assert_eq!(cluster.count_by_state(&NodeState::Active), 1);
1955 assert_eq!(cluster.count_by_state(&NodeState::Suspect), 1);
1956 }
1957
1958 #[test]
1959 fn active_peers() {
1960 let cluster = make_cluster(ClusterConfig::default());
1961 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
1962 cluster.add_peer(make_peer("node-2", "beta")).unwrap();
1963 cluster.update_state("node-2", NodeState::Leaving).unwrap();
1964 let active = cluster.active_peers();
1965 assert_eq!(active.len(), 1);
1966 assert_eq!(active[0], "node-1");
1967 }
1968
1969 #[test]
1970 fn peer_serde_roundtrip() {
1971 let peer = make_peer("node-1", "alpha");
1972 let json = serde_json::to_string(&peer).unwrap();
1973 let restored: PeerNode = serde_json::from_str(&json).unwrap();
1974 assert_eq!(restored.id, "node-1");
1975 assert_eq!(restored.capabilities, vec!["compute"]);
1976 }
1977
1978 #[test]
1979 fn cluster_error_display() {
1980 let err = ClusterError::NodeNotFound {
1981 node_id: "node-1".into(),
1982 };
1983 assert!(err.to_string().contains("node-1"));
1984
1985 let err = ClusterError::ClusterFull { max: 10 };
1986 assert!(err.to_string().contains("10"));
1987 }
1988
1989 #[test]
1990 fn mesh_and_auth_error_display() {
1991 let err = ClusterError::Mesh("connection refused".into());
1992 assert!(err.to_string().contains("connection refused"));
1993
1994 let err = ClusterError::AuthFailed("bad signature".into());
1995 assert!(err.to_string().contains("bad signature"));
1996 }
1997
1998 #[test]
1999 fn default_config_new_fields() {
2000 let config = ClusterConfig::default();
2001 assert!(config.bind_address.is_none());
2002 assert!(config.seed_peers.is_empty());
2003 assert!(config.identity_key_path.is_none());
2004 assert_eq!(config.peer_add_max_per_source, 1);
2006 assert_eq!(config.peer_add_window_ms, 100);
2007 assert!(config.peer_add_rate_config().is_enabled());
2008 }
2009
2010 #[test]
2011 fn rate_limited_peer_additions() {
2012 let cluster = ClusterMembership::new(ClusterConfig::default());
2015 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
2016
2017 let result = cluster.add_peer(make_peer("node-2", "beta"));
2019 assert!(
2020 matches!(result, Err(ClusterError::RateLimited)),
2021 "expected RateLimited, got {result:?}"
2022 );
2023 }
2024
2025 #[test]
2026 fn rate_limit_is_per_source_not_global() {
2027 let cluster = ClusterMembership::new(ClusterConfig::default());
2029 let mut a = make_peer("node-1", "alpha");
2030 a.address = Some("10.0.0.1:8080".into());
2031 let mut b = make_peer("node-2", "beta");
2032 b.address = Some("10.0.0.2:8080".into());
2033
2034 cluster.add_peer(a).unwrap();
2035 cluster.add_peer(b).unwrap();
2037 assert_eq!(cluster.len(), 2);
2038 }
2039
2040 #[test]
2041 fn rate_limit_configurable_threshold() {
2042 let cluster = ClusterMembership::new(ClusterConfig::default())
2044 .with_peer_add_rate_limit(crate::rate_limit::RateLimitConfig {
2045 max_per_window: 2,
2046 window: Duration::from_secs(60),
2047 });
2048 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
2049 cluster.add_peer(make_peer("node-2", "beta")).unwrap();
2050 let third = cluster.add_peer(make_peer("node-3", "gamma"));
2051 assert!(
2052 matches!(third, Err(ClusterError::RateLimited)),
2053 "expected RateLimited on third same-source add, got {third:?}"
2054 );
2055 }
2056
2057 #[test]
2058 fn rate_limit_source_key_prefers_address() {
2059 let mut peer = make_peer("node-1", "alpha");
2060 peer.address = Some("192.168.1.5:9489".into());
2061 assert_eq!(
2062 ClusterMembership::peer_add_source_key(&peer),
2063 "192.168.1.5:9489"
2064 );
2065 peer.address = None;
2066 assert_eq!(ClusterMembership::peer_add_source_key(&peer), "node-1");
2067 peer.address = Some(String::new());
2068 assert_eq!(ClusterMembership::peer_add_source_key(&peer), "node-1");
2069 }
2070
2071 #[test]
2072 fn rate_limited_error_display() {
2073 let err = ClusterError::RateLimited;
2074 assert!(err.to_string().contains("rate limited"));
2075 }
2076
2077 #[test]
2078 fn validate_known_capabilities() {
2079 let known = vec!["ipc".into(), "mesh".into(), "chain".into()];
2080 let unknown = validate_capabilities(&known);
2081 assert!(unknown.is_empty());
2082 }
2083
2084 #[test]
2085 fn validate_unknown_capabilities() {
2086 let caps = vec!["ipc".into(), "teleport".into(), "quantum".into()];
2087 let unknown = validate_capabilities(&caps);
2088 assert_eq!(unknown, vec!["teleport", "quantum"]);
2089 }
2090
2091 #[test]
2092 fn add_peer_with_unknown_capabilities_succeeds() {
2093 let cluster = make_cluster(ClusterConfig::default());
2094 let mut peer = make_peer("node-1", "alpha");
2095 peer.capabilities = vec!["ipc".into(), "teleport".into()];
2096 cluster.add_peer(peer).unwrap();
2098 assert_eq!(cluster.len(), 1);
2099 }
2100
2101 #[test]
2102 fn add_peer_checked_allows_clean_host() {
2103 let cluster = make_cluster(ClusterConfig::default());
2104 let dir = tempfile::tempdir().unwrap();
2105 let list = crate::revocation::RevocationList::new(dir.path().join("r.json"));
2106 let peer = make_peer("node-1", "alpha");
2107 assert!(cluster.add_peer_checked(peer, &list).is_ok());
2108 assert_eq!(cluster.len(), 1);
2109 }
2110
2111 #[test]
2112 fn add_peer_checked_rejects_revoked_host() {
2113 let cluster = make_cluster(ClusterConfig::default());
2114 let dir = tempfile::tempdir().unwrap();
2115 let list = crate::revocation::RevocationList::new(dir.path().join("r.json"));
2116 list.revoke_host("node-1", "banned");
2117 let peer = make_peer("node-1", "alpha");
2118 let result = cluster.add_peer_checked(peer, &list);
2119 assert!(matches!(result, Err(ClusterError::AuthFailed(_))));
2120 assert_eq!(cluster.len(), 0);
2121 }
2122
2123 fn pairing_tmp_path(name: &str) -> std::path::PathBuf {
2126 std::env::temp_dir().join(format!(
2127 "pairing_test_{name}_{}",
2128 std::time::SystemTime::now()
2129 .duration_since(std::time::UNIX_EPOCH)
2130 .unwrap()
2131 .as_nanos()
2132 ))
2133 }
2134
2135 fn cleanup_pairing(path: &std::path::Path) {
2136 if let Some(parent) = path.parent() {
2137 let _ = std::fs::remove_dir_all(parent);
2138 }
2139 }
2140
2141 #[test]
2142 fn pairing_gate_default_closed() {
2143 let path = pairing_tmp_path("default_closed").join("paired_hosts.json");
2144 let gate = PairingGate::new(path.clone());
2145 assert!(!gate.is_pairing_open());
2146 assert!(!gate.should_accept_peer("unknown-peer"));
2147 cleanup_pairing(&path);
2148 }
2149
2150 #[test]
2151 fn pairing_gate_open_window_accepts() {
2152 let path = pairing_tmp_path("open_window").join("paired_hosts.json");
2153 let gate = PairingGate::new(path.clone());
2154
2155 let result = gate.open_pairing_window(Duration::from_secs(30));
2156 assert_eq!(result.window_secs, 30);
2157 assert!(result.already_paired.is_empty());
2158 assert!(gate.is_pairing_open());
2159
2160 assert!(gate.should_accept_peer("peer-1"));
2162 assert!(gate.should_accept_peer("peer-2"));
2163
2164 let hosts = gate.paired_hosts();
2166 assert!(hosts.contains(&"peer-1".to_owned()));
2167 assert!(hosts.contains(&"peer-2".to_owned()));
2168
2169 cleanup_pairing(&path);
2170 }
2171
2172 #[test]
2173 fn pairing_gate_already_paired_always_accepted() {
2174 let path = pairing_tmp_path("already_paired").join("paired_hosts.json");
2175 let gate = PairingGate::new(path.clone());
2176
2177 gate.open_pairing_window(Duration::from_secs(30));
2179 assert!(gate.should_accept_peer("peer-1"));
2180
2181 gate.open_pairing_window(Duration::from_secs(0));
2183 std::thread::sleep(Duration::from_millis(10));
2184 assert!(!gate.is_pairing_open());
2185
2186 assert!(gate.should_accept_peer("peer-1"));
2188
2189 assert!(!gate.should_accept_peer("unknown"));
2191
2192 cleanup_pairing(&path);
2193 }
2194
2195 #[test]
2196 fn pairing_gate_unpair() {
2197 let path = pairing_tmp_path("unpair").join("paired_hosts.json");
2198 let gate = PairingGate::new(path.clone());
2199
2200 gate.open_pairing_window(Duration::from_secs(30));
2201 gate.should_accept_peer("peer-1");
2202
2203 assert!(gate.unpair("peer-1"));
2204 assert!(!gate.unpair("peer-1")); cleanup_pairing(&path);
2207 }
2208
2209 #[test]
2210 fn pairing_gate_persist_and_load() {
2211 let dir = pairing_tmp_path("persist_load");
2212 let path = dir.join("paired_hosts.json");
2213
2214 {
2216 let gate = PairingGate::new(path.clone());
2217 gate.open_pairing_window(Duration::from_secs(30));
2218 gate.should_accept_peer("peer-a");
2219 gate.should_accept_peer("peer-b");
2220 gate.save().unwrap();
2221 }
2222
2223 {
2225 let gate = PairingGate::new(path.clone());
2226 let count = gate.load().unwrap();
2227 assert_eq!(count, 2);
2228 assert!(gate.paired_hosts().contains(&"peer-a".to_owned()));
2229 assert!(gate.paired_hosts().contains(&"peer-b".to_owned()));
2230 }
2231
2232 let _ = std::fs::remove_dir_all(&dir);
2233 }
2234
2235 #[test]
2236 fn pairing_gate_load_creates_file_if_missing() {
2237 let dir = pairing_tmp_path("load_creates");
2238 let path = dir.join("runtime").join("paired_hosts.json");
2239
2240 let gate = PairingGate::new(path.clone());
2241 let count = gate.load().unwrap();
2242 assert_eq!(count, 0);
2243 assert!(path.exists());
2244
2245 let _ = std::fs::remove_dir_all(&dir);
2246 }
2247
2248 #[test]
2249 fn pairing_window_result_serde() {
2250 let result = PairingWindowResult {
2251 window_secs: 30,
2252 already_paired: vec!["peer-1".into()],
2253 };
2254 let json = serde_json::to_string(&result).unwrap();
2255 let restored: PairingWindowResult = serde_json::from_str(&json).unwrap();
2256 assert_eq!(restored.window_secs, 30);
2257 assert_eq!(restored.already_paired, vec!["peer-1"]);
2258 }
2259
2260 fn persist_tmp_path(suffix: &str) -> PathBuf {
2261 let pid = std::process::id();
2262 let ns = std::time::SystemTime::now()
2263 .duration_since(std::time::UNIX_EPOCH)
2264 .unwrap()
2265 .as_nanos();
2266 let dir = std::env::temp_dir().join(format!("weftos-cluster-{pid}-{ns}-{suffix}"));
2267 std::fs::create_dir_all(&dir).unwrap();
2268 dir.join("cluster_peers.json")
2269 }
2270
2271 #[test]
2272 fn persisted_peers_rehydrate_on_restart() {
2273 let path = persist_tmp_path("rehydrate");
2274
2275 {
2276 let cluster = ClusterMembership::new(ClusterConfig::default())
2277 .with_min_peer_interval(std::time::Duration::ZERO)
2278 .with_persist_path(&path);
2279 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
2280 cluster.add_peer(make_peer("node-2", "beta")).unwrap();
2281 assert_eq!(cluster.len(), 2);
2282 }
2283
2284 assert!(path.exists(), "persist path should exist after add_peer");
2285
2286 let restored = ClusterMembership::new(ClusterConfig::default()).with_persist_path(&path);
2287 assert_eq!(restored.len(), 2, "peers should rehydrate from disk");
2288 assert!(restored.get_peer("node-1").is_some());
2289 assert!(restored.get_peer("node-2").is_some());
2290
2291 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2292 }
2293
2294 #[test]
2295 fn legacy_uuid_peer_ids_are_dropped_on_load_and_from_disk() {
2296 let path = persist_tmp_path("legacy-uuid");
2297 let uuid = "e6182c9e-2dc9-45c3-a93f-e93d978e3dca";
2298 let hex = "c12684d4ee48f647f0468b233a24b2c2";
2299 {
2300 let cluster = ClusterMembership::new(ClusterConfig::default())
2301 .with_min_peer_interval(std::time::Duration::ZERO)
2302 .with_persist_path(&path);
2303 for id in [uuid, hex, "node-1"] {
2304 cluster.add_peer(make_peer(id, id)).unwrap();
2305 }
2306 }
2307 let restored = ClusterMembership::new(ClusterConfig::default()).with_persist_path(&path);
2308 assert!(restored.get_peer(uuid).is_none(), "the UUID entry is dropped");
2309 assert!(restored.get_peer(hex).is_some() && restored.get_peer("node-1").is_some(), "other ids are kept");
2310 let on_disk = std::fs::read_to_string(&path).unwrap();
2311 assert!(!on_disk.contains(uuid), "and it is gone from the file");
2312 assert!(is_legacy_uuid_id(uuid) && !is_legacy_uuid_id(hex) && !is_legacy_uuid_id("node-1"));
2313 assert!(!is_legacy_uuid_id("e6182c9e-2dc9-45c3-a93f-e93d978e3dcz"), "non-hex is not a UUID");
2314 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2315 }
2316
2317 #[test]
2318 fn persist_reflects_remove_and_state_change() {
2319 let path = persist_tmp_path("mutations");
2320
2321 let cluster = ClusterMembership::new(ClusterConfig::default())
2322 .with_min_peer_interval(std::time::Duration::ZERO)
2323 .with_persist_path(&path);
2324 cluster.add_peer(make_peer("node-1", "alpha")).unwrap();
2325 cluster.add_peer(make_peer("node-2", "beta")).unwrap();
2326 cluster.update_state("node-1", NodeState::Suspect).unwrap();
2327 cluster.remove_peer("node-2").unwrap();
2328
2329 let data = std::fs::read_to_string(&path).unwrap();
2330 let file: ClusterPeersFile = serde_json::from_str(&data).unwrap();
2331 assert_eq!(file.peers.len(), 1);
2332 assert_eq!(file.peers[0].id, "node-1");
2333 assert_eq!(file.peers[0].state, NodeState::Suspect);
2334
2335 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2336 }
2337
2338 #[test]
2339 fn with_persist_path_missing_file_is_ok() {
2340 let path = persist_tmp_path("missing")
2341 .parent()
2342 .unwrap()
2343 .join("nope.json");
2344 let cluster = ClusterMembership::new(ClusterConfig::default()).with_persist_path(&path);
2345 assert_eq!(cluster.len(), 0);
2346
2347 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2348 }
2349
2350 #[cfg(feature = "mesh")]
2353 mod mesh_peer_events {
2354 use super::*;
2355 use crate::mesh_discovery::MeshPeerEvent;
2356
2357 fn joined(id: &str, verified: bool) -> MeshPeerEvent {
2358 MeshPeerEvent::Joined {
2359 node_id: id.into(),
2360 address: Some("10.0.0.6:9489".into()),
2361 platform: None,
2362 verified,
2363 }
2364 }
2365
2366 #[test]
2367 fn an_unverified_join_is_held_unverified_not_active() {
2368 let cluster = make_cluster(ClusterConfig::default());
2369 assert!(cluster.apply_mesh_peer_event(&joined("claimed", false)).unwrap());
2370 assert_eq!(cluster.get_peer("claimed").unwrap().state, NodeState::Unverified);
2371 assert!(cluster.active_peers().is_empty(), "an unverified claim is not a member");
2372 assert_eq!(cluster.get_peer("claimed").unwrap().address.as_deref(), Some("10.0.0.6:9489"));
2374 assert_eq!(cluster.count_by_state(&NodeState::Unverified), 1);
2375 }
2376
2377 #[test]
2378 fn an_unverified_announce_cannot_refresh_last_seen_or_move_a_verified_peers_address() {
2379 let cluster = make_cluster(ClusterConfig::default());
2380 cluster.apply_mesh_peer_event(&joined("real", true)).unwrap();
2381 let old = chrono::DateTime::from_timestamp(1_000_000, 0).unwrap();
2382 cluster.peers.get_mut("real").unwrap().last_heartbeat = old;
2383 let spoof = MeshPeerEvent::Joined {
2384 node_id: "real".into(),
2385 address: Some("6.6.6.6:1".into()),
2386 platform: None,
2387 verified: false,
2388 };
2389 cluster.apply_mesh_peer_event(&spoof).unwrap();
2390 let p = cluster.get_peer("real").unwrap();
2391 assert_eq!(p.state, NodeState::Active);
2392 assert_eq!(p.last_heartbeat, old);
2393 assert_eq!(p.address.as_deref(), Some("10.0.0.6:9489"));
2394 cluster.apply_mesh_peer_event(&joined("real", true)).unwrap();
2396 assert!(cluster.get_peer("real").unwrap().last_heartbeat > old);
2397 }
2398
2399 #[test]
2400 fn an_alive_event_for_an_unverified_id_gets_no_liveness_credit() {
2401 let cluster = make_cluster(ClusterConfig::default());
2402 cluster.apply_mesh_peer_event(&joined("claimed", false)).unwrap();
2403 let old = chrono::DateTime::from_timestamp(1_000_000, 0).unwrap();
2404 cluster.peers.get_mut("claimed").unwrap().last_heartbeat = old;
2405 assert!(!cluster
2406 .apply_mesh_peer_event(&MeshPeerEvent::Alive { node_id: "claimed".into() })
2407 .unwrap());
2408 assert_eq!(cluster.get_peer("claimed").unwrap().last_heartbeat, old);
2409 cluster.apply_mesh_peer_event(&joined("real", true)).unwrap();
2411 cluster.peers.get_mut("real").unwrap().last_heartbeat = old;
2412 cluster.apply_mesh_peer_event(&MeshPeerEvent::Alive { node_id: "real".into() }).unwrap();
2413 assert!(cluster.get_peer("real").unwrap().last_heartbeat > old);
2414 }
2415
2416 #[test]
2417 fn heartbeats_and_health_events_never_promote_an_unverified_peer() {
2418 let cluster = make_cluster(ClusterConfig::default());
2419 cluster.apply_mesh_peer_event(&joined("claimed", false)).unwrap();
2420 for ev in [
2421 MeshPeerEvent::Alive { node_id: "claimed".into() },
2422 MeshPeerEvent::Suspect { node_id: "claimed".into() },
2423 MeshPeerEvent::Alive { node_id: "claimed".into() },
2424 MeshPeerEvent::Recovered {
2425 node_id: "claimed".into(),
2426 address: None,
2427 verified: false,
2428 },
2429 ] {
2430 cluster.apply_mesh_peer_event(&ev).unwrap();
2431 assert_eq!(cluster.get_peer("claimed").unwrap().state, NodeState::Unverified, "{ev:?}");
2432 }
2433 cluster
2435 .apply_mesh_peer_event(&MeshPeerEvent::Left { node_id: "claimed".into() })
2436 .unwrap();
2437 assert!(cluster.get_peer("claimed").is_none());
2438 }
2439
2440 #[test]
2441 fn only_a_verified_join_makes_an_unverified_peer_active() {
2442 let cluster = make_cluster(ClusterConfig::default());
2443 cluster.apply_mesh_peer_event(&joined("p", false)).unwrap();
2444 cluster.apply_mesh_peer_event(&joined("p", true)).unwrap();
2445 assert_eq!(cluster.get_peer("p").unwrap().state, NodeState::Active);
2446 assert_eq!(cluster.active_peers(), vec!["p".to_string()]);
2447 }
2448
2449 #[test]
2450 fn an_unverified_event_does_not_demote_an_active_peer() {
2451 let cluster = make_cluster(ClusterConfig::default());
2454 cluster.apply_mesh_peer_event(&joined("op", true)).unwrap();
2455 cluster.apply_mesh_peer_event(&joined("op", false)).unwrap();
2456 assert_eq!(cluster.get_peer("op").unwrap().state, NodeState::Active);
2457 cluster.update_state("op", NodeState::Unreachable).unwrap();
2459 cluster.apply_mesh_peer_event(&joined("op", false)).unwrap();
2460 assert_eq!(cluster.get_peer("op").unwrap().state, NodeState::Unverified);
2461 }
2462
2463 #[test]
2464 fn an_unreachable_unverified_peer_is_dropped_not_left_listed() {
2465 let cluster = make_cluster(ClusterConfig::default());
2466 cluster.apply_mesh_peer_event(&joined("claimed", false)).unwrap();
2467 cluster
2468 .apply_mesh_peer_event(&MeshPeerEvent::Unreachable { node_id: "claimed".into() })
2469 .unwrap();
2470 assert!(cluster.get_peer("claimed").is_none());
2471 cluster.apply_mesh_peer_event(&joined("member", true)).unwrap();
2473 cluster
2474 .apply_mesh_peer_event(&MeshPeerEvent::Unreachable { node_id: "member".into() })
2475 .unwrap();
2476 assert_eq!(cluster.get_peer("member").unwrap().state, NodeState::Unreachable);
2477 }
2478
2479 #[test]
2480 fn the_reaper_expires_only_stale_unverified_peers() {
2481 let cluster = make_cluster(ClusterConfig::default());
2482 cluster.apply_mesh_peer_event(&joined("old-claim", false)).unwrap();
2483 cluster.apply_mesh_peer_event(&joined("fresh-claim", false)).unwrap();
2484 cluster.apply_mesh_peer_event(&joined("old-member", true)).unwrap();
2485 let long_ago = Utc::now() - chrono::Duration::hours(3);
2486 for id in ["old-claim", "old-member"] {
2487 cluster.peers.get_mut(id).unwrap().last_heartbeat = long_ago;
2488 }
2489 assert_eq!(cluster.expire_unverified(UNVERIFIED_TTL), 1);
2490 assert!(cluster.get_peer("old-claim").is_none());
2491 assert!(cluster.get_peer("fresh-claim").is_some(), "recently heard from");
2492 assert!(cluster.get_peer("old-member").is_some(), "members are not the reaper's");
2493 }
2494
2495 #[test]
2496 fn an_older_event_without_the_flag_reads_as_unverified() {
2497 let ev: MeshPeerEvent =
2498 serde_json::from_str(r#"{"Joined":{"node_id":"n","address":null,"platform":null}}"#).unwrap();
2499 assert!(matches!(ev, MeshPeerEvent::Joined { verified: false, .. }));
2500 }
2501
2502 #[cfg(feature = "cluster")]
2503 #[tokio::test]
2504 async fn the_ruvector_manager_only_learns_of_verified_nodes() {
2505 use crate::cluster::ClusterService;
2506 use clawft_types::config::ClusterNetworkConfig;
2507 use ruvector_cluster::StaticDiscovery;
2508
2509 let membership = Arc::new(make_cluster(ClusterConfig {
2510 node_id: "local".into(),
2511 ..Default::default()
2512 }));
2513 let svc = Arc::new(
2514 ClusterService::new(
2515 ClusterNetworkConfig::default(),
2516 "local".into(),
2517 Box::new(StaticDiscovery::new(vec![])),
2518 Arc::clone(&membership),
2519 )
2520 .expect("cluster service"),
2521 );
2522 svc.apply_mesh_peer_event_async(&joined("claimed", false)).await.unwrap();
2523 assert!(membership.get_peer("claimed").is_some());
2524 assert!(!svc.list_nodes().iter().any(|n| n.node_id == "claimed"));
2525 svc.apply_mesh_peer_event_async(&joined("real", true)).await.unwrap();
2526 assert!(svc.list_nodes().iter().any(|n| n.node_id == "real"));
2527 }
2528
2529 #[test]
2530 fn mesh_join_adds_active_peer() {
2531 let cluster = make_cluster(ClusterConfig::default());
2532 let changed = cluster
2533 .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2534 node_id: "peer-a".into(),
2535 address: Some("10.0.0.5:9489".into()),
2536 platform: Some("edge".into()),
2537 verified: true,
2538 })
2539 .unwrap();
2540 assert!(changed);
2541 let peer = cluster.get_peer("peer-a").expect("joined peer");
2542 assert_eq!(peer.state, NodeState::Active);
2543 assert_eq!(peer.address.as_deref(), Some("10.0.0.5:9489"));
2544 assert_eq!(peer.platform, NodePlatform::Edge);
2545 assert!(peer.capabilities.contains(&"mesh".into()));
2546 }
2547
2548 #[test]
2549 fn mesh_leave_removes_peer() {
2550 let cluster = make_cluster(ClusterConfig::default());
2551 cluster
2552 .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2553 node_id: "peer-b".into(),
2554 address: None,
2555 platform: None,
2556 verified: true,
2557 })
2558 .unwrap();
2559 assert_eq!(cluster.len(), 1);
2560
2561 let changed = cluster
2562 .apply_mesh_peer_event(&MeshPeerEvent::Left {
2563 node_id: "peer-b".into(),
2564 })
2565 .unwrap();
2566 assert!(changed);
2567 assert!(cluster.get_peer("peer-b").is_none());
2568 assert_eq!(cluster.len(), 0);
2569 }
2570
2571 #[test]
2572 fn mesh_partition_marks_unreachable_then_recovery() {
2573 let cluster = make_cluster(ClusterConfig::default());
2574 cluster
2575 .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2576 node_id: "peer-p".into(),
2577 address: Some("10.1.0.1:9489".into()),
2578 platform: None,
2579 verified: true,
2580 })
2581 .unwrap();
2582
2583 cluster
2584 .apply_mesh_peer_event(&MeshPeerEvent::Unreachable {
2585 node_id: "peer-p".into(),
2586 })
2587 .unwrap();
2588 assert_eq!(
2589 cluster.get_peer("peer-p").unwrap().state,
2590 NodeState::Unreachable
2591 );
2592
2593 cluster
2595 .apply_mesh_peer_event(&MeshPeerEvent::Recovered {
2596 node_id: "peer-p".into(),
2597 address: Some("10.1.0.1:9489".into()),
2598 verified: true,
2599 })
2600 .unwrap();
2601 let peer = cluster.get_peer("peer-p").unwrap();
2602 assert_eq!(peer.state, NodeState::Active);
2603 assert_eq!(cluster.len(), 1);
2604 }
2605
2606 #[test]
2607 fn mesh_suspect_then_alive() {
2608 let cluster = make_cluster(ClusterConfig::default());
2609 cluster
2610 .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2611 node_id: "peer-s".into(),
2612 address: None,
2613 platform: None,
2614 verified: true,
2615 })
2616 .unwrap();
2617 cluster
2618 .apply_mesh_peer_event(&MeshPeerEvent::Suspect {
2619 node_id: "peer-s".into(),
2620 })
2621 .unwrap();
2622 assert_eq!(
2623 cluster.get_peer("peer-s").unwrap().state,
2624 NodeState::Suspect
2625 );
2626 cluster
2627 .apply_mesh_peer_event(&MeshPeerEvent::Alive {
2628 node_id: "peer-s".into(),
2629 })
2630 .unwrap();
2631 assert_eq!(cluster.get_peer("peer-s").unwrap().state, NodeState::Active);
2632 }
2633
2634 #[test]
2635 fn mesh_join_is_idempotent_upsert() {
2636 let cluster = make_cluster(ClusterConfig::default());
2637 for _ in 0..3 {
2638 cluster
2639 .apply_mesh_peer_event(&MeshPeerEvent::Joined {
2640 node_id: "peer-i".into(),
2641 address: Some("1.2.3.4:9".into()),
2642 platform: None,
2643 verified: true,
2644 })
2645 .unwrap();
2646 }
2647 assert_eq!(cluster.len(), 1);
2648 assert_eq!(
2649 cluster.get_peer("peer-i").unwrap().state,
2650 NodeState::Active
2651 );
2652 }
2653
2654 #[tokio::test]
2655 async fn mesh_event_bus_drives_membership_listener() {
2656 use crate::mesh_discovery::MeshPeerEventBus;
2657
2658 let cluster = Arc::new(make_cluster(ClusterConfig::default()));
2659 let bus = MeshPeerEventBus::new();
2660 let rx = bus.subscribe();
2661 cluster.spawn_mesh_peer_listener(rx);
2662
2663 bus.emit(MeshPeerEvent::Joined {
2664 node_id: "async-peer".into(),
2665 address: Some("127.0.0.1:1".into()),
2666 platform: None,
2667 verified: true,
2668 });
2669 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2671 assert!(cluster.get_peer("async-peer").is_some());
2672
2673 bus.emit(MeshPeerEvent::Left {
2674 node_id: "async-peer".into(),
2675 });
2676 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2677 assert!(cluster.get_peer("async-peer").is_none());
2678 }
2679
2680 #[cfg(feature = "cluster")]
2681 #[tokio::test]
2682 async fn cluster_service_applies_mesh_join_leave_async() {
2683 use crate::cluster::ClusterService;
2684 use clawft_types::config::ClusterNetworkConfig;
2685 use ruvector_cluster::StaticDiscovery;
2686
2687 let membership = Arc::new(make_cluster(ClusterConfig {
2688 node_id: "local".into(),
2689 ..Default::default()
2690 }));
2691 let svc = Arc::new(
2692 ClusterService::new(
2693 ClusterNetworkConfig::default(),
2694 "local".into(),
2695 Box::new(StaticDiscovery::new(vec![])),
2696 Arc::clone(&membership),
2697 )
2698 .expect("cluster service"),
2699 );
2700
2701 svc.apply_mesh_peer_event_async(&MeshPeerEvent::Joined {
2702 node_id: "remote-1".into(),
2703 address: Some("127.0.0.1:9471".into()),
2704 platform: None,
2705 verified: true,
2706 })
2707 .await
2708 .unwrap();
2709 assert!(membership.get_peer("remote-1").is_some());
2710 assert!(svc.list_nodes().iter().any(|n| n.node_id == "remote-1"));
2711
2712 svc.apply_mesh_peer_event_async(&MeshPeerEvent::Left {
2713 node_id: "remote-1".into(),
2714 })
2715 .await
2716 .unwrap();
2717 assert!(membership.get_peer("remote-1").is_none());
2718 }
2719
2720 #[cfg(feature = "cluster")]
2721 #[tokio::test]
2722 async fn cluster_service_subscribes_to_mesh_stream() {
2723 use crate::cluster::ClusterService;
2724 use crate::mesh_discovery::MeshPeerEventBus;
2725 use clawft_types::config::ClusterNetworkConfig;
2726 use ruvector_cluster::StaticDiscovery;
2727
2728 let membership = Arc::new(make_cluster(ClusterConfig {
2729 node_id: "coord".into(),
2730 ..Default::default()
2731 }));
2732 let svc = Arc::new(
2733 ClusterService::new(
2734 ClusterNetworkConfig::default(),
2735 "coord".into(),
2736 Box::new(StaticDiscovery::new(vec![])),
2737 Arc::clone(&membership),
2738 )
2739 .expect("cluster service"),
2740 );
2741
2742 let bus = MeshPeerEventBus::new();
2743 svc.spawn_mesh_peer_listener(bus.subscribe());
2744 assert!(svc.mesh_listener_started());
2745
2746 bus.emit(MeshPeerEvent::Joined {
2747 node_id: "stream-peer".into(),
2748 address: Some("10.0.0.9:9489".into()),
2749 platform: None,
2750 verified: true,
2751 });
2752 tokio::time::sleep(std::time::Duration::from_millis(80)).await;
2753 assert_eq!(
2754 membership.get_peer("stream-peer").unwrap().state,
2755 NodeState::Active
2756 );
2757
2758 bus.emit(MeshPeerEvent::Unreachable {
2760 node_id: "stream-peer".into(),
2761 });
2762 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2763 assert_eq!(
2764 membership.get_peer("stream-peer").unwrap().state,
2765 NodeState::Unreachable
2766 );
2767
2768 bus.emit(MeshPeerEvent::Recovered {
2769 node_id: "stream-peer".into(),
2770 address: Some("10.0.0.9:9489".into()),
2771 verified: true,
2772 });
2773 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2774 assert_eq!(
2775 membership.get_peer("stream-peer").unwrap().state,
2776 NodeState::Active
2777 );
2778 }
2779 }
2780}
2781
2782#[cfg(test)]
2783#[cfg(any(feature = "mesh", feature = "exochain"))]
2784mod mesh_tests {
2785 use super::*;
2786
2787 #[test]
2788 fn node_identity_unique_ids() {
2789 let id1 = NodeIdentity::generate();
2790 let id2 = NodeIdentity::generate();
2791 assert_ne!(id1.node_id(), id2.node_id());
2792 }
2793
2794 #[test]
2795 fn node_identity_sign_verify() {
2796 use ed25519_dalek::Verifier;
2797
2798 let identity = NodeIdentity::generate();
2799 let data = b"hello mesh";
2800 let sig = identity.sign(data);
2801 assert!(identity.public_key().verify(data, &sig).is_ok());
2802 }
2803
2804 #[test]
2805 fn node_identity_id_is_32_hex_chars() {
2806 let identity = NodeIdentity::generate();
2807 let nid = identity.node_id();
2808 assert_eq!(nid.len(), 32, "node_id should be 32 hex chars (16 bytes)");
2809 assert!(
2810 nid.chars().all(|c| c.is_ascii_hexdigit()),
2811 "node_id must be hex: {nid}"
2812 );
2813 }
2814}