#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use crate::fleet_doctor_schema::HostProbeStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ProbePhase {
Reachability,
Version,
CheapHealth,
DeepStatus,
DeepDoctor,
}
impl ProbePhase {
pub(crate) fn cheap_first_order() -> &'static [ProbePhase] {
&[
ProbePhase::Reachability,
ProbePhase::Version,
ProbePhase::CheapHealth,
ProbePhase::DeepStatus,
ProbePhase::DeepDoctor,
]
}
pub(crate) fn is_cheap(self) -> bool {
matches!(self, Self::Reachability | Self::Version | Self::CheapHealth)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct HostProbeSignals {
pub reachable: bool,
pub version_compatible: Option<bool>,
pub cheap_health_ok: bool,
pub deep_status_timed_out: bool,
pub tool_difference: bool,
pub is_macos: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct HostProbeOutcome {
pub host_alias: String,
pub status: HostProbeStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skipped_phases: Vec<ProbePhase>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub partial_facts: Vec<String>,
pub next_command: String,
pub elapsed_ms: u64,
}
pub(crate) fn classify_host(
host_alias: impl Into<String>,
signals: HostProbeSignals,
elapsed_ms: u64,
) -> HostProbeOutcome {
let host_alias = host_alias.into();
let deep = vec![ProbePhase::DeepStatus, ProbePhase::DeepDoctor];
if !signals.reachable {
return HostProbeOutcome {
host_alias,
status: HostProbeStatus::Unreachable,
skipped_phases: vec![
ProbePhase::Version,
ProbePhase::CheapHealth,
ProbePhase::DeepStatus,
ProbePhase::DeepDoctor,
],
partial_facts: vec!["host identity only; transport failed".to_string()],
next_command: "cass fleet doctor --json # retry from a reachable node".to_string(),
elapsed_ms,
};
}
if signals.version_compatible == Some(false) {
return HostProbeOutcome {
host_alias,
status: HostProbeStatus::OldBinarySkew,
skipped_phases: deep,
partial_facts: vec!["reachable; cass version behind the fleet baseline".to_string()],
next_command: "cass self-update # upgrade this host before trusting deep state"
.to_string(),
elapsed_ms,
};
}
if signals.cheap_health_ok && signals.deep_status_timed_out {
let mut facts = vec!["cheap health ok; deep status exceeded its budget".to_string()];
if signals.tool_difference {
facts.push("BSD/GNU tool difference observed (macOS)".to_string());
}
return HostProbeOutcome {
host_alias,
status: HostProbeStatus::TimedOut,
skipped_phases: deep,
partial_facts: facts,
next_command: "cass status --json # re-run with a larger --budget-ms".to_string(),
elapsed_ms,
};
}
if signals.tool_difference || signals.is_macos {
return HostProbeOutcome {
host_alias,
status: HostProbeStatus::Partial,
skipped_phases: Vec::new(),
partial_facts: vec!["probed with macOS BSD/GNU tool adaptations".to_string()],
next_command: "cass status --json".to_string(),
elapsed_ms,
};
}
HostProbeOutcome {
host_alias,
status: HostProbeStatus::Ok,
skipped_phases: Vec::new(),
partial_facts: Vec::new(),
next_command: "cass status --json".to_string(),
elapsed_ms,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct FleetProbeReport {
pub hosts: Vec<HostProbeOutcome>,
pub degraded_host_count: usize,
pub blocked: bool,
}
pub(crate) fn assemble(hosts: Vec<HostProbeOutcome>) -> FleetProbeReport {
let degraded_host_count = hosts
.iter()
.filter(|h| h.status != HostProbeStatus::Ok)
.count();
FleetProbeReport {
hosts,
degraded_host_count,
blocked: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn healthy() -> HostProbeSignals {
HostProbeSignals {
reachable: true,
version_compatible: Some(true),
cheap_health_ok: true,
deep_status_timed_out: false,
tool_difference: false,
is_macos: false,
}
}
fn assert_safe_command(cmd: &str) {
assert!(
cmd.starts_with("cass "),
"must be a concrete cass command: {cmd}"
);
for bad in ["rm ", "--force-clean", "delete ", "DROP "] {
assert!(!cmd.contains(bad), "destructive: {cmd}");
}
}
#[test]
fn probe_phase_order_is_cheap_first() {
let order = ProbePhase::cheap_first_order();
assert_eq!(order[0], ProbePhase::Reachability);
let first_deep = order.iter().position(|p| !p.is_cheap()).unwrap();
assert!(order[..first_deep].iter().all(|p| p.is_cheap()));
assert!(order[first_deep..].iter().all(|p| !p.is_cheap()));
}
#[test]
fn healthy_host_is_ok_with_no_skips() {
let o = classify_host("local", healthy(), 40);
assert_eq!(o.status, HostProbeStatus::Ok);
assert!(o.skipped_phases.is_empty());
assert_safe_command(&o.next_command);
}
#[test]
fn ts2_fast_health_slow_status_is_timed_out_partial() {
let mut s = healthy();
s.deep_status_timed_out = true;
let o = classify_host("ts2", s, 5_000);
assert_eq!(o.status, HostProbeStatus::TimedOut);
assert!(o.skipped_phases.contains(&ProbePhase::DeepStatus));
assert!(!o.partial_facts.is_empty(), "partial facts preserved");
assert_safe_command(&o.next_command);
}
#[test]
fn unreachable_host_returns_identity_only_and_never_blocks() {
let mut s = healthy();
s.reachable = false;
let o = classify_host("mac-mini-old", s, 30_000);
assert_eq!(o.status, HostProbeStatus::Unreachable);
assert!(o.skipped_phases.contains(&ProbePhase::DeepDoctor));
assert!(o.next_command.contains("reachable node"));
}
#[test]
fn old_binary_skews_and_skips_deep_probes() {
let mut s = healthy();
s.version_compatible = Some(false);
let o = classify_host("csd", s, 80);
assert_eq!(o.status, HostProbeStatus::OldBinarySkew);
assert!(o.skipped_phases.contains(&ProbePhase::DeepStatus));
assert!(o.next_command.contains("self-update"));
}
#[test]
fn macos_tool_difference_is_partial_not_failure() {
let mut s = healthy();
s.tool_difference = true;
s.is_macos = true;
let o = classify_host("mac-mini-max", s, 120);
assert_eq!(o.status, HostProbeStatus::Partial);
assert!(o.partial_facts.iter().any(|f| f.contains("macOS")));
}
#[test]
fn one_unreachable_host_does_not_block_or_drop_the_others() {
let mut down = healthy();
down.reachable = false;
let mut slow = healthy();
slow.deep_status_timed_out = true;
let report = assemble(vec![
classify_host("local", healthy(), 40),
classify_host("mac-mini-old", down, 30_000),
classify_host("ts2", slow, 5_000),
]);
assert_eq!(report.hosts.len(), 3);
assert!(!report.blocked);
assert_eq!(report.degraded_host_count, 2);
assert_eq!(report.hosts[0].status, HostProbeStatus::Ok);
}
#[test]
fn report_round_trips_through_json() {
let report = assemble(vec![classify_host(
"ts2",
{
let mut s = healthy();
s.deep_status_timed_out = true;
s
},
5_000,
)]);
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"blocked\":false"));
assert!(json.contains("\"status\":\"timed-out\""));
assert!(json.contains("\"skipped_phases\":[\"deep_status\""));
let parsed: FleetProbeReport = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, report);
}
}