nixfleet_state_machine/transitions/
activating.rs

1//! Transitions from `Activating`.
2//!
3//! - `LocalActivationStarted` / `RemoteActivationStarted` — visibility, no
4//!   state change; stamps `activation_started_at`.
5//! - `LocalActivationCompleted` / `RemoteActivationCompleted` — drives
6//!   `Activating → Soaking`, sets `current_closure`, resets probe cache.
7//! - `LocalActivationFailed` / `RemoteActivationFailed` — drives
8//!   `Activating → Failed`; agent reads `onHealthFailure` from policy and
9//!   (if `rollback-and-halt`) fires the rollback in the same handler.
10
11use chrono::{DateTime, Utc};
12use nixfleet_proto::{OnHealthFailure, RolloutPolicy};
13
14use crate::effect::{Effect, OutboundAgentEvent};
15use crate::error::TransitionError;
16use crate::event::Event;
17use crate::state::{HostRolloutState, HostState};
18
19use super::illegal;
20
21pub(super) fn handle(
22    mut state: HostRolloutState,
23    event: Event,
24    _now: DateTime<Utc>,
25    policy: &RolloutPolicy,
26) -> Result<(HostRolloutState, Vec<Effect>), TransitionError> {
27    match event {
28        // Visibility — no transition, stamp + emit
29        Event::LocalActivationStarted {
30            started_at,
31            switch_method,
32            seq,
33        } => {
34            state.activation_started_at = Some(started_at);
35            state.last_event_seq = seq;
36            let effects = vec![Effect::LocalEmitEvent {
37                rollout_id: state.rollout_id.clone(),
38                payload: OutboundAgentEvent::ActivationStarted {
39                    started_at,
40                    switch_method,
41                    seq,
42                },
43                durable: true,
44            }];
45            Ok((state, effects))
46        }
47        Event::RemoteActivationStarted {
48            started_at,
49            switch_method,
50            seq,
51        } => {
52            state.activation_started_at = Some(started_at);
53            state.last_event_seq = seq;
54            let effects = vec![Effect::RemoteAppendEventLog {
55                host: state.hostname.clone(),
56                rollout_id: state.rollout_id.clone(),
57                payload: OutboundAgentEvent::ActivationStarted {
58                    started_at,
59                    switch_method,
60                    seq,
61                },
62            }];
63            Ok((state, effects))
64        }
65
66        // Activating → Soaking
67        Event::LocalActivationCompleted {
68            observed_current_closure,
69            exit_code,
70            completed_at,
71            seq,
72        } => {
73            let from = state.state;
74            state.state = HostState::Soaking;
75            state.activation_completed_at = Some(completed_at);
76            state.current_closure = Some(observed_current_closure.clone());
77            state.probes.clear();
78            state.last_event_seq = seq;
79
80            let effects = vec![
81                Effect::LocalResetProbeCache {
82                    rollout_id: state.rollout_id.clone(),
83                },
84                Effect::LocalEmitEvent {
85                    rollout_id: state.rollout_id.clone(),
86                    payload: OutboundAgentEvent::ActivationCompleted {
87                        observed_current_closure,
88                        exit_code,
89                        completed_at,
90                        seq,
91                    },
92                    durable: true,
93                },
94                Effect::RecordTransition {
95                    host: state.hostname.clone(),
96                    rollout_id: state.rollout_id.clone(),
97                    from,
98                    to: HostState::Soaking,
99                    at: completed_at,
100                },
101            ];
102            Ok((state, effects))
103        }
104        Event::RemoteActivationCompleted {
105            observed_current_closure,
106            exit_code,
107            completed_at,
108            seq,
109        } => {
110            let from = state.state;
111            state.state = HostState::Soaking;
112            state.activation_completed_at = Some(completed_at);
113            state.current_closure = Some(observed_current_closure.clone());
114            state.probes.clear();
115            state.last_event_seq = seq;
116
117            let effects = vec![
118                Effect::RemoteAppendEventLog {
119                    host: state.hostname.clone(),
120                    rollout_id: state.rollout_id.clone(),
121                    payload: OutboundAgentEvent::ActivationCompleted {
122                        observed_current_closure,
123                        exit_code,
124                        completed_at,
125                        seq,
126                    },
127                },
128                Effect::RecordTransition {
129                    host: state.hostname.clone(),
130                    rollout_id: state.rollout_id.clone(),
131                    from,
132                    to: HostState::Soaking,
133                    at: completed_at,
134                },
135            ];
136            Ok((state, effects))
137        }
138
139        // Activating → Activating (no state change). Live switch was
140        // deferred because a critical component (dbus/systemd/kernel)
141        // cannot be live-swapped. Profile + bootloader are correct;
142        // the activation completes on next reboot via boot-recovery
143        // (LIFT #1's handle_heartbeat synthesis).
144        // Activating → Deferred. Live switch skipped because a critical
145        // component (dbus/systemd/kernel/init) cannot be live-swapped.
146        // The host is ordering-eligible (cascade can progress past it
147        // for host-edges + wave-promotion + advance_current_waves) but
148        // not health-verified (channel-edges still waits for actual
149        // Converged). On reboot, the agent's boot-recovery handshake
150        // triggers LIFT #1's `RemoteActivationCompleted` synthesis →
151        // `Deferred → Soaking` via deferred.rs.
152        Event::LocalActivationDeferred {
153            component,
154            deferred_at,
155            seq,
156        } => {
157            let from = state.state;
158            state.state = HostState::Deferred;
159            state.last_event_seq = seq;
160            let effects = vec![
161                Effect::LocalEmitEvent {
162                    rollout_id: state.rollout_id.clone(),
163                    payload: OutboundAgentEvent::ActivationDeferred {
164                        component,
165                        deferred_at,
166                        seq,
167                    },
168                    durable: true,
169                },
170                Effect::RecordTransition {
171                    host: state.hostname.clone(),
172                    rollout_id: state.rollout_id.clone(),
173                    from,
174                    to: HostState::Deferred,
175                    at: deferred_at,
176                },
177            ];
178            Ok((state, effects))
179        }
180
181        // CP-side mirror of LocalActivationDeferred. Same transition
182        // shape (Activating → Deferred), but uses RemoteAppendEventLog
183        // (CP writes directly, bypassing the outbound queue).
184        Event::RemoteActivationDeferred {
185            component,
186            deferred_at,
187            seq,
188        } => {
189            let from = state.state;
190            state.state = HostState::Deferred;
191            state.last_event_seq = seq;
192            let effects = vec![
193                Effect::RemoteAppendEventLog {
194                    host: state.hostname.clone(),
195                    rollout_id: state.rollout_id.clone(),
196                    payload: OutboundAgentEvent::ActivationDeferred {
197                        component,
198                        deferred_at,
199                        seq,
200                    },
201                },
202                Effect::RecordTransition {
203                    host: state.hostname.clone(),
204                    rollout_id: state.rollout_id.clone(),
205                    from,
206                    to: HostState::Deferred,
207                    at: deferred_at,
208                },
209            ];
210            Ok((state, effects))
211        }
212
213        // Activating → Failed
214        Event::LocalActivationFailed {
215            exit_code,
216            stderr_tail,
217            failed_at,
218            seq,
219        } => {
220            let from = state.state;
221            state.state = HostState::Failed;
222            state.activation_failed_at = Some(failed_at);
223            state.failed_at = Some(failed_at);
224            state.policy_applied = Some(policy.on_health_failure);
225            state.last_event_seq = seq;
226
227            let mut effects = vec![
228                Effect::LocalEmitEvent {
229                    rollout_id: state.rollout_id.clone(),
230                    payload: OutboundAgentEvent::ActivationFailed {
231                        exit_code,
232                        stderr_tail,
233                        failed_at,
234                        seq,
235                    },
236                    durable: true,
237                },
238                Effect::RecordTransition {
239                    host: state.hostname.clone(),
240                    rollout_id: state.rollout_id.clone(),
241                    from,
242                    to: HostState::Failed,
243                    at: failed_at,
244                },
245            ];
246            // RFC-0005 §4.1 + §4.2: rollback is agent-decided from manifest
247            // policy. No CP signal — agent fires the switch immediately.
248            if matches!(policy.on_health_failure, OnHealthFailure::RollbackAndHalt)
249                && let Some(prior) = state.current_closure_at_dispatch.clone()
250            {
251                effects.push(Effect::LocalFireRollbackTo {
252                    rollout_id: state.rollout_id.clone(),
253                    closure: prior,
254                });
255            }
256            Ok((state, effects))
257        }
258        Event::RemoteActivationFailed {
259            exit_code,
260            stderr_tail,
261            failed_at,
262            seq,
263        } => {
264            let from = state.state;
265            state.state = HostState::Failed;
266            state.activation_failed_at = Some(failed_at);
267            state.failed_at = Some(failed_at);
268            state.policy_applied = Some(policy.on_health_failure);
269            state.last_event_seq = seq;
270
271            let effects = vec![
272                Effect::RemoteAppendEventLog {
273                    host: state.hostname.clone(),
274                    rollout_id: state.rollout_id.clone(),
275                    payload: OutboundAgentEvent::ActivationFailed {
276                        exit_code,
277                        stderr_tail,
278                        failed_at,
279                        seq,
280                    },
281                },
282                Effect::RecordTransition {
283                    host: state.hostname.clone(),
284                    rollout_id: state.rollout_id.clone(),
285                    from,
286                    to: HostState::Failed,
287                    at: failed_at,
288                },
289            ];
290            Ok((state, effects))
291        }
292
293        other => Err(illegal(&state, &other)),
294    }
295}