ignition_core/actions/restart.rs
1//! Restart + wait actions (02-05, HLTH-09/11) — built on the ONE wait
2//! engine ([`crate::poll`], 02-04): serde models OUT, no printing.
3//!
4//! Every wait anchors on the UNAUTHENTICATED [`GatewayApi::status_ping`]
5//! (strictly better than polling gateway-info, 02-RESEARCH §Restart:
6//! it separates down-ness from auth failure and answers during the
7//! STARTING window — the webserver never drops the connection).
8//!
9//! ## Probe shape (the 02-04 HRTB pattern, verbatim)
10//!
11//! Every probe returns `PollState<()>` and its poll-owned state is a
12//! `&mut`-borrowing type (the tail's `TailState<'a>` shape): a probe
13//! whose state carries no lifetime does not typecheck under poll's
14//! `for<'a> FnMut(&'a mut S) -> Probe<'a, T>` bound. The terminal
15//! STATE string rides the state itself (an outer [`Cell`] the state
16//! mutably borrows — readable after `poll` consumes the state), like
17//! the tail's `streamed` counter.
18//!
19//! ## The ONE shared floor ([`RESTART_FLOOR`])
20//!
21//! Open Question 4's fast-restart race: a very fast restart could flip
22//! back to RUNNING before the first poll observes STARTING, so
23//! "observe non-RUNNING once, then RUNNING" alone can false-positive.
24//! BOTH restart-aware waits share one mitigation constant — no
25//! duplicated literals:
26//!
27//! - [`restart_and_wait`] sleeps the floor right after the POST: any
28//! RUNNING observed after it is genuine success (the grace window
29//! has passed);
30//! - [`wait_restart`] (the STANDALONE arm — deliberately NOT
31//! [`wait_gateway`] semantics) accepts an all-RUNNING poll sequence
32//! as success only once the floor has elapsed: `ign restart` fired
33//! the POST up to ~5 s before `ign wait restart` starts polling, and
34//! the gateway still reports RUNNING inside that grace window.
35//! Observing non-RUNNING→RUNNING short-circuits the floor (the
36//! restart was WITNESSED — floor not needed).
37//!
38//! The floor is a PARAMETER (tests inject milliseconds; the CLI passes
39//! [`RESTART_FLOOR`]) — the one knob both semantics share.
40//!
41//! NEVER use `restart-tasks/pending` as the progress signal (research:
42//! it is required-restart config, not restart status).
43
44use std::sync::Mutex;
45use std::time::{Duration, Instant};
46
47use serde::Serialize;
48
49use crate::client::GatewayApi;
50use crate::error::CoreError;
51use crate::poll::{self, PollConfig, PollState};
52
53/// The ONE shared floor for both restart-aware waits (must-have
54/// key_link): 5 s of post-POST grace before an all-RUNNING poll
55/// sequence may report success. Injectable as a parameter; this is the
56/// production value the CLI passes.
57pub const RESTART_FLOOR: Duration = Duration::from_secs(5);
58
59/// Default wait interval (research §Wait-loop pattern).
60pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(2);
61
62/// Default restart budget (research: ~40 s observed; 300 s headroom).
63pub const RESTART_TIMEOUT: Duration = Duration::from_secs(300);
64
65/// Default gateway/module readiness budget.
66pub const READINESS_TIMEOUT: Duration = Duration::from_secs(120);
67
68/// The state a healthy gateway reports (observed live; the wait's
69/// terminal condition).
70const RUNNING: &str = "RUNNING";
71
72/// The state a fully-loaded healthy module reports.
73const ACTIVE: &str = "ACTIVE";
74
75/// `ign restart` (no `--wait`) output model.
76#[derive(Debug, Serialize)]
77pub struct RestartResult {
78 /// Always `true` — the POST was accepted.
79 pub restarted: bool,
80}
81
82/// `ign restart --wait` output model.
83#[derive(Debug, Serialize)]
84pub struct RestartWaitResult {
85 /// Always `true` — the POST was accepted.
86 pub restarted: bool,
87 /// The terminal state observed (`RUNNING`).
88 pub state: String,
89 /// Seconds from the POST to the terminal state (floor included).
90 pub elapsed_secs: u64,
91}
92
93/// `ign wait <target>` output model (gateway / restart / module).
94#[derive(Debug, Serialize)]
95pub struct WaitResult {
96 /// What was waited on: `gateway`, `restart`, or `module <id>`.
97 pub target: String,
98 /// The terminal state observed (`RUNNING` / `ACTIVE`).
99 pub state: String,
100 /// Seconds until the terminal state.
101 pub elapsed_secs: u64,
102}
103
104/// `ign restart` without `--wait`: fire the POST and return — the
105/// human-mode advisory line ("READY in ~1 min") belongs to the CLI.
106/// Confirmation guarding belongs to the CALLER (guard before any API
107/// construction).
108pub async fn restart(api: &dyn GatewayApi) -> Result<RestartResult, CoreError> {
109 api.restart().await?;
110 Ok(RestartResult { restarted: true })
111}
112
113/// `ign restart --wait`: POST → sleep the floor (Open Question 4) →
114/// poll `/StatusPing` until RUNNING. Timeout (default 300 s) → the
115/// poll engine's Network-class deadline error, whose message names the
116/// last observed state.
117pub async fn restart_and_wait(
118 api: &dyn GatewayApi,
119 interval: Duration,
120 timeout: Duration,
121 floor: Duration,
122) -> Result<RestartWaitResult, CoreError> {
123 api.restart().await?;
124 let started = Instant::now();
125 // The floor sleeps BEFORE the first poll: post-floor RUNNING is
126 // unambiguous success even if the STARTING window was never
127 // observed (fast-flip race closed by construction).
128 tokio::time::sleep(floor).await;
129 let state = wait_state_running(
130 api,
131 "restart completion (GET /StatusPing)".to_string(),
132 interval,
133 timeout,
134 )
135 .await?;
136 Ok(RestartWaitResult {
137 restarted: true,
138 state,
139 elapsed_secs: started.elapsed().as_secs(),
140 })
141}
142
143/// `ign wait gateway`: poll `/StatusPing` until RUNNING. Works with NO
144/// credential (the dispatch constructs the client header-less for this
145/// command). IMMEDIATE success when already RUNNING is CORRECT here:
146/// `wait gateway` answers "is it up", not "did it restart" — the
147/// restart-aware variant is [`wait_restart`].
148pub async fn wait_gateway(
149 api: &dyn GatewayApi,
150 interval: Duration,
151 timeout: Duration,
152) -> Result<WaitResult, CoreError> {
153 let started = Instant::now();
154 let state = wait_state_running(
155 api,
156 "gateway readiness (GET /StatusPing)".to_string(),
157 interval,
158 timeout,
159 )
160 .await?;
161 Ok(WaitResult {
162 target: "gateway".to_string(),
163 state,
164 elapsed_secs: started.elapsed().as_secs(),
165 })
166}
167
168/// `ign wait restart`: the STANDALONE restart-aware wait (research
169/// line 94 + Open Question 4). The moment any non-RUNNING state is
170/// observed, keep polling until RUNNING → terminal success (the
171/// restart was witnessed; floor not needed). If polls are RUNNING from
172/// the start, success is accepted ONLY after `floor` has elapsed —
173/// `ign restart` fired the POST up to `floor` before this command
174/// started and the gateway still reports RUNNING in that grace window,
175/// so immediate success would be a false positive. Deadline → the poll
176/// engine's Network-class timeout naming the last observed state.
177pub async fn wait_restart(
178 api: &dyn GatewayApi,
179 interval: Duration,
180 timeout: Duration,
181 floor: Duration,
182) -> Result<WaitResult, CoreError> {
183 let started = Instant::now();
184 let cfg = PollConfig {
185 subject: "restart completion (GET /StatusPing)".to_string(),
186 interval,
187 deadline: timeout,
188 ..PollConfig::default()
189 };
190 /// Probe scratch (borrowing state — the HRTB shape): did any poll
191 /// observe a non-RUNNING state, and where the terminal state goes.
192 /// The Mutex lives OUTSIDE poll; the state mutably borrows it, so
193 /// the result survives poll consuming the state (the tail's
194 /// `streamed` pattern). A `Mutex` (not `Cell`) so the probe future
195 /// is Send — the 06-02 TUI spawns whole waits on tokio.
196 struct Witness<'a> {
197 seen_non_running: bool,
198 final_state: &'a mut Mutex<String>,
199 }
200 let mut final_state = Mutex::new(String::new());
201 poll::poll(
202 cfg,
203 Witness {
204 seen_non_running: false,
205 final_state: &mut final_state,
206 },
207 |witness| {
208 Box::pin(async {
209 let ping = api.status_ping().await?;
210 if ping.state == RUNNING {
211 if witness.seen_non_running || started.elapsed() >= floor {
212 witness
213 .final_state
214 .get_mut()
215 .expect("terminal state")
216 .clone_from(&ping.state);
217 Ok(PollState::<()>::Done(()))
218 } else {
219 // All-RUNNING inside the grace floor: accepting
220 // now would false-positive on `ign restart`'s
221 // ~5 s post-POST window.
222 Ok(PollState::<()>::Pending(Some(format!(
223 "{RUNNING} (all-RUNNING inside the {floor:?} restart grace floor)"
224 ))))
225 }
226 } else {
227 witness.seen_non_running = true;
228 Ok(PollState::<()>::Pending(Some(ping.state)))
229 }
230 })
231 },
232 )
233 .await?;
234 Ok(WaitResult {
235 target: "restart".to_string(),
236 state: std::mem::take(&mut *final_state.lock().expect("terminal state")),
237 elapsed_secs: started.elapsed().as_secs(),
238 })
239}
240
241/// `ign wait module <id>`: poll `modules/healthy?search=<id>` until the
242/// item with that id reports `ACTIVE` (research §Modules). Deadline →
243/// the poll engine's Network-class timeout naming the id (the subject)
244/// and the last observed state.
245pub async fn wait_module(
246 api: &dyn GatewayApi,
247 module_id: &str,
248 interval: Duration,
249 timeout: Duration,
250) -> Result<WaitResult, CoreError> {
251 let started = Instant::now();
252 let cfg = PollConfig {
253 subject: format!("module {module_id} ACTIVE (GET /data/api/v1/modules/healthy)"),
254 interval,
255 deadline: timeout,
256 ..PollConfig::default()
257 };
258 let mut final_state = Mutex::new(String::new());
259 poll::poll(cfg, &mut final_state, |final_state| {
260 Box::pin(async {
261 let query = crate::client::query::ListQuery {
262 search: Some(module_id.to_string()),
263 ..Default::default()
264 };
265 let modules = api.modules(false, &query).await?;
266 // search is a substring match over names too — the row
267 // must be THE module (id equality).
268 if let Some(module) = modules.items.iter().find(|m| m.id == module_id) {
269 if module.state.as_deref() == Some(ACTIVE) {
270 final_state
271 .get_mut()
272 .expect("terminal state")
273 .push_str(ACTIVE);
274 Ok(PollState::<()>::Done(()))
275 } else {
276 Ok(PollState::<()>::Pending(Some(format!(
277 "{} state {}",
278 module.id,
279 module.state.as_deref().unwrap_or("-")
280 ))))
281 }
282 } else {
283 Ok(PollState::<()>::Pending(Some(format!(
284 "{module_id} not present in the healthy module list"
285 ))))
286 }
287 })
288 })
289 .await?;
290 Ok(WaitResult {
291 target: format!("module {module_id}"),
292 state: std::mem::take(&mut *final_state.lock().expect("terminal state")),
293 elapsed_secs: started.elapsed().as_secs(),
294 })
295}
296
297/// The shared RUNNING probe: poll `/StatusPing` until `state ==
298/// "RUNNING"` (STARTING/unknown = Pending with the observed state —
299/// unknown states surface verbatim, research Open Question 3). The
300/// terminal state rides the Mutex the state borrows (poll's T is `()`;
301/// Mutex not Cell so the probe future is Send — the 06-02 TUI spawns it).
302async fn wait_state_running(
303 api: &dyn GatewayApi,
304 subject: String,
305 interval: Duration,
306 timeout: Duration,
307) -> Result<String, CoreError> {
308 let cfg = PollConfig {
309 subject,
310 interval,
311 deadline: timeout,
312 ..PollConfig::default()
313 };
314 let mut final_state = Mutex::new(String::new());
315 poll::poll(cfg, &mut final_state, |final_state| {
316 Box::pin(async {
317 let ping = api.status_ping().await?;
318 if ping.state == RUNNING {
319 final_state
320 .get_mut()
321 .expect("terminal state")
322 .clone_from(&ping.state);
323 Ok(PollState::<()>::Done(()))
324 } else {
325 Ok(PollState::<()>::Pending(Some(ping.state)))
326 }
327 })
328 })
329 .await?;
330 Ok(std::mem::take(
331 &mut *final_state.lock().expect("terminal state"),
332 ))
333}
334
335#[cfg(test)]
336mod tests {
337 use std::time::Duration;
338
339 use super::RESTART_FLOOR;
340
341 /// The must-have key_link pin: the floor BOTH restart-aware waits
342 /// share is the literal 5 s — no duplicated literals, ever.
343 #[test]
344 fn restart_floor_is_five_seconds() {
345 assert_eq!(RESTART_FLOOR, Duration::from_secs(5));
346 }
347}