mod.rs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. //! Serve orchestrator — combines probing, power control, and node-pinging
  2. //! into a single `prepare_cluster` step that `serve` calls.
  3. //!
  4. //! Every external interaction goes through the trait bounds `P: NodeProber`,
  5. //! `C: PowerController`, `N: NodePinger`, making the control flow testable
  6. //! with mock implementations.
  7. use std::time::Duration;
  8. use anyhow::{Context, Result};
  9. use tracing::info;
  10. use crate::cluster_state::{ClusterState, select_cluster};
  11. use crate::config::{Cluster, Config, NcclConfig, Node, Recipe};
  12. use crate::ends::{NodePinger, NodeProber, PowerController, cluster_entities};
  13. /// Prepare a cluster for serving: probe, optionally power on, wait, then
  14. /// select a cluster with its free nodes.
  15. ///
  16. /// Returns a `Cluster` whose `nodes` field contains only free (reachable
  17. /// and not occupied) nodes, plus the matching NCCL config.
  18. #[allow(clippy::too_many_arguments)]
  19. pub async fn prepare_cluster<P, C, N>(
  20. config: &Config,
  21. recipe: &Recipe,
  22. cluster_name: &str,
  23. node_name: Option<&str>,
  24. auto_power_on: bool,
  25. prober: &P,
  26. power: Option<&C>,
  27. pinger: &N,
  28. ) -> Result<(Cluster, NcclConfig)>
  29. where
  30. P: NodeProber,
  31. C: PowerController,
  32. N: NodePinger,
  33. {
  34. let state = prober.probe(config).await;
  35. let resolved = if let Some(nn) = node_name {
  36. resolve_solo_node(config, nn)?
  37. } else {
  38. // Auto-power-on: if any nodes are unreachable, try powering on.
  39. let state = if auto_power_on && state_has_unreachable(&state, config) {
  40. let (c, _nccl) = resolve_cluster_simple(config, recipe, cluster_name)?;
  41. if let Some(pwr) = power {
  42. power_on_if_off(&state, &c, pwr).await?;
  43. }
  44. // Wait for nodes to become reachable.
  45. wait_for_all_nodes(config, &c.nodes, pinger).await?;
  46. // Re-probe after power-on.
  47. prober.probe(config).await
  48. } else {
  49. state
  50. };
  51. let (c, n) = select_cluster(config, cluster_name, recipe.required_nodes, &state)?;
  52. let avail: Vec<Node> = c
  53. .nodes
  54. .into_iter()
  55. .filter(|n| {
  56. state
  57. .nodes
  58. .get(&n.name)
  59. .is_some_and(|ns| ns.reachable && !ns.occupied)
  60. })
  61. .collect();
  62. let c = Cluster { nodes: avail, ..c };
  63. (c, n.clone())
  64. };
  65. // Validate node count.
  66. if let Some(required) = recipe.required_nodes {
  67. let actual = resolved.0.nodes.len() as u32;
  68. if actual < required {
  69. anyhow::bail!(
  70. "Recipe '{}' requires {} nodes, but cluster '{}' has only {} nodes",
  71. recipe.name,
  72. required,
  73. resolved.0.name,
  74. actual
  75. );
  76. }
  77. }
  78. Ok(resolved)
  79. }
  80. /// Collect a single node from any cluster.
  81. fn resolve_solo_node(config: &Config, node_name: &str) -> Result<(Cluster, NcclConfig)> {
  82. let (solo_c, solo_n) = config
  83. .clusters
  84. .iter()
  85. .find_map(|c| {
  86. c.nodes.iter().find(|n| n.name == node_name).map(|node| {
  87. let solo = Cluster {
  88. name: c.name.clone(),
  89. master_ib_ip: node.ib_ip.clone(),
  90. nodes: vec![node.clone()],
  91. extra_entities: vec![],
  92. };
  93. let nc = config
  94. .nccl
  95. .get(&c.name)
  96. .cloned()
  97. .unwrap_or_else(|| NcclConfig {
  98. ib_hca: String::new(),
  99. socket_ifname: "lo".to_string(),
  100. ib_gid_index: 3,
  101. ib_disable: 1,
  102. gloo_socket_ifname: None,
  103. ucx_net_devices: None,
  104. nccl_cross_nic: None,
  105. nccl_proto: None,
  106. nccl_p2p_level: None,
  107. });
  108. (solo, nc)
  109. })
  110. })
  111. .context(format!("Node '{}' not found in any cluster", node_name))?;
  112. Ok((solo_c, solo_n))
  113. }
  114. /// Resolve cluster by name or auto treating every config node as free
  115. /// (reachable, not occupied). Only used for the power-on pre-check
  116. /// before any probe has been made.
  117. fn resolve_cluster_simple<'c>(
  118. config: &'c Config,
  119. recipe: &Recipe,
  120. cluster_name: &str,
  121. ) -> Result<(Cluster, &'c NcclConfig)> {
  122. use crate::cluster_state::NodeState;
  123. let mut state = ClusterState::default();
  124. for cluster in &config.clusters {
  125. for node in &cluster.nodes {
  126. state.nodes.entry(node.name.clone()).or_insert(NodeState {
  127. reachable: true,
  128. occupied: false,
  129. });
  130. }
  131. }
  132. select_cluster(config, cluster_name, recipe.required_nodes, &state)
  133. }
  134. /// Does the state contain any unreachable nodes?
  135. fn state_has_unreachable(state: &ClusterState, config: &Config) -> bool {
  136. for cluster in &config.clusters {
  137. for node in &cluster.nodes {
  138. if state.nodes.get(&node.name).is_some_and(|ns| !ns.reachable) {
  139. return true;
  140. }
  141. }
  142. }
  143. false
  144. }
  145. /// Power on HA entities that are not already on.
  146. async fn power_on_if_off(
  147. _state: &ClusterState,
  148. cluster: &Cluster,
  149. power: &impl PowerController,
  150. ) -> Result<bool> {
  151. let entities = cluster_entities(cluster);
  152. if entities.is_empty() {
  153. return Ok(false);
  154. }
  155. let any_off = {
  156. let mut off = false;
  157. for e in &entities {
  158. match power.entity_is_on(e).await {
  159. Some(true) => {}
  160. _ => off = true,
  161. }
  162. }
  163. off
  164. };
  165. if !any_off {
  166. return Ok(false);
  167. }
  168. info!("{}", fluent_i18n::t!("cluster-switch-off-detected"));
  169. for e in &entities {
  170. power.turn_on(e).await?;
  171. }
  172. info!("{}", fluent_i18n::t!("cluster-switch-powered-on"));
  173. Ok(true)
  174. }
  175. /// Wait until every node in the list accepts an SSH ping, with a timeout.
  176. async fn wait_for_all_nodes(
  177. config: &Config,
  178. nodes: &[Node],
  179. pinger: &impl NodePinger,
  180. ) -> Result<()> {
  181. info!("{}", fluent_i18n::t!("cluster-waiting-for-nodes"));
  182. let boot_wait_secs = 60;
  183. info!("⏳ Waiting {}s for nodes to boot...", boot_wait_secs);
  184. tokio::time::sleep(Duration::from_secs(boot_wait_secs)).await;
  185. let max_poll_secs = 120;
  186. let poll_interval_secs = 10;
  187. let mut waited = 0;
  188. while waited < max_poll_secs {
  189. let mut all_online = true;
  190. for node in nodes {
  191. if !pinger.is_reachable(config, node).await {
  192. all_online = false;
  193. break;
  194. }
  195. }
  196. if all_online {
  197. info!("{}", fluent_i18n::t!("cluster-nodes-available"));
  198. return Ok(());
  199. }
  200. tokio::time::sleep(Duration::from_secs(poll_interval_secs)).await;
  201. waited += poll_interval_secs;
  202. }
  203. anyhow::bail!("{}", fluent_i18n::t!("cluster-nodes-not-available-timeout"));
  204. }
  205. #[cfg(test)]
  206. #[path = "tests.rs"]
  207. mod tests;