nixfleet_state_machine/transitions/
deferred.rs

1//! Transitions from `Deferred`.
2//!
3//! Activation was set up at the bootloader level but the live switch
4//! was deferred because a critical component (dbus/systemd/kernel/init)
5//! cannot be live-swapped on a running system. The host stays Deferred
6//! until the operator reboots; on reboot the agent's boot-recovery
7//! handshake observes `current_closure == target_closure` and CP's
8//! `handle_heartbeat` synthesis (LIFT #1) drives the transition out
9//! via `RemoteActivationCompleted`.
10//!
11//! Legal events from Deferred:
12//!   - `LocalActivationCompleted` / `RemoteActivationCompleted` —
13//!     post-reboot, the activation has actually taken; drives
14//!     `Deferred → Soaking` (same target state as Activating →
15//!     Soaking). LIFT #1's CP-side synthesis is the typical emitter.
16//!   - `LocalActivationFailed` / `RemoteActivationFailed` — operator
17//!     rebooted but boot failed / verification failed. Drives
18//!     `Deferred → Failed`. Rare; covered for completeness.
19//!
20//! All other events are illegal from Deferred (probe events: probes
21//! aren't running yet because activation hasn't completed; rollback
22//! events: nothing to roll back from at the in-memory state level —
23//! the bootloader has the new target; rollback semantics for Deferred
24//! is a v0.3 design question).
25
26use chrono::{DateTime, Utc};
27use nixfleet_proto::RolloutPolicy;
28
29use crate::effect::{Effect, OutboundAgentEvent};
30use crate::error::TransitionError;
31use crate::event::Event;
32use crate::state::{HostRolloutState, HostState};
33
34use super::illegal;
35
36pub(super) fn handle(
37    mut state: HostRolloutState,
38    event: Event,
39    _now: DateTime<Utc>,
40    _policy: &RolloutPolicy,
41) -> Result<(HostRolloutState, Vec<Effect>), TransitionError> {
42    match event {
43        // Deferred → Soaking. Agent rebooted; activation took live;
44        // current_closure now matches target. Typical emitter is
45        // LIFT #1's CP-side synthesis (handle_heartbeat sees
46        // current == target while state ∈ {Activating, Deferred}).
47        Event::LocalActivationCompleted {
48            observed_current_closure,
49            exit_code,
50            completed_at,
51            seq,
52        } => {
53            let from = state.state;
54            state.state = HostState::Soaking;
55            state.activation_completed_at = Some(completed_at);
56            state.current_closure = Some(observed_current_closure.clone());
57            state.probes.clear();
58            state.last_event_seq = seq;
59
60            let effects = vec![
61                Effect::LocalResetProbeCache {
62                    rollout_id: state.rollout_id.clone(),
63                },
64                Effect::LocalEmitEvent {
65                    rollout_id: state.rollout_id.clone(),
66                    payload: OutboundAgentEvent::ActivationCompleted {
67                        observed_current_closure,
68                        exit_code,
69                        completed_at,
70                        seq,
71                    },
72                    durable: true,
73                },
74                Effect::RecordTransition {
75                    host: state.hostname.clone(),
76                    rollout_id: state.rollout_id.clone(),
77                    from,
78                    to: HostState::Soaking,
79                    at: completed_at,
80                },
81            ];
82            Ok((state, effects))
83        }
84        Event::RemoteActivationCompleted {
85            observed_current_closure,
86            exit_code,
87            completed_at,
88            seq,
89        } => {
90            let from = state.state;
91            state.state = HostState::Soaking;
92            state.activation_completed_at = Some(completed_at);
93            state.current_closure = Some(observed_current_closure.clone());
94            state.probes.clear();
95            state.last_event_seq = seq;
96
97            let effects = vec![
98                Effect::RemoteAppendEventLog {
99                    host: state.hostname.clone(),
100                    rollout_id: state.rollout_id.clone(),
101                    payload: OutboundAgentEvent::ActivationCompleted {
102                        observed_current_closure,
103                        exit_code,
104                        completed_at,
105                        seq,
106                    },
107                },
108                Effect::RecordTransition {
109                    host: state.hostname.clone(),
110                    rollout_id: state.rollout_id.clone(),
111                    from,
112                    to: HostState::Soaking,
113                    at: completed_at,
114                },
115            ];
116            Ok((state, effects))
117        }
118
119        // Deferred → Failed. Operator rebooted but boot failed or
120        // verification failed. Same shape as Activating → Failed but
121        // from the Deferred source.
122        Event::LocalActivationFailed {
123            exit_code,
124            stderr_tail,
125            failed_at,
126            seq,
127        } => {
128            let from = state.state;
129            state.state = HostState::Failed;
130            state.activation_failed_at = Some(failed_at);
131            state.failed_at = Some(failed_at);
132            state.last_event_seq = seq;
133
134            let effects = vec![
135                Effect::LocalEmitEvent {
136                    rollout_id: state.rollout_id.clone(),
137                    payload: OutboundAgentEvent::ActivationFailed {
138                        exit_code,
139                        stderr_tail,
140                        failed_at,
141                        seq,
142                    },
143                    durable: true,
144                },
145                Effect::RecordTransition {
146                    host: state.hostname.clone(),
147                    rollout_id: state.rollout_id.clone(),
148                    from,
149                    to: HostState::Failed,
150                    at: failed_at,
151                },
152            ];
153            Ok((state, effects))
154        }
155        Event::RemoteActivationFailed {
156            exit_code,
157            stderr_tail,
158            failed_at,
159            seq,
160        } => {
161            let from = state.state;
162            state.state = HostState::Failed;
163            state.activation_failed_at = Some(failed_at);
164            state.failed_at = Some(failed_at);
165            state.last_event_seq = seq;
166
167            let effects = vec![
168                Effect::RemoteAppendEventLog {
169                    host: state.hostname.clone(),
170                    rollout_id: state.rollout_id.clone(),
171                    payload: OutboundAgentEvent::ActivationFailed {
172                        exit_code,
173                        stderr_tail,
174                        failed_at,
175                        seq,
176                    },
177                },
178                Effect::RecordTransition {
179                    host: state.hostname.clone(),
180                    rollout_id: state.rollout_id.clone(),
181                    from,
182                    to: HostState::Failed,
183                    at: failed_at,
184                },
185            ];
186            Ok((state, effects))
187        }
188
189        _ => Err(illegal(&state, &event)),
190    }
191}