Skip to main content

consortium/
propagation.rs

1//! Tree propagation engine.
2//!
3//! Rust implementation of `ClusterShell.Propagation`.
4//!
5//! This module provides:
6//! - [`PropagationTreeRouter`]: Routes commands through the topology tree by
7//!   computing next-hop gateways and managing fanout/load-balancing.
8//! - [`PropagationChannel`]: State machine for gateway-to-gateway channel
9//!   lifecycle (configuration, control, gather phases).
10
11use std::collections::{HashMap, VecDeque};
12
13use crate::communication::{Channel, Message, MessageProcessingError};
14use crate::node_set::NodeSet;
15use crate::topology::{TopologyError, TopologyTree};
16
17use thiserror::Error;
18
19// ============================================================================
20// Errors
21// ============================================================================
22
23/// Errors raised during route resolving operations.
24#[derive(Error, Debug)]
25pub enum RouteResolvingError {
26    /// Invalid root or gateway node.
27    #[error("Invalid root or gateway node: {0}")]
28    InvalidRoot(String),
29
30    /// No route available to the destination.
31    #[error("No route available to {0}")]
32    NoRoute(String),
33
34    /// Destination host is unreachable.
35    #[error("Invalid destination: {0}, host is unreachable")]
36    Unreachable(String),
37
38    /// Source equals destination.
39    #[error("Invalid resolution request: {0} -> {1}")]
40    SameSourceDest(String, String),
41
42    /// Topology error.
43    #[error("Topology error: {0}")]
44    Topology(#[from] TopologyError),
45
46    /// NodeSet error.
47    #[error("NodeSet error: {0}")]
48    NodeSet(#[from] crate::node_set::NodeSetError),
49}
50
51// ============================================================================
52// PropagationTreeRouter
53// ============================================================================
54
55/// A routing table entry: (reachable network, next-hop gateways).
56#[derive(Debug, Clone)]
57pub struct RouteEntry {
58    /// Set of all nodes reachable through this route (transitive children).
59    network: NodeSet,
60    /// Direct next-hop gateway(s) for this route.
61    nexthops: NodeSet,
62}
63
64/// Performs route resolving operations within a propagation tree.
65///
66/// The router parses the topology tree to generate a routing table, then
67/// provides [`next_hop()`](PropagationTreeRouter::next_hop) and
68/// [`dispatch()`](PropagationTreeRouter::dispatch) methods for directing
69/// messages to the correct gateway.
70///
71/// Load balancing is achieved by tracking the number of connections
72/// (fanin) per gateway node.
73#[derive(Debug)]
74pub struct PropagationTreeRouter {
75    /// The root node of this router.
76    root: String,
77    /// Fanout setting (0 = no limit).
78    fanout: usize,
79    /// Connections count per gateway node (for load balancing).
80    nodes_fanin: HashMap<String, usize>,
81    /// Routing table: list of (network, nexthops).
82    table: Vec<RouteEntry>,
83    /// Set of nodes marked as unreachable.
84    unreachable_hosts: NodeSet,
85}
86
87impl PropagationTreeRouter {
88    /// Create a new router from a root node and topology tree.
89    ///
90    /// The routing table is generated by traversing the tree from the root
91    /// node's position downward.
92    ///
93    /// # Errors
94    /// Returns [`RouteResolvingError::InvalidRoot`] if the root node cannot
95    /// be found in the topology.
96    pub fn new(
97        root: &str,
98        topology: &TopologyTree,
99        fanout: usize,
100    ) -> Result<Self, RouteResolvingError> {
101        let mut router = Self {
102            root: root.to_string(),
103            fanout,
104            nodes_fanin: HashMap::new(),
105            table: Vec::new(),
106            unreachable_hosts: NodeSet::new(),
107        };
108        router.table_generate(root, topology)?;
109        Ok(router)
110    }
111
112    /// Generate the routing table from the topology tree.
113    ///
114    /// For each direct child group of the root, compute the set of all
115    /// transitively reachable nodes (the "network") and store it alongside
116    /// the next-hop gateway nodeset.
117    fn table_generate(
118        &mut self,
119        root: &str,
120        topology: &TopologyTree,
121    ) -> Result<(), RouteResolvingError> {
122        // Validate the root exists in the topology
123        let _root_group = topology
124            .find_nodegroup(root)
125            .map_err(|_| RouteResolvingError::InvalidRoot(root.to_string()))?;
126
127        self.table.clear();
128
129        // We need to find the root TopologyNodeGroup and enumerate its
130        // direct children. Each child group becomes a route entry, with
131        // its "network" being all transitively reachable nodes below it.
132        let root_tng = topology
133            .root()
134            .ok_or_else(|| RouteResolvingError::InvalidRoot(root.to_string()))?;
135
136        // The root of the topology tree should contain our root node.
137        // If the root_tng doesn't contain our node, we need to find
138        // the subtree rooted at our node.
139        let subtree_root = Self::find_group_containing(root_tng, root)
140            .ok_or_else(|| RouteResolvingError::InvalidRoot(root.to_string()))?;
141
142        for child in subtree_root.children() {
143            let mut network = NodeSet::new();
144            Self::collect_reachable(child, &mut network);
145            self.table.push(RouteEntry {
146                network,
147                nexthops: child.nodeset.clone(),
148            });
149        }
150
151        Ok(())
152    }
153
154    /// Find the TopologyNodeGroup that contains the given node name.
155    fn find_group_containing<'a>(
156        group: &'a crate::topology::TopologyNodeGroup,
157        node: &str,
158    ) -> Option<&'a crate::topology::TopologyNodeGroup> {
159        if group.nodeset.contains(node) {
160            return Some(group);
161        }
162        for child in group.children() {
163            if let Some(found) = Self::find_group_containing(child, node) {
164                return Some(found);
165            }
166        }
167        None
168    }
169
170    /// Recursively collect all nodes reachable below a group (its children,
171    /// grandchildren, etc.) into `dest`. Does NOT include the group itself.
172    fn collect_reachable(group: &crate::topology::TopologyNodeGroup, dest: &mut NodeSet) {
173        for child in group.children() {
174            dest.update(&child.nodeset);
175            Self::collect_reachable(child, dest);
176        }
177    }
178
179    /// Dispatch nodes from a target nodeset to directly connected gateways.
180    ///
181    /// Acts as an iterator, yielding `(gateway_nodeset, target_nodes)` pairs.
182    /// Provides load balancing between gateways. Remaining nodes that don't
183    /// match any route are treated as directly connected.
184    pub fn dispatch(&self, dst: &NodeSet) -> Vec<(NodeSet, NodeSet)> {
185        let mut remaining = dst.clone();
186        let mut results = Vec::new();
187
188        // Check for remote targets requiring a gateway
189        for entry in &self.table {
190            let dst_inter = entry.network.intersection(&remaining);
191            if !dst_inter.is_empty() {
192                remaining.difference_update(&dst_inter);
193                // For each host in the intersection, find best next hop
194                for host_name in dst_inter.iter() {
195                    if let Ok(hop) = self.next_hop_str(&host_name) {
196                        let mut hop_ns = NodeSet::new();
197                        let _ = hop_ns.update_str(&hop);
198                        let mut host_ns = NodeSet::new();
199                        let _ = host_ns.update_str(&host_name);
200                        results.push((hop_ns, host_ns));
201                    }
202                }
203            }
204        }
205
206        // Remaining nodes are directly connected
207        if !remaining.is_empty() {
208            results.push((remaining.clone(), remaining));
209        }
210
211        results
212    }
213
214    /// Perform next-hop resolution for a single destination node.
215    ///
216    /// Returns the best gateway to use based on current load (connections
217    /// tracked in `nodes_fanin`).
218    pub fn next_hop(&mut self, dst: &NodeSet) -> Result<NodeSet, RouteResolvingError> {
219        let dst_str = dst.to_string();
220
221        // Check unreachable
222        if !self.unreachable_hosts.intersection(dst).is_empty() {
223            return Err(RouteResolvingError::Unreachable(dst_str));
224        }
225
226        // Can't resolve if source == destination
227        if self.root == dst_str {
228            return Err(RouteResolvingError::SameSourceDest(
229                self.root.clone(),
230                dst_str,
231            ));
232        }
233
234        for entry in &self.table {
235            // destination contained in current network
236            if !entry.network.intersection(dst).is_empty() {
237                let res = self.best_next_hop(&entry.nexthops)?;
238                let res_str = res.to_string();
239                *self.nodes_fanin.entry(res_str).or_insert(0) += dst.len();
240                return Ok(res);
241            }
242            // destination is a direct next-hop
243            if !entry.nexthops.intersection(dst).is_empty() {
244                return Ok(dst.clone());
245            }
246        }
247
248        Err(RouteResolvingError::NoRoute(dst_str))
249    }
250
251    /// String-based next_hop for convenience (non-mutating for dispatch).
252    fn next_hop_str(&self, dst: &str) -> Result<String, RouteResolvingError> {
253        // Check unreachable
254        if self.unreachable_hosts.contains(dst) {
255            return Err(RouteResolvingError::Unreachable(dst.to_string()));
256        }
257
258        if self.root == dst {
259            return Err(RouteResolvingError::SameSourceDest(
260                self.root.clone(),
261                dst.to_string(),
262            ));
263        }
264
265        let dst_ns = NodeSet::parse(dst)?;
266
267        for entry in &self.table {
268            if !entry.network.intersection(&dst_ns).is_empty() {
269                let res = self.best_next_hop(&entry.nexthops)?;
270                return Ok(res.to_string());
271            }
272            if !entry.nexthops.intersection(&dst_ns).is_empty() {
273                return Ok(dst.to_string());
274            }
275        }
276
277        Err(RouteResolvingError::NoRoute(dst.to_string()))
278    }
279
280    /// Mark a node as unreachable.
281    pub fn mark_unreachable(&mut self, dst: &str) {
282        let _ = self.unreachable_hosts.update_str(dst);
283    }
284
285    /// Find the best next-hop from a set of candidates.
286    ///
287    /// Selects the gateway with the fewest current connections (load balancing).
288    fn best_next_hop(&self, candidates: &NodeSet) -> Result<NodeSet, RouteResolvingError> {
289        let available = candidates.difference(&self.unreachable_hosts);
290        if available.is_empty() {
291            return Err(RouteResolvingError::NoRoute(
292                "all candidates unreachable".into(),
293            ));
294        }
295
296        let mut best: Option<String> = None;
297        let mut best_connections = usize::MAX;
298
299        for host in available.iter() {
300            let connections = self.nodes_fanin.get(&host).copied().unwrap_or(0);
301            if connections < best_connections {
302                best_connections = connections;
303                best = Some(host);
304            }
305        }
306
307        match best {
308            Some(host) => {
309                let mut ns = NodeSet::new();
310                let _ = ns.update_str(&host);
311                Ok(ns)
312            }
313            None => Err(RouteResolvingError::NoRoute(
314                "no candidate available".into(),
315            )),
316        }
317    }
318
319    /// Get the root node name.
320    pub fn root(&self) -> &str {
321        &self.root
322    }
323
324    /// Get the fanout setting.
325    pub fn fanout(&self) -> usize {
326        self.fanout
327    }
328
329    /// Get the routing table for inspection.
330    pub fn table(&self) -> &[RouteEntry] {
331        &self.table
332    }
333}
334
335// ============================================================================
336// PropagationChannel
337// ============================================================================
338
339/// State of a PropagationChannel's finite state machine.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum ChannelState {
342    /// Initial state — channel not yet opened.
343    Init,
344    /// Configuration sent, waiting for ACK.
345    Config,
346    /// Control message sent, waiting for ACK.
347    Control,
348    /// Gathering results from the subtree.
349    Gather,
350    /// Channel is closed.
351    Closed,
352}
353
354/// Admin-node propagation channel.
355///
356/// Manages the lifecycle of communication with a gateway node:
357/// 1. **Init**: Send configuration (serialized topology).
358/// 2. **Config**: Wait for ACK, then send control message (command).
359/// 3. **Control**: Wait for ACK, then enter gather state.
360/// 4. **Gather**: Collect results (stdout, stderr, retcodes, timeouts, routing).
361///
362/// This is a state machine that drives the [`Channel`] transport.
363#[derive(Debug)]
364pub struct PropagationChannel {
365    /// The underlying communication channel.
366    pub channel: Channel,
367    /// The gateway node this channel connects to.
368    pub gateway: String,
369    /// Current FSM state.
370    pub state: ChannelState,
371    /// Queue of control messages waiting to be sent.
372    send_queue: VecDeque<Message>,
373    /// Last return code from the gateway process.
374    pub last_rc: Option<i32>,
375    /// Write history for tracking ACKs to write operations.
376    /// Each entry: (msgid, nodes, byte_count).
377    write_history: VecDeque<(u64, String, usize)>,
378    /// Collected results: (node, stream_name, data).
379    pub results: Vec<PropagationResult>,
380}
381
382/// A result received from the propagation subtree.
383#[derive(Debug, Clone)]
384pub enum PropagationResult {
385    /// Standard output line from a node.
386    StdOut { node: String, data: Vec<u8> },
387    /// Standard error line from a node.
388    StdErr { node: String, data: Vec<u8> },
389    /// Return code from a node.
390    Retcode { node: String, rc: i32 },
391    /// Timeout for a node.
392    Timeout { node: String },
393    /// Routing event.
394    Routing {
395        event: String,
396        gateway: String,
397        targets: String,
398    },
399}
400
401impl PropagationChannel {
402    /// Create a new propagation channel for a gateway.
403    pub fn new(gateway: &str) -> Self {
404        Self {
405            channel: Channel::new(true), // initiator
406            gateway: gateway.to_string(),
407            state: ChannelState::Init,
408            send_queue: VecDeque::new(),
409            last_rc: None,
410            write_history: VecDeque::new(),
411            results: Vec::new(),
412        }
413    }
414
415    /// Start the propagation channel — send configuration.
416    ///
417    /// Serializes the topology and sends it as a ConfigurationMessage.
418    pub fn start(&mut self, topology_bytes: &[u8]) {
419        // Open the channel
420        self.channel.opened = true;
421
422        // Send configuration message
423        let mut cfg = Message::configuration(&self.gateway);
424        cfg.data_encode(topology_bytes);
425        self.channel.send(&cfg);
426        self.state = ChannelState::Config;
427    }
428
429    /// Enqueue a control message for sending.
430    ///
431    /// If the channel is already set up and the queue is empty, the message
432    /// is sent immediately. Otherwise it's queued for later.
433    pub fn send_queued(&mut self, msg: Message) {
434        if self.channel.setup && self.send_queue.is_empty() {
435            self.channel.send(&msg);
436        } else {
437            self.send_queue.push_back(msg);
438        }
439    }
440
441    /// Send one queued message, if any.
442    fn send_dequeue(&mut self) {
443        if let Some(msg) = self.send_queue.pop_front() {
444            self.channel.send(&msg);
445        }
446    }
447
448    /// Create and enqueue a shell command for execution through the channel.
449    pub fn shell(&mut self, nodes: &str, command: &[u8], worker_id: u64) {
450        let mut ctl = Message::control(worker_id);
451        if let Message::Control {
452            ref mut action,
453            ref mut target,
454            ..
455        } = ctl
456        {
457            *action = "shell".to_string();
458            *target = nodes.to_string();
459        }
460        ctl.data_encode(command);
461        self.send_queued(ctl);
462    }
463
464    /// Create and enqueue a write operation through the channel.
465    pub fn write(&mut self, nodes: &str, buf: &[u8], worker_id: u64) {
466        let mut ctl = Message::control(worker_id);
467        if let Message::Control {
468            ref mut action,
469            ref mut target,
470            ..
471        } = ctl
472        {
473            *action = "write".to_string();
474            *target = nodes.to_string();
475        }
476        let msgid = ctl.msgid();
477        ctl.data_encode(buf);
478        self.write_history
479            .push_back((msgid, nodes.to_string(), buf.len()));
480        self.send_queued(ctl);
481    }
482
483    /// Create and enqueue an EOF operation through the channel.
484    pub fn set_write_eof(&mut self, nodes: &str, worker_id: u64) {
485        let mut ctl = Message::control(worker_id);
486        if let Message::Control {
487            ref mut action,
488            ref mut target,
489            ..
490        } = ctl
491        {
492            *action = "eof".to_string();
493            *target = nodes.to_string();
494        }
495        self.send_queued(ctl);
496    }
497
498    /// Process an incoming message according to the current FSM state.
499    ///
500    /// Returns any propagation results generated by this message.
501    pub fn recv(&mut self, msg: Message) -> Result<Vec<PropagationResult>, MessageProcessingError> {
502        let mut new_results = Vec::new();
503
504        match msg.ident() {
505            "END" => {
506                self.state = ChannelState::Closed;
507                return Ok(new_results);
508            }
509            "CHA" => {
510                self.channel.opened = true;
511                return Ok(new_results);
512            }
513            _ => {}
514        }
515
516        match self.state {
517            ChannelState::Config => {
518                // Waiting for ACK to configuration
519                if msg.ident() == "ACK" {
520                    self.channel.setup = true;
521                    self.state = ChannelState::Gather;
522                    self.send_dequeue();
523                }
524            }
525            ChannelState::Control | ChannelState::Gather => {
526                match msg.ident() {
527                    "ACK" => {
528                        // Check if ACK matches a write history entry
529                        if let Message::Ack { ack, .. } = &msg {
530                            if let Some(back) = self.write_history.back() {
531                                if back.0 == *ack {
532                                    let _ = self.write_history.pop_back();
533                                }
534                            }
535                        }
536                        self.send_dequeue();
537                    }
538                    "OUT" => {
539                        if let Some(nodes_str) = msg.nodes() {
540                            if let Ok(decoded) = msg.data_decode() {
541                                let ns = NodeSet::parse(nodes_str).unwrap_or_default();
542                                for node in ns.iter() {
543                                    let result = PropagationResult::StdOut {
544                                        node,
545                                        data: decoded.clone(),
546                                    };
547                                    new_results.push(result);
548                                }
549                            }
550                        }
551                    }
552                    "SER" => {
553                        if let Some(nodes_str) = msg.nodes() {
554                            if let Ok(decoded) = msg.data_decode() {
555                                let ns = NodeSet::parse(nodes_str).unwrap_or_default();
556                                for node in ns.iter() {
557                                    let result = PropagationResult::StdErr {
558                                        node,
559                                        data: decoded.clone(),
560                                    };
561                                    new_results.push(result);
562                                }
563                            }
564                        }
565                    }
566                    "RET" => {
567                        if let Message::Retcode { retcode, nodes, .. } = &msg {
568                            let ns = NodeSet::parse(nodes).unwrap_or_default();
569                            for node in ns.iter() {
570                                new_results.push(PropagationResult::Retcode { node, rc: *retcode });
571                            }
572                        }
573                    }
574                    "TIM" => {
575                        if let Some(nodes_str) = msg.nodes() {
576                            let ns = NodeSet::parse(nodes_str).unwrap_or_default();
577                            for node in ns.iter() {
578                                new_results.push(PropagationResult::Timeout { node });
579                            }
580                        }
581                    }
582                    "RTR" => {
583                        if let Message::Routing {
584                            event,
585                            gateway,
586                            targets,
587                            ..
588                        } = &msg
589                        {
590                            new_results.push(PropagationResult::Routing {
591                                event: event.clone(),
592                                gateway: gateway.clone(),
593                                targets: targets.clone(),
594                            });
595                        }
596                    }
597                    "ERR" => {
598                        if let Message::Error { reason, .. } = &msg {
599                            return Err(MessageProcessingError::ParseError(format!(
600                                "{}: {}",
601                                self.gateway, reason
602                            )));
603                        }
604                    }
605                    _ => {
606                        // Ignore unknown messages in gather state
607                    }
608                }
609            }
610            _ => {}
611        }
612
613        self.results.extend(new_results.clone());
614        Ok(new_results)
615    }
616
617    /// Handle the gateway process closing.
618    pub fn ev_hup(&mut self, rc: i32) {
619        self.last_rc = Some(rc);
620    }
621
622    /// Handle the channel closing.
623    pub fn ev_close(&mut self) {
624        self.state = ChannelState::Closed;
625    }
626
627    /// Get the current channel state.
628    pub fn current_state(&self) -> ChannelState {
629        self.state
630    }
631
632    /// Check if the channel is in gather state (ready for results).
633    pub fn is_gathering(&self) -> bool {
634        self.state == ChannelState::Gather
635    }
636
637    /// Check if the channel is closed.
638    pub fn is_closed(&self) -> bool {
639        self.state == ChannelState::Closed
640    }
641}
642
643// ============================================================================
644// Tests
645// ============================================================================
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::topology::{TopologyGraph, TopologyTree};
651
652    /// Helper to build a simple topology tree for testing.
653    /// Structure: admin -> gw1 -> node[1-4], gw2 -> node[5-8]
654    fn build_test_topology() -> TopologyTree {
655        let mut graph = TopologyGraph::new();
656        let admin = NodeSet::parse("admin").unwrap();
657        let gw1 = NodeSet::parse("gw1").unwrap();
658        let gw2 = NodeSet::parse("gw2").unwrap();
659        let nodes1 = NodeSet::parse("node[1-4]").unwrap();
660        let nodes2 = NodeSet::parse("node[5-8]").unwrap();
661
662        graph.add_route(admin.clone(), gw1.clone()).unwrap();
663        graph.add_route(admin, gw2.clone()).unwrap();
664        graph.add_route(gw1, nodes1).unwrap();
665        graph.add_route(gw2, nodes2).unwrap();
666        graph.to_tree("admin").unwrap()
667    }
668
669    // -- RouteResolvingError tests ------------------------------------------
670
671    #[test]
672    fn test_error_display() {
673        let err = RouteResolvingError::InvalidRoot("badnode".into());
674        assert!(err.to_string().contains("badnode"));
675
676        let err = RouteResolvingError::NoRoute("node99".into());
677        assert!(err.to_string().contains("node99"));
678
679        let err = RouteResolvingError::Unreachable("gw1".into());
680        assert!(err.to_string().contains("gw1"));
681
682        let err = RouteResolvingError::SameSourceDest("a".into(), "a".into());
683        assert!(err.to_string().contains("a"));
684    }
685
686    // -- PropagationTreeRouter tests ----------------------------------------
687
688    #[test]
689    fn test_router_creation() {
690        let tree = build_test_topology();
691        let router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
692        assert_eq!(router.root(), "admin");
693        assert_eq!(router.fanout(), 0);
694    }
695
696    #[test]
697    fn test_router_invalid_root() {
698        let tree = build_test_topology();
699        let result = PropagationTreeRouter::new("nonexistent", &tree, 0);
700        assert!(result.is_err());
701    }
702
703    #[test]
704    fn test_router_next_hop() {
705        let tree = build_test_topology();
706        let mut router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
707
708        // node1 should be reachable through gw1
709        let dst = NodeSet::parse("node1").unwrap();
710        let hop = router.next_hop(&dst).unwrap();
711        assert_eq!(hop.to_string(), "gw1");
712
713        // node5 should be reachable through gw2
714        let dst = NodeSet::parse("node5").unwrap();
715        let hop = router.next_hop(&dst).unwrap();
716        assert_eq!(hop.to_string(), "gw2");
717    }
718
719    #[test]
720    fn test_router_same_source_dest() {
721        let tree = build_test_topology();
722        let mut router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
723        let dst = NodeSet::parse("admin").unwrap();
724        let result = router.next_hop(&dst);
725        assert!(result.is_err());
726    }
727
728    #[test]
729    fn test_router_mark_unreachable() {
730        let tree = build_test_topology();
731        let mut router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
732
733        router.mark_unreachable("gw1");
734
735        // node1 goes through gw1, but gw1 is unreachable
736        // The best_next_hop should fail since the only candidate is unreachable
737        let dst = NodeSet::parse("node1").unwrap();
738        let result = router.next_hop(&dst);
739        assert!(result.is_err());
740    }
741
742    #[test]
743    fn test_router_dispatch_directly_connected() {
744        let tree = build_test_topology();
745        let router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
746
747        // Dispatch nodes not in any route -> treated as directly connected
748        let dst = NodeSet::parse("localnode").unwrap();
749        let results = router.dispatch(&dst);
750        assert_eq!(results.len(), 1);
751        assert_eq!(results[0].0.to_string(), "localnode");
752    }
753
754    #[test]
755    fn test_router_dispatch_remote() {
756        let tree = build_test_topology();
757        let router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
758
759        let dst = NodeSet::parse("node[1-2]").unwrap();
760        let results = router.dispatch(&dst);
761        // Should dispatch through gw1
762        assert!(!results.is_empty());
763        for (hop, _target) in &results {
764            assert_eq!(hop.to_string(), "gw1");
765        }
766    }
767
768    #[test]
769    fn test_router_load_balancing() {
770        // Build a topology with two gateways to same network
771        // admin -> gw[1-2] -> node[1-8]
772        let mut graph = TopologyGraph::new();
773        let admin = NodeSet::parse("admin").unwrap();
774        let gws = NodeSet::parse("gw[1-2]").unwrap();
775        let nodes = NodeSet::parse("node[1-8]").unwrap();
776        graph.add_route(admin, gws).unwrap();
777
778        // Each gw gets half the nodes
779        let gw1 = NodeSet::parse("gw1").unwrap();
780        let gw2 = NodeSet::parse("gw2").unwrap();
781        graph
782            .add_route(gw1, NodeSet::parse("node[1-4]").unwrap())
783            .unwrap();
784        graph
785            .add_route(gw2, NodeSet::parse("node[5-8]").unwrap())
786            .unwrap();
787
788        let tree = graph.to_tree("admin").unwrap();
789        let mut router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
790
791        // Route several nodes — should distribute across gateways
792        let d1 = NodeSet::parse("node1").unwrap();
793        let _h1 = router.next_hop(&d1).unwrap();
794        let d2 = NodeSet::parse("node5").unwrap();
795        let _h2 = router.next_hop(&d2).unwrap();
796
797        // Both should resolve to different gateways
798        // (node1 -> gw1, node5 -> gw2 based on topology)
799    }
800
801    // -- PropagationChannel tests -------------------------------------------
802
803    #[test]
804    fn test_channel_creation() {
805        let ch = PropagationChannel::new("gw1");
806        assert_eq!(ch.gateway, "gw1");
807        assert_eq!(ch.current_state(), ChannelState::Init);
808        assert!(!ch.is_gathering());
809        assert!(!ch.is_closed());
810    }
811
812    #[test]
813    fn test_channel_start() {
814        let mut ch = PropagationChannel::new("gw1");
815        ch.start(b"topology data");
816        assert_eq!(ch.current_state(), ChannelState::Config);
817        assert!(ch.channel.opened);
818
819        // Should have enqueued a configuration message
820        let outgoing = ch.channel.take_outgoing().unwrap();
821        let xml = String::from_utf8(outgoing).unwrap();
822        assert!(xml.contains("type=\"CFG\""));
823        assert!(xml.contains("gateway=\"gw1\""));
824    }
825
826    #[test]
827    fn test_channel_config_ack() {
828        let mut ch = PropagationChannel::new("gw1");
829        ch.start(b"topo");
830        assert_eq!(ch.state, ChannelState::Config);
831
832        // Receive ACK
833        let ack = Message::ack(0);
834        let results = ch.recv(ack).unwrap();
835        assert!(results.is_empty());
836        assert_eq!(ch.state, ChannelState::Gather);
837        assert!(ch.channel.setup);
838    }
839
840    #[test]
841    fn test_channel_shell_command() {
842        let mut ch = PropagationChannel::new("gw1");
843        ch.start(b"topo");
844
845        // ACK the config
846        ch.recv(Message::ack(0)).unwrap();
847
848        // Send shell command
849        ch.shell("node[1-4]", b"uname -a", 42);
850
851        // Should have outgoing message
852        let outgoing = ch.channel.take_outgoing().unwrap();
853        // skip the cfg message
854        let outgoing2 = ch.channel.take_outgoing().unwrap();
855        let xml = String::from_utf8(outgoing2).unwrap();
856        assert!(xml.contains("type=\"CTL\""));
857        assert!(xml.contains("action=\"shell\""));
858        assert!(xml.contains("target=\"node[1-4]\""));
859    }
860
861    #[test]
862    fn test_channel_recv_stdout() {
863        let mut ch = PropagationChannel::new("gw1");
864        ch.start(b"topo");
865        ch.recv(Message::ack(0)).unwrap();
866
867        // Simulate stdout message
868        let mut stdout = Message::stdout("node[1-2]", 1);
869        stdout.data_encode(b"hello world");
870        let results = ch.recv(stdout).unwrap();
871
872        assert_eq!(results.len(), 2);
873        for r in &results {
874            if let PropagationResult::StdOut { data, .. } = r {
875                assert_eq!(data, b"hello world");
876            } else {
877                panic!("expected StdOut");
878            }
879        }
880    }
881
882    #[test]
883    fn test_channel_recv_stderr() {
884        let mut ch = PropagationChannel::new("gw1");
885        ch.start(b"topo");
886        ch.recv(Message::ack(0)).unwrap();
887
888        let mut stderr = Message::stderr("node1", 1);
889        stderr.data_encode(b"error output");
890        let results = ch.recv(stderr).unwrap();
891
892        assert_eq!(results.len(), 1);
893        if let PropagationResult::StdErr { node, data } = &results[0] {
894            assert_eq!(node, "node1");
895            assert_eq!(data, b"error output");
896        } else {
897            panic!("expected StdErr");
898        }
899    }
900
901    #[test]
902    fn test_channel_recv_retcode() {
903        let mut ch = PropagationChannel::new("gw1");
904        ch.start(b"topo");
905        ch.recv(Message::ack(0)).unwrap();
906
907        let ret = Message::retcode("node[1-3]", 0, 1);
908        let results = ch.recv(ret).unwrap();
909
910        assert_eq!(results.len(), 3);
911        for r in &results {
912            if let PropagationResult::Retcode { rc, .. } = r {
913                assert_eq!(*rc, 0);
914            } else {
915                panic!("expected Retcode");
916            }
917        }
918    }
919
920    #[test]
921    fn test_channel_recv_timeout() {
922        let mut ch = PropagationChannel::new("gw1");
923        ch.start(b"topo");
924        ch.recv(Message::ack(0)).unwrap();
925
926        let tim = Message::timeout("node[5-6]", 1);
927        let results = ch.recv(tim).unwrap();
928
929        assert_eq!(results.len(), 2);
930        for r in &results {
931            assert!(matches!(r, PropagationResult::Timeout { .. }));
932        }
933    }
934
935    #[test]
936    fn test_channel_recv_routing() {
937        let mut ch = PropagationChannel::new("gw1");
938        ch.start(b"topo");
939        ch.recv(Message::ack(0)).unwrap();
940
941        let rtr = Message::routing("connect", "gw2", "node[10-20]", 1);
942        let results = ch.recv(rtr).unwrap();
943
944        assert_eq!(results.len(), 1);
945        if let PropagationResult::Routing {
946            event,
947            gateway,
948            targets,
949        } = &results[0]
950        {
951            assert_eq!(event, "connect");
952            assert_eq!(gateway, "gw2");
953            assert_eq!(targets, "node[10-20]");
954        } else {
955            panic!("expected Routing");
956        }
957    }
958
959    #[test]
960    fn test_channel_recv_error() {
961        let mut ch = PropagationChannel::new("gw1");
962        ch.start(b"topo");
963        ch.recv(Message::ack(0)).unwrap();
964
965        let err = Message::error("something went wrong");
966        let result = ch.recv(err);
967        assert!(result.is_err());
968    }
969
970    #[test]
971    fn test_channel_recv_end() {
972        let mut ch = PropagationChannel::new("gw1");
973        ch.start(b"topo");
974        ch.recv(Message::ack(0)).unwrap();
975
976        let end = Message::end();
977        ch.recv(end).unwrap();
978        assert!(ch.is_closed());
979    }
980
981    #[test]
982    fn test_channel_ev_hup() {
983        let mut ch = PropagationChannel::new("gw1");
984        ch.ev_hup(1);
985        assert_eq!(ch.last_rc, Some(1));
986    }
987
988    #[test]
989    fn test_channel_ev_close() {
990        let mut ch = PropagationChannel::new("gw1");
991        ch.ev_close();
992        assert!(ch.is_closed());
993    }
994
995    #[test]
996    fn test_channel_send_queue() {
997        let mut ch = PropagationChannel::new("gw1");
998        ch.start(b"topo");
999        // Before ACK, messages should be queued
1000        ch.shell("node1", b"echo hi", 1);
1001
1002        // Only the config message should be in outbox
1003        let _cfg = ch.channel.take_outgoing().unwrap();
1004        assert!(ch.channel.take_outgoing().is_none());
1005
1006        // After ACK, queued message should be sent
1007        ch.recv(Message::ack(0)).unwrap();
1008        let ctl = ch.channel.take_outgoing().unwrap();
1009        let xml = String::from_utf8(ctl).unwrap();
1010        assert!(xml.contains("type=\"CTL\""));
1011    }
1012
1013    #[test]
1014    fn test_channel_write_and_eof() {
1015        let mut ch = PropagationChannel::new("gw1");
1016        ch.start(b"topo");
1017        ch.recv(Message::ack(0)).unwrap();
1018
1019        ch.write("node1", b"input data", 1);
1020        ch.set_write_eof("node1", 1);
1021
1022        let _ = ch.channel.take_outgoing(); // cfg
1023        let write_msg = ch.channel.take_outgoing().unwrap();
1024        let xml = String::from_utf8(write_msg).unwrap();
1025        assert!(xml.contains("action=\"write\""));
1026
1027        let eof_msg = ch.channel.take_outgoing().unwrap();
1028        let xml2 = String::from_utf8(eof_msg).unwrap();
1029        assert!(xml2.contains("action=\"eof\""));
1030    }
1031
1032    #[test]
1033    fn test_channel_results_accumulate() {
1034        let mut ch = PropagationChannel::new("gw1");
1035        ch.start(b"topo");
1036        ch.recv(Message::ack(0)).unwrap();
1037
1038        // Send multiple results
1039        let mut out1 = Message::stdout("node1", 1);
1040        out1.data_encode(b"line1");
1041        ch.recv(out1).unwrap();
1042
1043        let mut out2 = Message::stdout("node2", 1);
1044        out2.data_encode(b"line2");
1045        ch.recv(out2).unwrap();
1046
1047        let ret = Message::retcode("node[1-2]", 0, 1);
1048        ch.recv(ret).unwrap();
1049
1050        // Should have accumulated 4 results (2 stdout + 2 retcode)
1051        assert_eq!(ch.results.len(), 4);
1052    }
1053}