use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use crafty_proto::NodeId;
use serde::{Deserialize, Serialize};
use crate::proto::LogIndex;
use crate::state_machine::StateMachine;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactManifest {
pub app_version: String,
pub url: String,
pub sha256_hex: String,
#[serde(default)]
pub min_protocol: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum UpgradeCommand {
SetDesired(ArtifactManifest),
Grant {
node_id: NodeId,
},
Report {
node_id: NodeId,
phase: UpgradePhase,
},
Abort {
reason: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum UpgradePhase {
Downloading,
Installed,
Restarting,
Ready,
Failed {
message: String,
},
}
impl UpgradePhase {
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Ready | Self::Failed { .. })
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum UpgradeQuery {
View {
members: Vec<NodeId>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum UpgradeResponse {
Ok,
View(UpgradeView),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpgradeError {
NoDesired,
InvalidGrant,
Aborted,
Snapshot,
}
impl fmt::Display for UpgradeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoDesired => f.write_str("no desired upgrade"),
Self::InvalidGrant => f.write_str("invalid grant"),
Self::Aborted => f.write_str("upgrade aborted"),
Self::Snapshot => f.write_str("upgrade snapshot error"),
}
}
}
impl std::error::Error for UpgradeError {}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeState {
desired: Option<ArtifactManifest>,
granted: Option<NodeId>,
completed: BTreeSet<NodeId>,
last_report: BTreeMap<NodeId, UpgradePhase>,
aborted: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeView {
pub desired: Option<ArtifactManifest>,
pub granted: Option<NodeId>,
pub completed: BTreeSet<NodeId>,
pub pending: Vec<NodeId>,
pub fleet_ready: bool,
pub aborted: Option<String>,
}
#[must_use]
pub fn plan_next_grant(
state: &UpgradeState,
members: &[NodeId],
leader_id: NodeId,
) -> Option<NodeId> {
if state.aborted.is_some() || state.desired.is_none() {
return None;
}
if state.granted.is_some() {
return None;
}
let mut pending: Vec<NodeId> = members
.iter()
.copied()
.filter(|id| !state.completed.contains(id))
.collect();
if pending.is_empty() {
return None;
}
pending.sort_by_key(|id| id.0);
let non_leader: Vec<_> = pending
.iter()
.copied()
.filter(|&id| id != leader_id)
.collect();
if non_leader.is_empty() {
pending.into_iter().min_by_key(|id| id.0)
} else {
non_leader.into_iter().min_by_key(|id| id.0)
}
}
#[must_use]
pub fn upgrade_view(state: &UpgradeState, members: &[NodeId]) -> UpgradeView {
let pending: Vec<NodeId> = members
.iter()
.copied()
.filter(|id| !state.completed.contains(id))
.collect();
let fleet_ready = state.desired.is_some() && pending.is_empty() && state.aborted.is_none();
UpgradeView {
desired: state.desired.clone(),
granted: state.granted,
completed: state.completed.clone(),
pending,
fleet_ready,
aborted: state.aborted.clone(),
}
}
#[must_use]
pub fn upgrade_state_for_planning(view: &UpgradeView) -> UpgradeState {
UpgradeState {
desired: view.desired.clone(),
granted: view.granted,
completed: view.completed.clone(),
last_report: BTreeMap::new(),
aborted: view.aborted.clone(),
}
}
fn apply_command(state: &mut UpgradeState, command: &UpgradeCommand) -> Result<(), UpgradeError> {
match command {
UpgradeCommand::SetDesired(manifest) => {
state.desired = Some(manifest.clone());
state.granted = None;
state.completed.clear();
state.last_report.clear();
state.aborted = None;
}
UpgradeCommand::Grant { node_id } => {
if state.aborted.is_some() || state.desired.is_none() || state.granted.is_some() {
return Err(UpgradeError::InvalidGrant);
}
state.granted = Some(*node_id);
}
UpgradeCommand::Report { node_id, phase } => {
state.last_report.insert(*node_id, phase.clone());
if matches!(phase, UpgradePhase::Ready) {
state.completed.insert(*node_id);
if state.granted == Some(*node_id) {
state.granted = None;
}
}
if matches!(phase, UpgradePhase::Failed { .. }) && state.granted == Some(*node_id) {
state.granted = None;
}
}
UpgradeCommand::Abort { reason } => {
state.aborted = Some(reason.clone());
state.granted = None;
}
}
Ok(())
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeMachine {
inner: UpgradeState,
}
impl UpgradeMachine {
#[must_use]
pub fn state(&self) -> &UpgradeState {
&self.inner
}
}
pub type UpgradeStateMachine = UpgradeMachine;
impl StateMachine for UpgradeMachine {
type Command = UpgradeCommand;
type Query = UpgradeQuery;
type Response = UpgradeResponse;
type Error = UpgradeError;
fn apply(
&mut self,
_index: LogIndex,
command: &UpgradeCommand,
) -> Result<UpgradeResponse, UpgradeError> {
apply_command(&mut self.inner, command)?;
Ok(UpgradeResponse::Ok)
}
fn query(&self, query: &UpgradeQuery) -> Result<UpgradeResponse, UpgradeError> {
Ok(match query {
UpgradeQuery::View { members } => {
UpgradeResponse::View(upgrade_view(&self.inner, members))
}
})
}
fn snapshot(&self) -> Result<Vec<u8>, UpgradeError> {
crate::proto::encode(&self.inner).map_err(|_| UpgradeError::Snapshot)
}
fn restore(&mut self, snapshot: &[u8]) -> Result<(), UpgradeError> {
self.inner = crate::proto::decode(snapshot).map_err(|_| UpgradeError::Snapshot)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn members() -> Vec<NodeId> {
vec![NodeId(1), NodeId(2), NodeId(3)]
}
#[test]
fn leader_last_grant_ordering() {
let mut state = UpgradeState {
desired: Some(ArtifactManifest {
app_version: "1.0.0".into(),
url: "file:///tmp/x".into(),
sha256_hex: "00".repeat(64),
min_protocol: None,
}),
..Default::default()
};
let leader = NodeId(2);
let m = members();
assert_eq!(plan_next_grant(&state, &m, leader), Some(NodeId(1)));
state.granted = Some(NodeId(1));
state.last_report.insert(NodeId(1), UpgradePhase::Ready);
state.completed.insert(NodeId(1));
state.granted = None;
assert_eq!(plan_next_grant(&state, &m, leader), Some(NodeId(3)));
state.completed.insert(NodeId(3));
assert_eq!(plan_next_grant(&state, &m, leader), Some(NodeId(2)));
}
#[test]
fn report_ready_clears_grant_and_completes_node() {
let mut sm = UpgradeMachine::default();
let manifest = ArtifactManifest {
app_version: "2.0.0".into(),
url: "file:///bin".into(),
sha256_hex: "ab".repeat(64),
min_protocol: None,
};
sm.apply(LogIndex(1), &UpgradeCommand::SetDesired(manifest))
.unwrap();
sm.apply(LogIndex(2), &UpgradeCommand::Grant { node_id: NodeId(1) })
.unwrap();
sm.apply(
LogIndex(3),
&UpgradeCommand::Report {
node_id: NodeId(1),
phase: UpgradePhase::Ready,
},
)
.unwrap();
assert!(sm.state().completed.contains(&NodeId(1)));
assert_eq!(sm.state().granted, None);
}
#[test]
fn fleet_ready_when_all_members_complete() {
let mut sm = UpgradeMachine::default();
sm.apply(
LogIndex(1),
&UpgradeCommand::SetDesired(ArtifactManifest {
app_version: "1.0.0".into(),
url: "file:///x".into(),
sha256_hex: "00".repeat(64),
min_protocol: None,
}),
)
.unwrap();
for (idx, id) in members().into_iter().enumerate() {
sm.apply(
LogIndex(u64::try_from(idx + 2).unwrap()),
&UpgradeCommand::Report {
node_id: id,
phase: UpgradePhase::Ready,
},
)
.unwrap();
}
let UpgradeResponse::View(view) = sm
.query(&UpgradeQuery::View { members: members() })
.unwrap()
else {
panic!("expected view");
};
assert!(view.fleet_ready);
assert!(view.pending.is_empty());
}
}