|
|
@@ -30,6 +30,9 @@ pub struct BootTiming {
|
|
|
pub boot_wait_secs: u64,
|
|
|
pub poll_interval_secs: u64,
|
|
|
pub max_poll_secs: u64,
|
|
|
+ /// Grace before the single re-probe when the first pass saw too few
|
|
|
+ /// free nodes (transient probe misses). Tests set this to 0.
|
|
|
+ pub retry_grace_secs: u64,
|
|
|
}
|
|
|
|
|
|
impl Default for BootTiming {
|
|
|
@@ -38,6 +41,7 @@ impl Default for BootTiming {
|
|
|
boot_wait_secs: 60,
|
|
|
poll_interval_secs: 10,
|
|
|
max_poll_secs: 120,
|
|
|
+ retry_grace_secs: 5,
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -69,13 +73,13 @@ where
|
|
|
C: PowerController,
|
|
|
N: NodePinger,
|
|
|
{
|
|
|
- let state = req.prober.probe(config).await;
|
|
|
+ let mut last_state = req.prober.probe(config).await;
|
|
|
|
|
|
- let resolved = if let Some(nn) = node_name {
|
|
|
+ let mut resolved = if let Some(nn) = node_name {
|
|
|
resolve_solo_node(config, nn)?
|
|
|
} else {
|
|
|
// Auto-power-on: if any nodes are unreachable, try powering on.
|
|
|
- let state = if req.auto_power_on && state_has_unreachable(&state, config) {
|
|
|
+ let state = if req.auto_power_on && state_has_unreachable(&last_state, config) {
|
|
|
let (c, _nccl) = resolve_cluster_simple(config, recipe, cluster_name)?;
|
|
|
if let Some(pwr) = req.power {
|
|
|
power_on_if_off(&c, pwr).await?;
|
|
|
@@ -85,7 +89,7 @@ where
|
|
|
// Re-probe after power-on.
|
|
|
req.prober.probe(config).await
|
|
|
} else {
|
|
|
- state
|
|
|
+ last_state.clone()
|
|
|
};
|
|
|
|
|
|
// `--force`: clear occupied flags so select_cluster counts reachable
|
|
|
@@ -98,8 +102,38 @@ where
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+ last_state = state.clone();
|
|
|
|
|
|
- let (c, n) = select_cluster(config, cluster_name, recipe.required_nodes, &state)?;
|
|
|
+ let (c, n) = match select_cluster(config, cluster_name, recipe.required_nodes, &state) {
|
|
|
+ Ok(v) => v,
|
|
|
+ Err(e) => {
|
|
|
+ // A transient probe miss (ssh blip, node warming up) can
|
|
|
+ // make auto-selection see too few free nodes — re-probe
|
|
|
+ // once after the retry grace before giving up.
|
|
|
+ if req.force_occupied {
|
|
|
+ return Err(e);
|
|
|
+ }
|
|
|
+ tokio::time::sleep(Duration::from_secs(req.boot_timing.retry_grace_secs)).await;
|
|
|
+ let state2 = req.prober.probe(config).await;
|
|
|
+ match select_cluster(config, cluster_name, recipe.required_nodes, &state2) {
|
|
|
+ Ok(v) => {
|
|
|
+ state = state2;
|
|
|
+ last_state = state.clone();
|
|
|
+ v
|
|
|
+ }
|
|
|
+ Err(e2) => {
|
|
|
+ return Err(anyhow::anyhow!(
|
|
|
+ "Recipe '{}' requires {} nodes, but cluster '{}' had too few free nodes after re-probe ({}; {})",
|
|
|
+ recipe.name,
|
|
|
+ recipe.required_nodes.unwrap_or(0),
|
|
|
+ cluster_name,
|
|
|
+ node_details(config, &state2, cluster_name),
|
|
|
+ e2
|
|
|
+ ));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
let avail: Vec<Node> = c
|
|
|
.nodes
|
|
|
.into_iter()
|
|
|
@@ -114,16 +148,76 @@ where
|
|
|
(c, n.cloned())
|
|
|
};
|
|
|
|
|
|
- // Validate node count.
|
|
|
+ // Validate node count. A transient probe miss (ssh blip, node still
|
|
|
+ // warming up after stop/power-on) must not kill the serve — re-probe
|
|
|
+ // once after a short grace window before giving up, and on failure
|
|
|
+ // report WHY each node was not counted (unreachable vs occupied).
|
|
|
if let Some(required) = recipe.required_nodes {
|
|
|
+ let actual = resolved.0.nodes.len() as u32;
|
|
|
+ if actual < required && !req.force_occupied {
|
|
|
+ tokio::time::sleep(Duration::from_secs(req.boot_timing.retry_grace_secs)).await;
|
|
|
+ let state2 = req.prober.probe(config).await;
|
|
|
+ if let Ok((c2, n2)) =
|
|
|
+ select_cluster(config, cluster_name, recipe.required_nodes, &state2)
|
|
|
+ {
|
|
|
+ let avail2: Vec<Node> = c2
|
|
|
+ .nodes
|
|
|
+ .iter()
|
|
|
+ .filter(|n| {
|
|
|
+ state2
|
|
|
+ .nodes
|
|
|
+ .get(&n.name)
|
|
|
+ .is_some_and(|ns| ns.reachable && !ns.occupied)
|
|
|
+ })
|
|
|
+ .cloned()
|
|
|
+ .collect();
|
|
|
+ if avail2.len() as u32 >= required {
|
|
|
+ info!(
|
|
|
+ "cluster re-probe recovered '{}': {} free node(s) (first pass saw {})",
|
|
|
+ c2.name,
|
|
|
+ avail2.len(),
|
|
|
+ actual
|
|
|
+ );
|
|
|
+ resolved = (
|
|
|
+ Cluster {
|
|
|
+ nodes: avail2,
|
|
|
+ ..c2
|
|
|
+ },
|
|
|
+ n2.cloned(),
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ last_state = state2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
let actual = resolved.0.nodes.len() as u32;
|
|
|
if actual < required {
|
|
|
+ let detail = config
|
|
|
+ .clusters
|
|
|
+ .iter()
|
|
|
+ .find(|c| c.name == resolved.0.name)
|
|
|
+ .map(|c| {
|
|
|
+ c.nodes
|
|
|
+ .iter()
|
|
|
+ .map(|n| match last_state.nodes.get(&n.name) {
|
|
|
+ Some(ns) if !ns.reachable => {
|
|
|
+ format!("{} unreachable (ssh probe)", n.name)
|
|
|
+ }
|
|
|
+ Some(ns) if ns.occupied => format!("{} occupied", n.name),
|
|
|
+ Some(_) => format!("{} free", n.name),
|
|
|
+ None => format!("{} unknown", n.name),
|
|
|
+ })
|
|
|
+ .collect::<Vec<_>>()
|
|
|
+ .join(", ")
|
|
|
+ })
|
|
|
+ .unwrap_or_default();
|
|
|
anyhow::bail!(
|
|
|
- "Recipe '{}' requires {} nodes, but cluster '{}' has only {} nodes",
|
|
|
+ "Recipe '{}' requires {} nodes, but cluster '{}' has only {} nodes ({})",
|
|
|
recipe.name,
|
|
|
required,
|
|
|
resolved.0.name,
|
|
|
- actual
|
|
|
+ actual,
|
|
|
+ detail
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
@@ -156,6 +250,30 @@ fn resolve_solo_node(config: &Config, node_name: &str) -> Result<(Cluster, Optio
|
|
|
Ok((solo_c, solo_n))
|
|
|
}
|
|
|
|
|
|
+/// One-line per-node status list for the given cluster ("auto" → all
|
|
|
+/// clusters, deduped by node name) — diagnostic for selection failures.
|
|
|
+fn node_details(config: &Config, state: &ClusterState, cluster_name: &str) -> String {
|
|
|
+ let mut seen = std::collections::HashSet::new();
|
|
|
+ let mut parts = Vec::new();
|
|
|
+ for c in &config.clusters {
|
|
|
+ if cluster_name != "auto" && c.name != cluster_name {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for n in &c.nodes {
|
|
|
+ if !seen.insert(n.name.clone()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ parts.push(match state.nodes.get(&n.name) {
|
|
|
+ Some(ns) if !ns.reachable => format!("{} unreachable", n.name),
|
|
|
+ Some(ns) if ns.occupied => format!("{} occupied", n.name),
|
|
|
+ Some(_) => format!("{} free", n.name),
|
|
|
+ None => format!("{} unknown", n.name),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ parts.join(", ")
|
|
|
+}
|
|
|
+
|
|
|
/// Resolve cluster by name or auto treating every config node as free
|
|
|
/// (reachable, not occupied). Only used for the power-on pre-check
|
|
|
/// before any probe has been made, so being optimistic is safe:
|