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}