nixfleet_proto/
host_rollout_state.rs

1//! Wire-side per-host rollout state. Mirrors RFC-0005 §3's 6-state machine.
2//!
3//! The CP's internal source of truth lives in
4//! [`nixfleet_state_machine::HostState`]; this proto type exists for
5//! HTTP / JSON serialization on the legacy `/v1/hosts` view layer. The
6//! six variants are 1:1 with the state-machine enum.
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct HostRolloutStateParseError {
12    pub got: String,
13}
14
15impl std::fmt::Display for HostRolloutStateParseError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        write!(f, "unknown host_rollout_state: {:?}", self.got)
18    }
19}
20
21impl std::error::Error for HostRolloutStateParseError {}
22
23/// 7-state machine per RFC-0005 §3. `Deferred` covers the
24/// "activation staged but live-switch skipped pending operator
25/// reboot" case (critical-component swap; see RFC-0005 §3.5).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum HostRolloutState {
28    Pending,
29    Activating,
30    Deferred,
31    Soaking,
32    Converged,
33    Failed,
34    Reverted,
35}
36
37impl HostRolloutState {
38    /// Canonical literal — matches the SQL CHECK on
39    /// `host_rollout_records.state` and the RFC-0005 §3 wire shape.
40    pub fn as_db_str(&self) -> &'static str {
41        match self {
42            HostRolloutState::Pending => "Pending",
43            HostRolloutState::Activating => "Activating",
44            HostRolloutState::Deferred => "Deferred",
45            HostRolloutState::Soaking => "Soaking",
46            HostRolloutState::Converged => "Converged",
47            HostRolloutState::Failed => "Failed",
48            HostRolloutState::Reverted => "Reverted",
49        }
50    }
51
52    pub fn from_db_str(s: &str) -> Result<Self, HostRolloutStateParseError> {
53        match s {
54            "Pending" => Ok(HostRolloutState::Pending),
55            "Activating" => Ok(HostRolloutState::Activating),
56            "Deferred" => Ok(HostRolloutState::Deferred),
57            "Soaking" => Ok(HostRolloutState::Soaking),
58            "Converged" => Ok(HostRolloutState::Converged),
59            "Failed" => Ok(HostRolloutState::Failed),
60            "Reverted" => Ok(HostRolloutState::Reverted),
61            other => Err(HostRolloutStateParseError {
62                got: other.to_string(),
63            }),
64        }
65    }
66
67    /// Terminal-for-ordering: predecessor hosts/waves can release once
68    /// every host hits this state. Converged is the canonical
69    /// health-verified state; Deferred is "staged for reboot,
70    /// ordering-eligible but not health-verified" — both clear
71    /// host-edges + wave-promotion gates. `channel_edges` keeps the
72    /// stricter Converged-only predicate (cross-channel cascade
73    /// should wait for actual verification).
74    pub fn is_terminal_for_ordering(&self) -> bool {
75        matches!(self, Self::Converged | Self::Deferred)
76    }
77
78    /// Host is consuming a disruption-budget slot (still moving through
79    /// activation / soak). Deferred is NOT in-flight: the in-memory
80    /// activation work is done, the host is just waiting on the
81    /// operator to reboot.
82    pub fn is_in_flight(&self) -> bool {
83        matches!(self, Self::Pending | Self::Activating | Self::Soaking)
84    }
85
86    /// Stuck and staying stuck; needs operator action.
87    pub fn is_failed(&self) -> bool {
88        matches!(self, Self::Failed | Self::Reverted)
89    }
90}
91
92#[cfg(feature = "rusqlite")]
93mod rusqlite_impls {
94    use super::*;
95    use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
96
97    impl ToSql for HostRolloutState {
98        fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
99            Ok(ToSqlOutput::Borrowed(self.as_db_str().into()))
100        }
101    }
102
103    impl FromSql for HostRolloutState {
104        fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
105            let s = value.as_str()?;
106            Self::from_db_str(s).map_err(|e| FromSqlError::Other(Box::new(e)))
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn round_trip_known_values() {
117        for v in [
118            HostRolloutState::Pending,
119            HostRolloutState::Activating,
120            HostRolloutState::Deferred,
121            HostRolloutState::Soaking,
122            HostRolloutState::Converged,
123            HostRolloutState::Failed,
124            HostRolloutState::Reverted,
125        ] {
126            assert_eq!(HostRolloutState::from_db_str(v.as_db_str()).unwrap(), v);
127        }
128    }
129
130    #[test]
131    fn legacy_variants_no_longer_parse() {
132        for legacy in ["Queued", "Dispatched", "ConfirmWindow", "Healthy", "Soaked"] {
133            assert!(
134                HostRolloutState::from_db_str(legacy).is_err(),
135                "v0.1 variant {legacy} must not parse against v0.2 wire shape",
136            );
137        }
138    }
139
140    #[test]
141    fn unknown_strings_error() {
142        assert!(HostRolloutState::from_db_str("").is_err());
143        assert!(HostRolloutState::from_db_str("pending").is_err());
144        assert!(HostRolloutState::from_db_str("Pendng").is_err());
145    }
146}