use serde::{Deserialize, Serialize};
use crate::e2e_runner::RunMode;
use crate::fleet_archive_coverage::{ArchiveCoverageSummary, CoverageState};
use crate::fleet_doctor_schema::{
ArchiveRisk, HostDoctorReport, HostOs, HostProbeStatus, ReadinessState, RemoteSyncState,
SemanticState,
};
use crate::fleet_version_skew::{CapabilityGap, UpgradeMethod, VersionAssessment};
use crate::proof_artifact::{ProofArtifact, ProofRun, ProofStatus};
pub const FLEET_UPGRADE_REHEARSAL_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RehearsalMode {
Fixture,
Live,
}
impl RehearsalMode {
pub const fn as_str(self) -> &'static str {
match self {
RehearsalMode::Fixture => "fixture",
RehearsalMode::Live => "live",
}
}
pub const fn run_mode(self) -> RunMode {
match self {
RehearsalMode::Fixture => RunMode::Ci,
RehearsalMode::Live => RunMode::Live,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum UpgradeActionScope {
None,
Binary,
DerivedAssets,
Archive,
}
impl UpgradeActionScope {
pub const fn as_str(self) -> &'static str {
match self {
UpgradeActionScope::None => "none",
UpgradeActionScope::Binary => "binary",
UpgradeActionScope::DerivedAssets => "derived-assets",
UpgradeActionScope::Archive => "archive",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum UpgradeAction {
BinaryUpgrade,
DataSchemaMigration,
ModelInstall,
SourceSync,
DerivedIndexRefresh,
}
impl UpgradeAction {
pub const fn as_str(self) -> &'static str {
match self {
UpgradeAction::BinaryUpgrade => "binary-upgrade",
UpgradeAction::DataSchemaMigration => "data-schema-migration",
UpgradeAction::ModelInstall => "model-install",
UpgradeAction::SourceSync => "source-sync",
UpgradeAction::DerivedIndexRefresh => "derived-index-refresh",
}
}
pub const fn scope(self) -> UpgradeActionScope {
match self {
UpgradeAction::BinaryUpgrade => UpgradeActionScope::Binary,
UpgradeAction::DataSchemaMigration => UpgradeActionScope::Archive,
UpgradeAction::ModelInstall => UpgradeActionScope::None,
UpgradeAction::SourceSync => UpgradeActionScope::DerivedAssets,
UpgradeAction::DerivedIndexRefresh => UpgradeActionScope::DerivedAssets,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HostUpgradeDisposition {
UpToDate,
UpgradeReady,
NeedsManualUpgrade,
UpgradeGatedByArchive,
InstallerUnavailable,
Unreachable,
}
impl HostUpgradeDisposition {
pub const fn as_str(self) -> &'static str {
match self {
HostUpgradeDisposition::UpToDate => "up-to-date",
HostUpgradeDisposition::UpgradeReady => "upgrade-ready",
HostUpgradeDisposition::NeedsManualUpgrade => "needs-manual-upgrade",
HostUpgradeDisposition::UpgradeGatedByArchive => "upgrade-gated-by-archive",
HostUpgradeDisposition::InstallerUnavailable => "installer-unavailable",
HostUpgradeDisposition::Unreachable => "unreachable",
}
}
pub const fn is_actionable_now(self) -> bool {
matches!(
self,
HostUpgradeDisposition::UpgradeReady
| HostUpgradeDisposition::NeedsManualUpgrade
| HostUpgradeDisposition::UpgradeGatedByArchive
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradePreflight {
pub archive_risk: ArchiveRisk,
pub coverage_state: CoverageState,
pub source_coverage_ok: bool,
pub archive_safe_to_proceed: bool,
pub backup_required: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocking_reasons: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeActionStep {
pub action: UpgradeAction,
pub applicable: bool,
pub mutation_scope: UpgradeActionScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
pub opt_in: bool,
pub requires_confirmation: bool,
pub backup_required: bool,
pub blocked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unblock_precondition: Option<String>,
pub why: String,
pub proof_check: PostUpgradeCheck,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockedUpgradeCommand {
pub command: String,
pub why_blocked: String,
pub unblock_precondition: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PostUpgradeCheck {
ApiVersion,
HealthStatusReadiness,
SourceCoverage,
QuarantineStatus,
LexicalSemanticFallback,
HumanRobotParity,
}
impl PostUpgradeCheck {
pub const fn as_str(self) -> &'static str {
match self {
PostUpgradeCheck::ApiVersion => "api-version",
PostUpgradeCheck::HealthStatusReadiness => "health-status-readiness",
PostUpgradeCheck::SourceCoverage => "source-coverage",
PostUpgradeCheck::QuarantineStatus => "quarantine-status",
PostUpgradeCheck::LexicalSemanticFallback => "lexical-semantic-fallback",
PostUpgradeCheck::HumanRobotParity => "human-robot-parity",
}
}
pub const fn command(self) -> &'static str {
match self {
PostUpgradeCheck::ApiVersion => "cass api-version --json",
PostUpgradeCheck::HealthStatusReadiness => "cass health --json",
PostUpgradeCheck::SourceCoverage => "cass status --json",
PostUpgradeCheck::QuarantineStatus => "cass diag --json --quarantine",
PostUpgradeCheck::LexicalSemanticFallback => "cass status --json",
PostUpgradeCheck::HumanRobotParity => "cass status --json",
}
}
pub const fn timeout_ms(self) -> u64 {
match self {
PostUpgradeCheck::ApiVersion => 5_000,
PostUpgradeCheck::HealthStatusReadiness => 5_000,
PostUpgradeCheck::SourceCoverage => 10_000,
PostUpgradeCheck::QuarantineStatus => 10_000,
PostUpgradeCheck::LexicalSemanticFallback => 5_000,
PostUpgradeCheck::HumanRobotParity => 10_000,
}
}
pub const fn battery() -> [PostUpgradeCheck; 6] {
[
PostUpgradeCheck::ApiVersion,
PostUpgradeCheck::HealthStatusReadiness,
PostUpgradeCheck::SourceCoverage,
PostUpgradeCheck::QuarantineStatus,
PostUpgradeCheck::LexicalSemanticFallback,
PostUpgradeCheck::HumanRobotParity,
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostUpgradeCheckSpec {
pub check: PostUpgradeCheck,
pub command: String,
pub timeout_ms: u64,
pub required: bool,
}
impl PostUpgradeCheckSpec {
fn for_check(check: PostUpgradeCheck) -> Self {
Self {
check,
command: check.command().to_string(),
timeout_ms: check.timeout_ms(),
required: true,
}
}
pub fn battery() -> Vec<PostUpgradeCheckSpec> {
PostUpgradeCheck::battery()
.into_iter()
.map(PostUpgradeCheckSpec::for_check)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostUpgradeRehearsal {
pub schema_version: u32,
pub host_alias: String,
pub disposition: HostUpgradeDisposition,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_version: Option<String>,
pub target_version: String,
pub capability_gap: CapabilityGap,
pub upgrade_before_repair: bool,
pub install_method: UpgradeMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_command: Option<String>,
pub channel: String,
pub preflight: UpgradePreflight,
pub actions: Vec<UpgradeActionStep>,
pub will_check_before_mutation: Vec<String>,
pub not_touched: Vec<String>,
pub post_checks: Vec<PostUpgradeCheckSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub safe_next_commands: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_next_commands: Vec<BlockedUpgradeCommand>,
pub rehearsal_mode: RehearsalMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub live_steps_skipped: Vec<String>,
pub abort_behavior: String,
pub redaction_applied: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeBeforeAfter {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_version: Option<String>,
pub binary_upgraded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_readiness: Option<ReadinessState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_readiness: Option<ReadinessState>,
pub readiness_improved: bool,
pub derived_assets_still_stale: bool,
pub coverage_before: CoverageState,
pub coverage_after: CoverageState,
}
impl UpgradeBeforeAfter {
pub fn ended_ready(&self) -> bool {
matches!(self.after_readiness, Some(ReadinessState::Ready))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostUpgradeVerification {
pub schema_version: u32,
pub host_alias: String,
pub before_after: UpgradeBeforeAfter,
pub check_proofs: Vec<PostUpgradeCheckProof>,
pub overall_status: ProofStatus,
pub upgrade_truly_fixed: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub partial_failures: Vec<String>,
pub summary: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostUpgradeCheckProof {
pub check: PostUpgradeCheck,
pub proof: ProofArtifact,
}
pub struct HostRehearsalInput<'a> {
pub report: &'a HostDoctorReport,
pub assessment: &'a VersionAssessment,
pub coverage: &'a ArchiveCoverageSummary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FleetUpgradeRehearsal {
pub schema_version: u32,
pub target_version: String,
pub rehearsal_mode: RehearsalMode,
pub hosts: Vec<HostUpgradeRehearsal>,
pub hosts_up_to_date: u64,
pub hosts_needing_upgrade: u64,
pub hosts_unreachable: u64,
pub hosts_gated_by_archive: u64,
pub hosts_installer_unavailable: u64,
pub recommended_order: Vec<String>,
pub highest_archive_risk: ArchiveRisk,
pub any_blocked: bool,
}
fn channel_label(method: UpgradeMethod, command: Option<&str>, os: HostOs) -> String {
match method {
UpgradeMethod::SelfUpdate => "self-update".to_string(),
UpgradeMethod::Unsupported => "unsupported".to_string(),
UpgradeMethod::ManualInstaller => match command {
Some(cmd) if cmd.contains("brew") => "homebrew".to_string(),
Some(cmd) if cmd.contains("scoop") => "scoop".to_string(),
Some(cmd) if cmd.contains("install.sh") => "installer-script".to_string(),
_ => match os {
HostOs::MacOs => "homebrew".to_string(),
HostOs::Windows => "scoop".to_string(),
HostOs::Linux => "installer-script".to_string(),
HostOs::Other => "manual".to_string(),
},
},
}
}
fn source_coverage_ok(coverage: &ArchiveCoverageSummary) -> bool {
use crate::fleet_archive_coverage::ProvenanceGapKind as G;
!coverage
.provenance_gaps
.iter()
.any(|g| matches!(g.kind, G::MissingRoot | G::UnreadableRoot))
}
fn build_preflight(
report: &HostDoctorReport,
coverage: &ArchiveCoverageSummary,
) -> UpgradePreflight {
let archive_risk = report.archive_risk.max(coverage.archive_risk);
let coverage_ok = source_coverage_ok(coverage);
let high_risk = matches!(archive_risk, ArchiveRisk::High);
let archive_safe = !high_risk && coverage_ok;
let mut blocking_reasons = Vec::new();
if high_risk {
blocking_reasons.push(
"archive risk is high: back up the canonical store before any data-affecting action"
.to_string(),
);
}
if !coverage_ok {
blocking_reasons.push(
"source roots are missing or unreadable: confirm coverage before mutating".to_string(),
);
}
UpgradePreflight {
archive_risk,
coverage_state: coverage.coverage_state,
source_coverage_ok: coverage_ok,
archive_safe_to_proceed: archive_safe,
backup_required: high_risk,
blocking_reasons,
}
}
fn action_applicability(
action: UpgradeAction,
assessment: &VersionAssessment,
report: &HostDoctorReport,
coverage: &ArchiveCoverageSummary,
) -> (bool, String) {
match action {
UpgradeAction::BinaryUpgrade => (
assessment.upgrade_needed,
"binary is behind the fleet target version".to_string(),
),
UpgradeAction::DataSchemaMigration => (
matches!(assessment.capability_gap, CapabilityGap::Major),
"major version jump may require a canonical schema migration".to_string(),
),
UpgradeAction::ModelInstall => (
matches!(report.semantic, Some(SemanticState::AssetsMissing)),
"semantic model assets are missing (opt-in install; never auto-downloaded)".to_string(),
),
UpgradeAction::SourceSync => (
matches!(
report.remote_sync,
Some(RemoteSyncState::Stale) | Some(RemoteSyncState::NeverSynced)
),
"remote source mirror is stale or never synced".to_string(),
),
UpgradeAction::DerivedIndexRefresh => (
matches!(
coverage.coverage_state,
CoverageState::MissingDerivedAssets
| CoverageState::Stale
| CoverageState::RemoteCopyAhead
),
"derived index is missing or stale relative to the canonical store".to_string(),
),
}
}
fn action_command(action: UpgradeAction, install_command: Option<&str>) -> Option<String> {
match action {
UpgradeAction::BinaryUpgrade => install_command.map(str::to_string),
UpgradeAction::DataSchemaMigration => Some("cass doctor --json".to_string()),
UpgradeAction::ModelInstall => Some("cass models install --model minilm".to_string()),
UpgradeAction::SourceSync => Some("cass sources sync --all --json".to_string()),
UpgradeAction::DerivedIndexRefresh => Some("cass index --full".to_string()),
}
}
fn build_action_step(
action: UpgradeAction,
assessment: &VersionAssessment,
report: &HostDoctorReport,
coverage: &ArchiveCoverageSummary,
preflight: &UpgradePreflight,
install_command: Option<&str>,
) -> UpgradeActionStep {
let (applicable, why) = action_applicability(action, assessment, report, coverage);
let scope = action.scope();
let opt_in = matches!(action, UpgradeAction::ModelInstall);
let backup_required = matches!(scope, UpgradeActionScope::Archive)
|| (preflight.backup_required && matches!(scope, UpgradeActionScope::DerivedAssets));
let requires_confirmation = opt_in || matches!(scope, UpgradeActionScope::Archive);
let (blocked, block_reason, unblock_precondition) = if !applicable {
(false, None, None)
} else if opt_in {
(
true,
Some("opt-in only: cass never auto-downloads semantic models".to_string()),
Some("operator explicitly requests the model install".to_string()),
)
} else if backup_required {
(
true,
Some("data-affecting action requires a fresh backup first".to_string()),
Some("a verified backup of the canonical store exists".to_string()),
)
} else {
(false, None, None)
};
UpgradeActionStep {
action,
applicable,
mutation_scope: scope,
command: action_command(action, install_command),
opt_in,
requires_confirmation,
backup_required,
blocked,
block_reason,
unblock_precondition,
why,
proof_check: action_proof_check(action),
}
}
fn action_proof_check(action: UpgradeAction) -> PostUpgradeCheck {
match action {
UpgradeAction::BinaryUpgrade => PostUpgradeCheck::ApiVersion,
UpgradeAction::DataSchemaMigration => PostUpgradeCheck::HealthStatusReadiness,
UpgradeAction::ModelInstall => PostUpgradeCheck::LexicalSemanticFallback,
UpgradeAction::SourceSync => PostUpgradeCheck::SourceCoverage,
UpgradeAction::DerivedIndexRefresh => PostUpgradeCheck::HealthStatusReadiness,
}
}
fn not_touched_list() -> Vec<String> {
vec![
"provider session logs (source of truth) are read-only".to_string(),
"canonical archive DB is never deleted".to_string(),
"semantic models are never auto-downloaded".to_string(),
"derived index is only rebuilt by an explicit, named refresh step".to_string(),
"remote hosts are never mutated while classifying".to_string(),
]
}
fn pre_mutation_checks(preflight: &UpgradePreflight) -> Vec<String> {
let mut checks = vec![
format!("archive risk assessed: {}", preflight.archive_risk_label()),
format!("source coverage ok: {}", preflight.source_coverage_ok),
format!(
"coverage state: {}",
coverage_state_label(preflight.coverage_state)
),
"binary reachability confirmed before any install".to_string(),
];
if preflight.backup_required {
checks.push("backup of canonical store required before data-affecting actions".to_string());
}
checks
}
impl UpgradePreflight {
fn archive_risk_label(&self) -> &'static str {
archive_risk_label(self.archive_risk)
}
}
fn archive_risk_label(risk: ArchiveRisk) -> &'static str {
match risk {
ArchiveRisk::Unknown => "unknown",
ArchiveRisk::Low => "low",
ArchiveRisk::Medium => "medium",
ArchiveRisk::High => "high",
}
}
fn coverage_state_label(state: CoverageState) -> &'static str {
match state {
CoverageState::Fresh => "fresh",
CoverageState::MissingDerivedAssets => "missing-derived-assets",
CoverageState::SourcePruned => "source-pruned",
CoverageState::LocalArchiveAhead => "local-archive-ahead",
CoverageState::RemoteCopyAhead => "remote-copy-ahead",
CoverageState::Stale => "stale",
CoverageState::Unknown => "unknown",
}
}
fn classify_disposition(
report: &HostDoctorReport,
assessment: &VersionAssessment,
preflight: &UpgradePreflight,
) -> HostUpgradeDisposition {
if report.unreachable || matches!(report.status, HostProbeStatus::Unreachable) {
return HostUpgradeDisposition::Unreachable;
}
if !assessment.upgrade_needed {
return HostUpgradeDisposition::UpToDate;
}
if matches!(assessment.install_hint.method, UpgradeMethod::Unsupported)
|| assessment.install_hint.command.is_none()
{
return HostUpgradeDisposition::InstallerUnavailable;
}
if preflight.backup_required {
return HostUpgradeDisposition::UpgradeGatedByArchive;
}
if assessment.upgrade_before_repair {
return HostUpgradeDisposition::NeedsManualUpgrade;
}
HostUpgradeDisposition::UpgradeReady
}
pub fn rehearse_host(
report: &HostDoctorReport,
assessment: &VersionAssessment,
coverage: &ArchiveCoverageSummary,
mode: RehearsalMode,
) -> HostUpgradeRehearsal {
let preflight = build_preflight(report, coverage);
let disposition = classify_disposition(report, assessment, &preflight);
let install_command = assessment.install_hint.command.clone();
let channel = channel_label(
assessment.install_hint.method,
install_command.as_deref(),
report.platform.os,
);
let reachable = !matches!(disposition, HostUpgradeDisposition::Unreachable);
let actions: Vec<UpgradeActionStep> = if reachable {
[
UpgradeAction::BinaryUpgrade,
UpgradeAction::DataSchemaMigration,
UpgradeAction::ModelInstall,
UpgradeAction::SourceSync,
UpgradeAction::DerivedIndexRefresh,
]
.into_iter()
.map(|action| {
build_action_step(
action,
assessment,
report,
coverage,
&preflight,
install_command.as_deref(),
)
})
.collect()
} else {
Vec::new()
};
let safe_next_commands = build_safe_commands(&disposition, &actions);
let blocked_next_commands = build_blocked_commands(&actions);
let live_steps_skipped = if matches!(mode, RehearsalMode::Fixture) && reachable {
vec![
"live binary install on the remote host".to_string(),
"live remote source sync".to_string(),
"live post-upgrade probe against the real host".to_string(),
]
} else {
Vec::new()
};
HostUpgradeRehearsal {
schema_version: FLEET_UPGRADE_REHEARSAL_SCHEMA_VERSION,
host_alias: report.host_alias.clone(),
disposition,
observed_version: assessment.observed_version.clone(),
target_version: assessment.current_repo_version.clone(),
capability_gap: assessment.capability_gap,
upgrade_before_repair: assessment.upgrade_before_repair,
install_method: assessment.install_hint.method,
install_command,
channel,
will_check_before_mutation: pre_mutation_checks(&preflight),
not_touched: not_touched_list(),
post_checks: PostUpgradeCheckSpec::battery(),
safe_next_commands,
blocked_next_commands,
rehearsal_mode: mode,
live_steps_skipped,
abort_behavior:
"aborting before a mutating step leaves the host unchanged; a binary swap is reversible by reinstalling the prior version, and no archive or source log is touched"
.to_string(),
redaction_applied: false,
preflight,
actions,
}
}
fn build_safe_commands(
disposition: &HostUpgradeDisposition,
actions: &[UpgradeActionStep],
) -> Vec<String> {
match disposition {
HostUpgradeDisposition::Unreachable => {
vec!["cass doctor --check --json # retry the bounded probe".to_string()]
}
HostUpgradeDisposition::InstallerUnavailable => {
vec!["cass api-version --json # confirm the contract gap".to_string()]
}
HostUpgradeDisposition::UpToDate => {
vec!["cass health --json # already current; verify readiness".to_string()]
}
_ => actions
.iter()
.filter(|step| step.applicable && !step.blocked)
.filter_map(|step| step.command.clone())
.collect(),
}
}
fn build_blocked_commands(actions: &[UpgradeActionStep]) -> Vec<BlockedUpgradeCommand> {
actions
.iter()
.filter(|step| step.applicable && step.blocked)
.filter_map(|step| {
let command = step.command.clone()?;
Some(BlockedUpgradeCommand {
command,
why_blocked: step
.block_reason
.clone()
.unwrap_or_else(|| "blocked by an unmet precondition".to_string()),
unblock_precondition: step
.unblock_precondition
.clone()
.unwrap_or_else(|| "precondition satisfied".to_string()),
})
})
.collect()
}
fn order_key(disposition: HostUpgradeDisposition) -> u8 {
match disposition {
HostUpgradeDisposition::UpgradeReady => 0,
HostUpgradeDisposition::NeedsManualUpgrade => 1,
HostUpgradeDisposition::UpgradeGatedByArchive => 2,
_ => u8::MAX,
}
}
pub fn rehearse_fleet(
inputs: &[HostRehearsalInput<'_>],
target_version: &str,
mode: RehearsalMode,
) -> FleetUpgradeRehearsal {
let hosts: Vec<HostUpgradeRehearsal> = inputs
.iter()
.map(|input| rehearse_host(input.report, input.assessment, input.coverage, mode))
.collect();
let mut hosts_up_to_date = 0u64;
let mut hosts_needing_upgrade = 0u64;
let mut hosts_unreachable = 0u64;
let mut hosts_gated_by_archive = 0u64;
let mut hosts_installer_unavailable = 0u64;
let mut highest_archive_risk = ArchiveRisk::Unknown;
let mut any_blocked = false;
for host in &hosts {
match host.disposition {
HostUpgradeDisposition::UpToDate => hosts_up_to_date += 1,
HostUpgradeDisposition::Unreachable => hosts_unreachable += 1,
HostUpgradeDisposition::UpgradeGatedByArchive => {
hosts_gated_by_archive += 1;
hosts_needing_upgrade += 1;
}
HostUpgradeDisposition::InstallerUnavailable => {
hosts_installer_unavailable += 1;
hosts_needing_upgrade += 1;
}
HostUpgradeDisposition::UpgradeReady | HostUpgradeDisposition::NeedsManualUpgrade => {
hosts_needing_upgrade += 1
}
}
highest_archive_risk = highest_archive_risk.max(host.preflight.archive_risk);
if !host.blocked_next_commands.is_empty() {
any_blocked = true;
}
}
let mut orderable: Vec<&HostUpgradeRehearsal> = hosts
.iter()
.filter(|h| h.disposition.is_actionable_now())
.collect();
orderable.sort_by(|a, b| {
order_key(a.disposition)
.cmp(&order_key(b.disposition))
.then_with(|| a.host_alias.cmp(&b.host_alias))
});
let recommended_order: Vec<String> = orderable
.into_iter()
.map(|h| h.host_alias.clone())
.collect();
FleetUpgradeRehearsal {
schema_version: FLEET_UPGRADE_REHEARSAL_SCHEMA_VERSION,
target_version: target_version.to_string(),
rehearsal_mode: mode,
hosts,
hosts_up_to_date,
hosts_needing_upgrade,
hosts_unreachable,
hosts_gated_by_archive,
hosts_installer_unavailable,
recommended_order,
highest_archive_risk,
any_blocked,
}
}
pub fn verify_post_upgrade(
host_alias: &str,
before_after: UpgradeBeforeAfter,
checks: Vec<(PostUpgradeCheck, ProofRun)>,
stale_after_ms: u64,
) -> PostUpgradeVerification {
let check_proofs: Vec<PostUpgradeCheckProof> = checks
.into_iter()
.map(|(check, run)| PostUpgradeCheckProof {
check,
proof: ProofArtifact::from_run_with_window(run, stale_after_ms),
})
.collect();
let overall_status = check_proofs
.iter()
.map(|cp| cp.proof.status)
.max()
.unwrap_or(ProofStatus::GeneratedOnly);
let mut partial_failures: Vec<String> = check_proofs
.iter()
.filter(|cp| !cp.proof.is_trustworthy_pass())
.map(|cp| format!("{}: {}", cp.check.as_str(), cp.proof.status.as_str()))
.collect();
if before_after.derived_assets_still_stale {
partial_failures.push(
"derived-index-refresh: derived assets remain stale after the binary upgrade"
.to_string(),
);
}
let all_pass = overall_status.is_trustworthy_pass();
let readiness_ok = before_after.readiness_improved || before_after.ended_ready();
let upgrade_truly_fixed = all_pass && readiness_ok && !before_after.derived_assets_still_stale;
let summary = build_verification_summary(
host_alias,
&before_after,
overall_status,
upgrade_truly_fixed,
partial_failures.len(),
);
PostUpgradeVerification {
schema_version: FLEET_UPGRADE_REHEARSAL_SCHEMA_VERSION,
host_alias: host_alias.to_string(),
before_after,
check_proofs,
overall_status,
upgrade_truly_fixed,
partial_failures,
summary,
}
}
fn build_verification_summary(
host_alias: &str,
ba: &UpgradeBeforeAfter,
overall: ProofStatus,
truly_fixed: bool,
partial_count: usize,
) -> String {
let from = ba.before_version.as_deref().unwrap_or("unknown");
let to = ba.after_version.as_deref().unwrap_or("unknown");
if truly_fixed {
format!("{host_alias}: upgrade {from} -> {to} verified (readiness improved, checks pass)")
} else if ba.binary_upgraded {
format!(
"{host_alias}: binary upgraded {from} -> {to} but NOT fully fixed ({} unresolved, overall={})",
partial_count,
overall.as_str()
)
} else {
format!(
"{host_alias}: upgrade not verified (overall={}, {} unresolved)",
overall.as_str(),
partial_count
)
}
}
#[cfg(test)]
fn contains_credential_leak(s: &str) -> bool {
s.split_whitespace().any(|tok| {
tok.contains('@') && {
let mut parts = tok.splitn(2, '@');
let user = parts.next().unwrap_or("");
let host = parts.next().unwrap_or("");
!user.is_empty() && host.contains('.')
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fleet_archive_coverage::{ProvenanceGap, ProvenanceGapKind};
use crate::fleet_doctor_schema::{PathStyle, Platform};
use crate::fleet_version_skew::assess_host;
const TARGET: &str = "0.6.13";
fn linux_host(alias: &str, version: Option<&str>, status: HostProbeStatus) -> HostDoctorReport {
let mut h = HostDoctorReport::skeleton(alias, Platform::linux_x86_64(), status, 50);
h.cass_version = version.map(str::to_string);
h
}
fn macos_host(alias: &str, version: Option<&str>) -> HostDoctorReport {
let platform = Platform {
os: HostOs::MacOs,
arch: "aarch64".to_string(),
path_style: PathStyle::Posix,
tool_notes: vec![],
};
let mut h = HostDoctorReport::skeleton(alias, platform, HostProbeStatus::Ok, 60);
h.cass_version = version.map(str::to_string);
h
}
fn coverage_with(state: CoverageState, risk: ArchiveRisk) -> ArchiveCoverageSummary {
ArchiveCoverageSummary {
schema_version: crate::fleet_archive_coverage::ARCHIVE_COVERAGE_SCHEMA_VERSION,
root_kind_counts: Default::default(),
total_estimated_sessions: 100,
total_estimated_bytes: 4096,
approximate: false,
newest_index_ms: Some(1_000),
newest_sync_ms: Some(1_000),
provenance_gaps: Vec::new(),
coverage_state: state,
archive_risk: risk,
}
}
fn fresh_coverage() -> ArchiveCoverageSummary {
coverage_with(CoverageState::Fresh, ArchiveRisk::Low)
}
#[test]
fn old_reachable_host_is_upgrade_ready_with_self_update() {
let host = linux_host("ts2", Some("0.6.10"), HostProbeStatus::Ok);
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(
&host,
&assessment,
&fresh_coverage(),
RehearsalMode::Fixture,
);
assert_eq!(plan.disposition, HostUpgradeDisposition::UpgradeReady);
assert_eq!(plan.channel, "self-update");
assert_eq!(plan.install_command.as_deref(), Some("cass self-update"));
let binary = plan
.actions
.iter()
.find(|a| matches!(a.action, UpgradeAction::BinaryUpgrade))
.expect("binary action present");
assert!(binary.applicable);
assert!(!binary.blocked);
assert_eq!(binary.mutation_scope, UpgradeActionScope::Binary);
assert!(
plan.safe_next_commands
.iter()
.any(|c| c.contains("self-update"))
);
assert!(!plan.live_steps_skipped.is_empty());
}
#[test]
fn unreachable_host_yields_no_mutating_plan_only_a_retry() {
let host = linux_host("gone", None, HostProbeStatus::Unreachable);
let mut host = host;
host.unreachable = true;
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(
&host,
&assessment,
&fresh_coverage(),
RehearsalMode::Fixture,
);
assert_eq!(plan.disposition, HostUpgradeDisposition::Unreachable);
assert!(plan.actions.is_empty());
assert!(plan.blocked_next_commands.is_empty());
assert!(
plan.safe_next_commands
.iter()
.any(|c| c.contains("doctor --check"))
);
assert!(plan.live_steps_skipped.is_empty());
}
#[test]
fn high_archive_risk_host_gates_data_actions_behind_a_backup() {
let host = linux_host("csd", Some("0.6.10"), HostProbeStatus::Ok);
let assessment = assess_host(&host, TARGET);
let coverage = coverage_with(CoverageState::Stale, ArchiveRisk::High);
let plan = rehearse_host(&host, &assessment, &coverage, RehearsalMode::Fixture);
assert_eq!(
plan.disposition,
HostUpgradeDisposition::UpgradeGatedByArchive
);
assert!(plan.preflight.backup_required);
assert_eq!(plan.preflight.archive_risk, ArchiveRisk::High);
assert!(!plan.preflight.archive_safe_to_proceed);
assert!(!plan.preflight.blocking_reasons.is_empty());
let refresh = plan
.actions
.iter()
.find(|a| matches!(a.action, UpgradeAction::DerivedIndexRefresh))
.expect("refresh action present");
assert!(
refresh.applicable,
"stale coverage makes refresh applicable"
);
assert!(refresh.blocked);
assert!(refresh.backup_required);
assert!(
plan.blocked_next_commands
.iter()
.any(|b| b.command.contains("index --full"))
);
assert!(
!plan
.safe_next_commands
.iter()
.any(|c| c.contains("index --full"))
);
let binary = plan
.actions
.iter()
.find(|a| matches!(a.action, UpgradeAction::BinaryUpgrade))
.expect("binary action present");
assert!(!binary.backup_required);
assert!(!binary.blocked);
}
#[test]
fn unsupported_platform_is_installer_unavailable() {
let platform = Platform {
os: HostOs::Other,
arch: "riscv64".to_string(),
path_style: PathStyle::Posix,
tool_notes: vec![],
};
let mut host =
HostDoctorReport::skeleton("exotic", platform, HostProbeStatus::CommandNotFound, 10);
host.cass_version = None;
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(
&host,
&assessment,
&fresh_coverage(),
RehearsalMode::Fixture,
);
assert_eq!(
plan.disposition,
HostUpgradeDisposition::InstallerUnavailable
);
assert_eq!(plan.install_method, UpgradeMethod::Unsupported);
assert!(plan.install_command.is_none());
assert!(
!plan
.safe_next_commands
.iter()
.any(|c| c.contains("self-update") || c.contains("brew"))
);
}
#[test]
fn binary_upgraded_but_stale_derived_assets_is_not_truly_fixed() {
let before_after = UpgradeBeforeAfter {
before_version: Some("0.4.1".to_string()),
after_version: Some("0.6.13".to_string()),
binary_upgraded: true,
before_readiness: Some(ReadinessState::NotReady),
after_readiness: Some(ReadinessState::Degraded),
readiness_improved: true,
derived_assets_still_stale: true, coverage_before: CoverageState::Stale,
coverage_after: CoverageState::Stale,
};
let checks = passing_checks();
let v = verify_post_upgrade("csd", before_after, checks, 86_400_000);
assert!(!v.upgrade_truly_fixed);
assert!(v.before_after.binary_upgraded);
assert!(
v.partial_failures
.iter()
.any(|f| f.contains("derived assets remain stale"))
);
assert!(v.summary.contains("NOT fully fixed"));
}
#[test]
fn successful_upgrade_with_readiness_improvement_is_truly_fixed() {
let before_after = UpgradeBeforeAfter {
before_version: Some("0.6.10".to_string()),
after_version: Some("0.6.13".to_string()),
binary_upgraded: true,
before_readiness: Some(ReadinessState::Degraded),
after_readiness: Some(ReadinessState::Ready),
readiness_improved: true,
derived_assets_still_stale: false,
coverage_before: CoverageState::Stale,
coverage_after: CoverageState::Fresh,
};
let v = verify_post_upgrade("ts2", before_after, passing_checks(), 86_400_000);
assert!(v.upgrade_truly_fixed);
assert_eq!(v.overall_status, ProofStatus::Pass);
assert!(v.partial_failures.is_empty());
assert!(v.summary.contains("verified"));
assert_eq!(v.check_proofs.len(), 6);
}
#[test]
fn a_timed_out_post_check_rolls_up_to_timeout_and_blocks_fix() {
let before_after = UpgradeBeforeAfter {
before_version: Some("0.6.10".to_string()),
after_version: Some("0.6.13".to_string()),
binary_upgraded: true,
before_readiness: Some(ReadinessState::Degraded),
after_readiness: Some(ReadinessState::Ready),
readiness_improved: true,
derived_assets_still_stale: false,
coverage_before: CoverageState::Fresh,
coverage_after: CoverageState::Fresh,
};
let mut checks = passing_checks();
checks[0].1.timed_out = true;
let v = verify_post_upgrade("ts2", before_after, checks, 86_400_000);
assert_eq!(v.overall_status, ProofStatus::Timeout);
assert!(!v.upgrade_truly_fixed, "a timeout can never read as a fix");
assert!(v.partial_failures.iter().any(|f| f.contains("timeout")));
}
#[test]
fn no_post_checks_is_generated_only_never_a_silent_pass() {
let before_after = UpgradeBeforeAfter {
before_version: Some("0.6.13".to_string()),
after_version: Some("0.6.13".to_string()),
binary_upgraded: false,
before_readiness: Some(ReadinessState::Ready),
after_readiness: Some(ReadinessState::Ready),
readiness_improved: false,
derived_assets_still_stale: false,
coverage_before: CoverageState::Fresh,
coverage_after: CoverageState::Fresh,
};
let v = verify_post_upgrade("local", before_after, Vec::new(), 86_400_000);
assert_eq!(v.overall_status, ProofStatus::GeneratedOnly);
assert!(!v.upgrade_truly_fixed);
}
#[test]
fn model_install_is_opt_in_and_never_auto() {
let mut host = linux_host("ts2", Some("0.6.10"), HostProbeStatus::Ok);
host.semantic = Some(SemanticState::AssetsMissing);
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(
&host,
&assessment,
&fresh_coverage(),
RehearsalMode::Fixture,
);
let model = plan
.actions
.iter()
.find(|a| matches!(a.action, UpgradeAction::ModelInstall))
.expect("model action present");
assert!(model.applicable);
assert!(model.opt_in);
assert!(model.blocked, "opt-in install is blocked by default");
assert!(model.requires_confirmation);
assert!(
plan.blocked_next_commands
.iter()
.any(|b| b.command.contains("models install"))
);
assert!(
!plan
.safe_next_commands
.iter()
.any(|c| c.contains("models install"))
);
}
#[test]
fn five_distinct_actions_each_carry_a_scope_and_proof_check() {
let host = linux_host("ts2", Some("0.4.1"), HostProbeStatus::Ok);
let assessment = assess_host(&host, "1.0.0"); let plan = rehearse_host(
&host,
&assessment,
&coverage_with(CoverageState::MissingDerivedAssets, ArchiveRisk::Low),
RehearsalMode::Fixture,
);
assert_eq!(plan.actions.len(), 5);
let migration = plan
.actions
.iter()
.find(|a| matches!(a.action, UpgradeAction::DataSchemaMigration))
.expect("migration present");
assert_eq!(migration.mutation_scope, UpgradeActionScope::Archive);
assert!(migration.backup_required);
assert!(migration.requires_confirmation);
}
#[test]
fn macos_minor_gap_needs_manual_upgrade_via_homebrew() {
let host = macos_host("mac-mini-max", Some("0.6.10"));
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(
&host,
&assessment,
&fresh_coverage(),
RehearsalMode::Fixture,
);
assert_eq!(plan.install_method, UpgradeMethod::ManualInstaller);
assert_eq!(plan.channel, "homebrew");
}
#[test]
fn current_host_is_up_to_date_with_no_applicable_binary_upgrade() {
let host = linux_host("local", Some("0.6.13"), HostProbeStatus::Ok);
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(
&host,
&assessment,
&fresh_coverage(),
RehearsalMode::Fixture,
);
assert_eq!(plan.disposition, HostUpgradeDisposition::UpToDate);
let binary = plan
.actions
.iter()
.find(|a| matches!(a.action, UpgradeAction::BinaryUpgrade))
.expect("binary action present");
assert!(!binary.applicable);
}
#[test]
fn fleet_rollup_counts_orders_and_takes_max_archive_risk() {
let local = linux_host("local", Some("0.6.13"), HostProbeStatus::Ok);
let ts2 = linux_host("ts2", Some("0.6.10"), HostProbeStatus::Ok);
let csd = linux_host("csd", Some("0.6.10"), HostProbeStatus::Ok);
let mut gone = linux_host("gone", None, HostProbeStatus::Unreachable);
gone.unreachable = true;
let a_local = assess_host(&local, TARGET);
let a_ts2 = assess_host(&ts2, TARGET);
let a_csd = assess_host(&csd, TARGET);
let a_gone = assess_host(&gone, TARGET);
let cov_fresh = fresh_coverage();
let cov_high = coverage_with(CoverageState::Stale, ArchiveRisk::High);
let inputs = vec![
HostRehearsalInput {
report: &local,
assessment: &a_local,
coverage: &cov_fresh,
},
HostRehearsalInput {
report: &ts2,
assessment: &a_ts2,
coverage: &cov_fresh,
},
HostRehearsalInput {
report: &csd,
assessment: &a_csd,
coverage: &cov_high,
},
HostRehearsalInput {
report: &gone,
assessment: &a_gone,
coverage: &cov_fresh,
},
];
let fleet = rehearse_fleet(&inputs, TARGET, RehearsalMode::Fixture);
assert_eq!(fleet.hosts_up_to_date, 1); assert_eq!(fleet.hosts_unreachable, 1); assert_eq!(fleet.hosts_gated_by_archive, 1); assert_eq!(fleet.hosts_needing_upgrade, 2); assert_eq!(fleet.highest_archive_risk, ArchiveRisk::High);
assert!(fleet.any_blocked);
assert_eq!(fleet.recommended_order, vec!["ts2", "csd"]);
}
#[test]
fn emitted_commands_never_leak_ssh_credentials() {
let host = linux_host("ts2", Some("0.6.10"), HostProbeStatus::Ok);
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(
&host,
&assessment,
&fresh_coverage(),
RehearsalMode::Fixture,
);
let json = serde_json::to_string(&plan).expect("serialize");
assert!(
!contains_credential_leak(&json),
"rehearsal must not embed user@host credentials"
);
assert!(contains_credential_leak("ssh deploy@host.example.com"));
assert!(!contains_credential_leak("cass self-update"));
}
#[test]
fn rehearsal_serializes_with_stable_fields_and_round_trips() {
let host = linux_host("ts2", Some("0.6.10"), HostProbeStatus::Ok);
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(
&host,
&assessment,
&fresh_coverage(),
RehearsalMode::Fixture,
);
let value = serde_json::to_value(&plan).expect("to_value");
assert_eq!(
value["schema_version"],
FLEET_UPGRADE_REHEARSAL_SCHEMA_VERSION
);
assert_eq!(value["host_alias"], "ts2");
assert_eq!(value["disposition"], "upgrade-ready");
assert_eq!(value["channel"], "self-update");
assert_eq!(value["rehearsal_mode"], "fixture");
assert_eq!(value["capability_gap"], "minor");
assert_eq!(value["post_checks"].as_array().map(|a| a.len()), Some(6));
let back: HostUpgradeRehearsal = serde_json::from_value(value).expect("round-trip");
assert_eq!(back, plan);
}
#[test]
fn wire_labels_are_stable_kebab() {
assert_eq!(RehearsalMode::Fixture.as_str(), "fixture");
assert_eq!(RehearsalMode::Live.as_str(), "live");
assert_eq!(UpgradeAction::BinaryUpgrade.as_str(), "binary-upgrade");
assert_eq!(
UpgradeAction::DataSchemaMigration.as_str(),
"data-schema-migration"
);
assert_eq!(UpgradeActionScope::DerivedAssets.as_str(), "derived-assets");
assert_eq!(
HostUpgradeDisposition::UpgradeGatedByArchive.as_str(),
"upgrade-gated-by-archive"
);
assert_eq!(PostUpgradeCheck::ApiVersion.as_str(), "api-version");
assert_eq!(
serde_json::to_string(&UpgradeAction::SourceSync).expect("ser"),
"\"source-sync\""
);
}
#[test]
fn rehearsal_mode_maps_to_runner_mode() {
assert_eq!(RehearsalMode::Fixture.run_mode(), RunMode::Ci);
assert_eq!(RehearsalMode::Live.run_mode(), RunMode::Live);
}
#[test]
fn missing_root_breaks_source_coverage_ok() {
let mut coverage = fresh_coverage();
coverage.provenance_gaps.push(ProvenanceGap {
kind: ProvenanceGapKind::MissingRoot,
path: "/home/user/.claude/projects".to_string(),
});
let host = linux_host("ts2", Some("0.6.10"), HostProbeStatus::Ok);
let assessment = assess_host(&host, TARGET);
let plan = rehearse_host(&host, &assessment, &coverage, RehearsalMode::Fixture);
assert!(!plan.preflight.source_coverage_ok);
assert!(!plan.preflight.archive_safe_to_proceed);
}
fn passing_run(command: &str) -> ProofRun {
ProofRun {
command: command.to_string(),
binary_path: Some("/usr/local/bin/cass".to_string()),
binary_version: Some("0.6.13".to_string()),
data_dir_or_fixture: Some("fixture:upgraded".to_string()),
exit_code: Some(0),
elapsed_ms: 80,
timeout_ms: 5_000,
timed_out: false,
skipped: false,
assertions_ran: true,
produced_artifact: true,
completed: true,
artifact_age_ms: Some(10),
stdout_path: None,
stderr_path: None,
}
}
fn passing_checks() -> Vec<(PostUpgradeCheck, ProofRun)> {
PostUpgradeCheck::battery()
.into_iter()
.map(|c| (c, passing_run(c.command())))
.collect()
}
}