nixfleet_state_machine/rollout/transitions/
failed.rs

1//! `Failed` source state. A host hit `halt-only` policy; operator
2//! action required. Same exit shape as `Reverted`.
3
4use chrono::{DateTime, Utc};
5
6use super::{illegal, transition_effect};
7use crate::rollout::effect::RolloutEffect;
8use crate::rollout::error::RolloutTransitionError;
9use crate::rollout::event::RolloutEvent;
10use crate::rollout::state::{RolloutRecord, RolloutState};
11
12pub(super) fn step(
13    mut record: RolloutRecord,
14    event: RolloutEvent,
15    _now: DateTime<Utc>,
16) -> Result<(RolloutRecord, Vec<RolloutEffect>), RolloutTransitionError> {
17    match event {
18        RolloutEvent::SuccessorOpened { at, .. } => {
19            record.state = RolloutState::Superseded;
20            record.superseded_at = Some(at);
21            let effects = vec![transition_effect(
22                &record,
23                RolloutState::Failed,
24                RolloutState::Superseded,
25                at,
26            )];
27            Ok((record, effects))
28        }
29        RolloutEvent::RetentionExpired { at, .. } => {
30            record.state = RolloutState::Pruned;
31            let effects = vec![transition_effect(
32                &record,
33                RolloutState::Failed,
34                RolloutState::Pruned,
35                at,
36            )];
37            Ok((record, effects))
38        }
39        RolloutEvent::OperatorClearance { .. } => Err(RolloutTransitionError::Unimplemented {
40            from: RolloutState::Failed,
41            event: event.kind(),
42        }),
43        RolloutEvent::HostStateChanged { .. }
44        | RolloutEvent::HostJoined { .. }
45        | RolloutEvent::WaveAdvanced { .. }
46        | RolloutEvent::RolloutTerminal { .. } => Ok((record, Vec::new())),
47        RolloutEvent::RolloutOpened { .. } => Err(illegal(
48            RolloutState::Failed,
49            &event,
50            record.rollout_id.clone(),
51        )),
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use chrono::TimeZone;
59
60    fn t0() -> DateTime<Utc> {
61        Utc.with_ymd_and_hms(2026, 5, 16, 1, 0, 0).unwrap()
62    }
63
64    fn failed_record() -> RolloutRecord {
65        RolloutRecord {
66            rollout_id: "r1".into(),
67            channel: "stable".into(),
68            target_ref: "ref-1".into(),
69            state: RolloutState::Failed,
70            current_wave: 0,
71            opened_event_log_seq: None,
72            last_transition_event_log_seq: None,
73            opened_at: t0(),
74            terminal_at: None,
75            superseded_at: None,
76        }
77    }
78
79    #[test]
80    fn successor_opened_transitions_to_superseded() {
81        let event = RolloutEvent::SuccessorOpened {
82            superseded_rollout_id: "r1".into(),
83            successor_rollout_id: "r2".into(),
84            at: t0(),
85        };
86        let (record, _) = step(failed_record(), event, t0()).unwrap();
87        assert_eq!(record.state, RolloutState::Superseded);
88    }
89
90    #[test]
91    fn retention_expired_transitions_to_pruned() {
92        let event = RolloutEvent::RetentionExpired {
93            rollout_id: "r1".into(),
94            at: t0(),
95        };
96        let (record, _) = step(failed_record(), event, t0()).unwrap();
97        assert_eq!(record.state, RolloutState::Pruned);
98    }
99}