use crate::fleet_doctor_schema::{FleetDoctorReport, HostDoctorReport, HostProbeStatus};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProbeBudget {
pub per_host_ms: u64,
pub total_ms: u64,
}
impl Default for ProbeBudget {
fn default() -> Self {
Self {
per_host_ms: 8_000,
total_ms: 60_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostProbeOutcome {
pub report: HostDoctorReport,
pub elapsed_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn finalize_host(
mut report: HostDoctorReport,
elapsed_ms: u64,
per_host_ms: u64,
) -> HostDoctorReport {
report.elapsed_ms = elapsed_ms;
let overran = per_host_ms > 0 && elapsed_ms >= per_host_ms;
if overran && !report.status.is_hard_failure() && report.status != HostProbeStatus::TimedOut {
report.status = HostProbeStatus::TimedOut;
report.timed_out = true;
if !report.skipped_sections.iter().any(|s| s == "deep-probe") {
report.skipped_sections.push("deep-probe".to_string());
}
if report.recommended_action.is_none() {
report.recommended_action =
Some("host exceeded its probe budget; retry with a higher --per-host-budget or run `cass status` on that host".to_string());
}
}
report
}
pub fn should_stop_sweep(elapsed_ms: u64, budget: ProbeBudget) -> bool {
budget.total_ms > 0 && elapsed_ms >= budget.total_ms
}
pub fn assemble_fleet(outcomes: Vec<HostProbeOutcome>, budget: ProbeBudget) -> FleetDoctorReport {
let hosts: Vec<HostDoctorReport> = outcomes
.into_iter()
.map(|o| finalize_host(o.report, o.elapsed_ms, budget.per_host_ms))
.collect();
FleetDoctorReport::from_hosts(hosts)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fleet_doctor_schema::{ArchiveRisk, HostOs, Platform, ReadinessState};
fn host(alias: &str, status: HostProbeStatus) -> HostDoctorReport {
HostDoctorReport::skeleton(alias, Platform::linux_x86_64(), status, 0)
}
#[test]
fn fast_host_with_status_timeout_keeps_partial_facts() {
let mut r = host("ts2", HostProbeStatus::Ok);
r.readiness = Some(ReadinessState::Ready); let finalized = finalize_host(r, 9_000, 8_000);
assert_eq!(finalized.status, HostProbeStatus::TimedOut);
assert!(finalized.timed_out);
assert_eq!(finalized.elapsed_ms, 9_000);
assert!(finalized.skipped_sections.iter().any(|s| s == "deep-probe"));
assert_eq!(
finalized.readiness,
Some(ReadinessState::Ready),
"partial facts preserved"
);
assert!(
finalized.recommended_action.is_some(),
"next command provided"
);
}
#[test]
fn unreachable_host_is_not_downgraded_to_timeout() {
let finalized = finalize_host(
host("mac-mini-old", HostProbeStatus::Unreachable),
12_000,
8_000,
);
assert_eq!(finalized.status, HostProbeStatus::Unreachable);
assert!(finalized.unreachable);
}
#[test]
fn old_binary_within_budget_is_preserved() {
let finalized = finalize_host(host("csd", HostProbeStatus::OldBinarySkew), 200, 8_000);
assert_eq!(finalized.status, HostProbeStatus::OldBinarySkew);
assert!(!finalized.timed_out);
}
#[test]
fn healthy_and_macos_hosts_within_budget_stay_ok() {
let healthy = finalize_host(host("local", HostProbeStatus::Ok), 50, 8_000);
assert_eq!(healthy.status, HostProbeStatus::Ok);
let mut mac = HostDoctorReport::skeleton(
"mac-mini-max",
Platform {
os: HostOs::MacOs,
arch: "aarch64".to_string(),
path_style: crate::fleet_doctor_schema::PathStyle::Posix,
tool_notes: vec!["date=bsd".to_string()],
},
HostProbeStatus::Ok,
0,
);
mac.archive_risk = ArchiveRisk::Low;
let mac = finalize_host(mac, 120, 8_000);
assert_eq!(mac.status, HostProbeStatus::Ok);
assert_eq!(mac.platform.os, HostOs::MacOs);
assert!(mac.platform.tool_notes.iter().any(|n| n == "date=bsd"));
}
#[test]
fn assemble_fleet_includes_every_host_and_no_host_blocks() {
let outcomes = vec![
HostProbeOutcome {
report: host("local", HostProbeStatus::Ok),
elapsed_ms: 40,
error: None,
},
HostProbeOutcome {
report: {
let mut r = host("ts2", HostProbeStatus::Ok);
r.readiness = Some(ReadinessState::Ready);
r
},
elapsed_ms: 9_000, error: None,
},
HostProbeOutcome {
report: host("mac-mini-old", HostProbeStatus::Unreachable),
elapsed_ms: 12_000,
error: Some("ssh: connect timeout".to_string()),
},
HostProbeOutcome {
report: host("csd", HostProbeStatus::OldBinarySkew),
elapsed_ms: 200,
error: None,
},
];
let report = assemble_fleet(
outcomes,
ProbeBudget {
per_host_ms: 8_000,
total_ms: 60_000,
},
);
assert_eq!(report.hosts.len(), 4, "every host appears; none is dropped");
assert_eq!(report.summary.total_hosts, 4);
let ts2 = report.hosts.iter().find(|h| h.host_alias == "ts2").unwrap();
assert_eq!(ts2.status, HostProbeStatus::TimedOut);
assert_eq!(ts2.readiness, Some(ReadinessState::Ready));
let old = report
.hosts
.iter()
.find(|h| h.host_alias == "mac-mini-old")
.unwrap();
assert_eq!(old.status, HostProbeStatus::Unreachable);
assert_eq!(report.summary.unreachable, 1);
assert_eq!(report.summary.timed_out, 1);
assert_eq!(report.summary.ok, 1);
let value = serde_json::to_value(&report).unwrap();
let back: FleetDoctorReport = serde_json::from_value(value).unwrap();
assert_eq!(back, report);
}
#[test]
fn should_stop_sweep_respects_total_budget() {
let b = ProbeBudget {
per_host_ms: 8_000,
total_ms: 60_000,
};
assert!(!should_stop_sweep(30_000, b));
assert!(should_stop_sweep(60_000, b));
assert!(should_stop_sweep(120_000, b));
}
#[test]
fn zero_per_host_budget_does_not_force_timeout() {
let finalized = finalize_host(host("local", HostProbeStatus::Ok), 999_999, 0);
assert_eq!(finalized.status, HostProbeStatus::Ok);
assert_eq!(finalized.elapsed_ms, 999_999);
}
#[test]
fn default_budget_is_bounded() {
let b = ProbeBudget::default();
assert!(b.per_host_ms > 0 && b.total_ms >= b.per_host_ms);
}
}