mod.rs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  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 (or `None` for
  18. /// single-node recipes, where NCCL config is not required).
  19. ///
  20. /// Timing for [`wait_for_all_nodes`] after a power-on: how long to wait
  21. /// for the machines to boot before the first poll, the poll interval, and
  22. /// the max total poll window. Injectable so tests don't sleep real seconds
  23. /// (a previous hardcoded 60s boot wait made the whole suite take 60s).
  24. #[derive(Clone, Debug)]
  25. pub struct BootTiming {
  26. pub boot_wait_secs: u64,
  27. pub poll_interval_secs: u64,
  28. pub max_poll_secs: u64,
  29. }
  30. impl Default for BootTiming {
  31. fn default() -> Self {
  32. Self {
  33. boot_wait_secs: 60,
  34. poll_interval_secs: 10,
  35. max_poll_secs: 120,
  36. }
  37. }
  38. }
  39. /// Bundles the injectable end-effectors + power-on flag so
  40. /// [`prepare_cluster`] stays under clippy's `too_many_arguments` threshold
  41. /// without an `#[allow]`.
  42. pub struct PrepareRequest<'a, P: NodeProber, C: PowerController, N: NodePinger> {
  43. pub prober: &'a P,
  44. pub power: Option<&'a C>,
  45. pub pinger: &'a N,
  46. pub auto_power_on: bool,
  47. pub boot_timing: BootTiming,
  48. /// `--force`: skip the occupancy filter during cluster selection.
  49. /// Nodes with another container running are kept in the available set
  50. /// (reachable is still required — can't SSH to an unreachable node).
  51. pub force_occupied: bool,
  52. }
  53. pub async fn prepare_cluster<P, C, N>(
  54. config: &Config,
  55. recipe: &Recipe,
  56. cluster_name: &str,
  57. node_name: Option<&str>,
  58. req: &PrepareRequest<'_, P, C, N>,
  59. ) -> Result<(Cluster, Option<NcclConfig>)>
  60. where
  61. P: NodeProber,
  62. C: PowerController,
  63. N: NodePinger,
  64. {
  65. let state = req.prober.probe(config).await;
  66. let resolved = if let Some(nn) = node_name {
  67. resolve_solo_node(config, nn)?
  68. } else {
  69. // Auto-power-on: if any nodes are unreachable, try powering on.
  70. let state = if req.auto_power_on && state_has_unreachable(&state, config) {
  71. let (c, _nccl) = resolve_cluster_simple(config, recipe, cluster_name)?;
  72. if let Some(pwr) = req.power {
  73. power_on_if_off(&c, pwr).await?;
  74. }
  75. // Wait for nodes to become reachable.
  76. wait_for_all_nodes(config, &c.nodes, req.pinger, &req.boot_timing).await?;
  77. // Re-probe after power-on.
  78. req.prober.probe(config).await
  79. } else {
  80. state
  81. };
  82. // `--force`: clear occupied flags so select_cluster counts reachable
  83. // nodes (ignoring occupancy) and the downstream filter keeps them.
  84. let mut state = state;
  85. if req.force_occupied {
  86. for ns in state.nodes.values_mut() {
  87. if ns.reachable {
  88. ns.occupied = false;
  89. }
  90. }
  91. }
  92. let (c, n) = select_cluster(config, cluster_name, recipe.required_nodes, &state)?;
  93. let avail: Vec<Node> = c
  94. .nodes
  95. .into_iter()
  96. .filter(|n| {
  97. state
  98. .nodes
  99. .get(&n.name)
  100. .is_some_and(|ns| ns.reachable && (req.force_occupied || !ns.occupied))
  101. })
  102. .collect();
  103. let c = Cluster { nodes: avail, ..c };
  104. (c, n.cloned())
  105. };
  106. // Validate node count.
  107. if let Some(required) = recipe.required_nodes {
  108. let actual = resolved.0.nodes.len() as u32;
  109. if actual < required {
  110. anyhow::bail!(
  111. "Recipe '{}' requires {} nodes, but cluster '{}' has only {} nodes",
  112. recipe.name,
  113. required,
  114. resolved.0.name,
  115. actual
  116. );
  117. }
  118. }
  119. Ok(resolved)
  120. }
  121. /// Collect a single node from any cluster.
  122. ///
  123. /// Returns `None` for the NCCL config when the cluster has no NCCL entry
  124. /// — this is valid for single-node deployments where NCCL env vars are
  125. /// never emitted.
  126. fn resolve_solo_node(config: &Config, node_name: &str) -> Result<(Cluster, Option<NcclConfig>)> {
  127. let (solo_c, solo_n) = config
  128. .clusters
  129. .iter()
  130. .find_map(|c| {
  131. c.nodes.iter().find(|n| n.name == node_name).map(|node| {
  132. let solo = Cluster {
  133. name: c.name.clone(),
  134. master_ib_ip: node.ib_ip.clone(),
  135. nodes: vec![node.clone()],
  136. extra_entities: vec![],
  137. };
  138. let nc = config.nccl.get(&c.name).cloned();
  139. (solo, nc)
  140. })
  141. })
  142. .context(format!("Node '{}' not found in any cluster", node_name))?;
  143. Ok((solo_c, solo_n))
  144. }
  145. /// Resolve cluster by name or auto treating every config node as free
  146. /// (reachable, not occupied). Only used for the power-on pre-check
  147. /// before any probe has been made, so being optimistic is safe:
  148. /// a full probe follows immediately after power-on completes.
  149. fn resolve_cluster_simple<'c>(
  150. config: &'c Config,
  151. recipe: &Recipe,
  152. cluster_name: &str,
  153. ) -> Result<(Cluster, Option<&'c NcclConfig>)> {
  154. use crate::cluster_state::NodeState;
  155. let mut state = ClusterState::default();
  156. for cluster in &config.clusters {
  157. for node in &cluster.nodes {
  158. state.nodes.entry(node.name.clone()).or_insert(NodeState {
  159. reachable: true,
  160. occupied: false,
  161. });
  162. }
  163. }
  164. select_cluster(config, cluster_name, recipe.required_nodes, &state)
  165. }
  166. /// Dry-run cluster resolution: treat every configured node as reachable and
  167. /// free, then select. Skips all SSH probing/power/pinging so a dry-run works
  168. /// even when the cluster is off or unreachable (the whole point of a dry-run
  169. /// is to preview the generated command, not to verify hardware).
  170. ///
  171. /// Like [`prepare_cluster`], returns the cluster with **all** its nodes (dry-run
  172. /// does not filter for free nodes, since none are actually inspected).
  173. pub fn prepare_cluster_dry_run(
  174. config: &Config,
  175. recipe: &Recipe,
  176. cluster_name: &str,
  177. node_name: Option<&str>,
  178. ) -> Result<(Cluster, Option<NcclConfig>)> {
  179. if let Some(nn) = node_name {
  180. let (c, n) = resolve_solo_node(config, nn)?;
  181. return Ok((c, n));
  182. }
  183. let (c, n) = resolve_cluster_simple(config, recipe, cluster_name)?;
  184. Ok((c, n.cloned()))
  185. }
  186. /// Does the state contain any unreachable nodes?
  187. fn state_has_unreachable(state: &ClusterState, config: &Config) -> bool {
  188. for cluster in &config.clusters {
  189. for node in &cluster.nodes {
  190. if state.nodes.get(&node.name).is_some_and(|ns| !ns.reachable) {
  191. return true;
  192. }
  193. }
  194. }
  195. false
  196. }
  197. /// Power on HA entities that are not already on.
  198. async fn power_on_if_off(cluster: &Cluster, power: &impl PowerController) -> Result<bool> {
  199. let entities = cluster_entities(cluster);
  200. if entities.is_empty() {
  201. return Ok(false);
  202. }
  203. let any_off = {
  204. let mut off = false;
  205. for e in &entities {
  206. match power.entity_is_on(e).await {
  207. Some(true) => {}
  208. _ => off = true,
  209. }
  210. }
  211. off
  212. };
  213. if !any_off {
  214. return Ok(false);
  215. }
  216. info!("{}", fluent_i18n::t!("cluster-switch-off-detected"));
  217. for e in &entities {
  218. power.turn_on(e).await?;
  219. }
  220. info!("{}", fluent_i18n::t!("cluster-switch-powered-on"));
  221. Ok(true)
  222. }
  223. /// Wait until every node in the list accepts an SSH ping, with a timeout.
  224. async fn wait_for_all_nodes(
  225. config: &Config,
  226. nodes: &[Node],
  227. pinger: &impl NodePinger,
  228. timing: &BootTiming,
  229. ) -> Result<()> {
  230. info!("{}", fluent_i18n::t!("cluster-waiting-for-nodes"));
  231. if timing.boot_wait_secs > 0 {
  232. info!(
  233. "{}",
  234. fluent_i18n::t!("boot-wait", { "secs" => timing.boot_wait_secs })
  235. );
  236. tokio::time::sleep(Duration::from_secs(timing.boot_wait_secs)).await;
  237. }
  238. let mut waited = 0;
  239. while waited < timing.max_poll_secs {
  240. let mut all_online = true;
  241. for node in nodes {
  242. if !pinger.is_reachable(config, node).await {
  243. all_online = false;
  244. break;
  245. }
  246. }
  247. if all_online {
  248. info!("{}", fluent_i18n::t!("cluster-nodes-available"));
  249. return Ok(());
  250. }
  251. tokio::time::sleep(Duration::from_secs(timing.poll_interval_secs)).await;
  252. // Advance by at least 1s so a zero poll-interval (tests) can't spin
  253. // forever when the pinger reports unreachable.
  254. waited += timing.poll_interval_secs.max(1);
  255. }
  256. anyhow::bail!("{}", fluent_i18n::t!("cluster-nodes-not-available-timeout"));
  257. }
  258. #[cfg(test)]
  259. #[path = "tests.rs"]
  260. mod tests;