1use std::path::{Path, PathBuf};
39use std::sync::Mutex;
40use std::time::{Duration, SystemTime, UNIX_EPOCH};
41
42use serde::Serialize;
43
44use crate::client::GatewayApi;
45use crate::client::diagnostics::{self};
46use crate::error::CoreError;
47use crate::poll::{self, PollConfig, PollState};
48
49pub use crate::client::diagnostics::{
52 BUNDLE_CAPTURED_STATES, BUNDLE_DOWNLOAD_TIMEOUT, BUNDLE_GENERATING_STATES,
53 BUNDLE_UNAVAILABLE_STATES, BundleStatusWire, is_bundle_unavailable, is_generating,
54};
55
56#[derive(Debug, Serialize)]
63pub struct BundleDownloadResult {
64 pub file: String,
68 pub bytes: u64,
70 pub content_type: Option<String>,
73}
74
75pub async fn bundle_generate(api: &dyn GatewayApi) -> Result<BundleStatusWire, CoreError> {
78 api.bundle_generate().await
79}
80
81pub async fn bundle_status(api: &dyn GatewayApi) -> Result<BundleStatusWire, CoreError> {
83 api.bundle_status().await
84}
85
86pub async fn bundle_wait(
95 api: &dyn GatewayApi,
96 interval: Duration,
97 timeout: Duration,
98) -> Result<BundleStatusWire, CoreError> {
99 let cfg = PollConfig {
100 subject: "diagnostics bundle generation".to_string(),
101 interval,
102 deadline: timeout,
103 ..PollConfig::default()
104 };
105 let mut final_wire = Mutex::new(None);
109 poll::poll(cfg, &mut final_wire, |final_wire| {
110 Box::pin(async {
111 let wire = api.bundle_status().await?;
112 let state = wire.state.as_str();
113 if diagnostics::is_generating(state) {
114 Ok(PollState::<()>::Pending(Some(format!(
115 "state {state:?} — still generating"
116 ))))
117 } else if diagnostics::is_bundle_unavailable(state) {
118 Err(CoreError::BundleNotAvailable {
123 state: state.to_string(),
124 })
125 } else if diagnostics::BUNDLE_CAPTURED_STATES.contains(&state) {
126 *final_wire.get_mut().expect("terminal wire") = Some(wire);
127 Ok(PollState::<()>::Done(()))
128 } else {
129 Ok(PollState::<()>::Pending(Some(format!(
130 "unknown state {state:?} — still waiting"
131 ))))
132 }
133 })
134 })
135 .await?;
136 Ok(
137 std::mem::take(&mut *final_wire.lock().expect("terminal wire"))
138 .expect("poll returns only on Done — the wire has landed"),
139 )
140}
141
142fn sanitize_basename(raw: &str) -> Option<String> {
146 let trimmed = raw.trim();
147 if trimmed.is_empty() {
148 return None;
149 }
150 Some(trimmed.replace(['/', '\\'], "_"))
151}
152
153fn default_bundle_name(now_secs: u64) -> String {
157 format!("ignition-diagnostics-bundle-{now_secs}.zip")
158}
159
160pub async fn bundle_download(
165 api: &dyn GatewayApi,
166 out: Option<&Path>,
167) -> Result<BundleDownloadResult, CoreError> {
168 let now_secs = SystemTime::now()
169 .duration_since(UNIX_EPOCH)
170 .map(|since| since.as_secs())
171 .unwrap_or_default();
172 if let Some(out) = out {
173 let meta = api.bundle_download(out).await?;
174 return Ok(BundleDownloadResult {
175 file: out.display().to_string(),
176 bytes: meta.bytes,
177 content_type: meta.content_type,
178 });
179 }
180 let fallback = default_bundle_name(now_secs);
181 let part = PathBuf::from(format!("{fallback}.part"));
182 let meta = match api.bundle_download(&part).await {
183 Ok(meta) => meta,
184 Err(err) => {
185 let _ = std::fs::remove_file(&part); return Err(err);
187 }
188 };
189 let final_name = meta
190 .filename
191 .as_deref()
192 .and_then(sanitize_basename)
193 .unwrap_or(fallback);
194 if let Err(err) = std::fs::rename(&part, &final_name) {
195 let _ = std::fs::remove_file(&part); return Err(CoreError::Internal(format!(
197 "cannot finalize bundle {final_name}: {err}"
198 )));
199 }
200 Ok(BundleDownloadResult {
201 file: final_name,
202 bytes: meta.bytes,
203 content_type: meta.content_type,
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use std::collections::VecDeque;
210 use std::sync::Mutex;
211 use std::time::Duration;
212
213 use super::bundle_wait;
214 use crate::client::GatewayApi;
215 use crate::client::diagnostics::BundleStatusWire;
216 use crate::error::CoreError;
217
218 struct WaitRig {
222 states: Mutex<VecDeque<&'static str>>,
223 fallback: &'static str,
224 calls: Mutex<usize>,
225 }
226
227 impl WaitRig {
228 fn with(states: &[&'static str], fallback: &'static str) -> Self {
229 Self {
230 states: Mutex::new(states.iter().copied().collect()),
231 fallback,
232 calls: Mutex::new(0),
233 }
234 }
235
236 fn calls(&self) -> usize {
237 *self.calls.lock().expect("calls lock")
238 }
239 }
240
241 #[async_trait::async_trait]
242 impl GatewayApi for WaitRig {
243 async fn bundle_generate(
244 &self,
245 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
246 unreachable!("not part of this action")
247 }
248 async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError> {
249 let mut queue = self.states.lock().expect("states lock");
250 *self.calls.lock().expect("calls lock") += 1;
251 let state = queue.pop_front().unwrap_or(self.fallback);
252 Ok(BundleStatusWire {
253 state: state.to_string(),
254 file_size: (state == "Valid").then_some(61053),
255 extra: Default::default(),
256 })
257 }
258 async fn bundle_download(
259 &self,
260 _out: &std::path::Path,
261 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
262 unreachable!("not part of this action")
263 }
264 async fn tag_provider_list(
265 &self,
266 _query: &crate::client::query::ListQuery,
267 ) -> Result<
268 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
269 CoreError,
270 > {
271 unreachable!("not part of this action")
272 }
273 async fn tag_provider_find(
274 &self,
275 _name: &str,
276 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
277 unreachable!("not part of this action")
278 }
279 async fn tag_provider_create(
280 &self,
281 _body: &[crate::client::tags::TagProviderCreate],
282 ) -> Result<(), CoreError> {
283 unreachable!("not part of this action")
284 }
285 async fn tag_provider_delete(
286 &self,
287 _name: &str,
288 _signature: &str,
289 ) -> Result<(), CoreError> {
290 unreachable!("not part of this action")
291 }
292 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
293 unreachable!("not part of this action")
294 }
295 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
296 unreachable!("not part of this action")
297 }
298 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
299 unreachable!("not part of this action")
300 }
301 async fn backup_download(
302 &self,
303 _out: &std::path::Path,
304 _backup_type: crate::client::backup::BackupType,
305 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
306 unreachable!("not part of this action")
307 }
308 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
309 unreachable!("not part of this action")
310 }
311 async fn eam_task_history(
312 &self,
313 _limit: Option<u32>,
314 _search: Option<&str>,
315 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
316 {
317 unreachable!("not part of this action")
318 }
319 async fn eam_task_definitions(
320 &self,
321 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
322 {
323 unreachable!("not part of this action")
324 }
325 async fn eam_task_find(
326 &self,
327 _name: &str,
328 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
329 unreachable!("not part of this action")
330 }
331 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
332 unreachable!("not part of this action")
333 }
334 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
335 unreachable!("not part of this action")
336 }
337 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
338 unreachable!("not part of this action")
339 }
340 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
341 unreachable!("not part of this action")
342 }
343 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
344 unreachable!("not part of this action")
345 }
346 async fn eam_tasks_scheduled(
347 &self,
348 _running: bool,
349 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
350 unreachable!("not part of this action")
351 }
352 async fn eam_task_modify(
353 &self,
354 _definition: &serde_json::Value,
355 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
356 unreachable!("not part of this action")
357 }
358 async fn eam_task_delete(
359 &self,
360 _name: &str,
361 _signature: &str,
362 _confirm: bool,
363 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
364 unreachable!("not part of this action")
365 }
366 async fn api_call(
367 &self,
368 _call: &crate::client::apicall::ApiCallRequest,
369 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
370 unreachable!("not part of this action")
371 }
372 async fn license_status(
373 &self,
374 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
375 unreachable!("not part of this action")
376 }
377 async fn redundancy_status(
378 &self,
379 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
380 unreachable!("not part of this action")
381 }
382 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
383 unreachable!("not part of this action")
384 }
385 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
386 unreachable!("not part of this action")
387 }
388 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
389 unreachable!("not part of this action")
390 }
391 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
392 unreachable!("not part of this action")
393 }
394 async fn modules(
395 &self,
396 _quarantined: bool,
397 _query: &crate::client::query::ListQuery,
398 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
399 {
400 unreachable!("not part of this action")
401 }
402 async fn metrics_current(
403 &self,
404 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
405 unreachable!("not part of this action")
406 }
407 async fn metrics_historic(
408 &self,
409 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
410 unreachable!("not part of this action")
411 }
412 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
413 unreachable!("not part of this action")
414 }
415 async fn designers(
416 &self,
417 _query: &crate::client::query::ListQuery,
418 ) -> Result<
419 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
420 CoreError,
421 > {
422 unreachable!("not part of this action")
423 }
424 async fn perspective_sessions(
425 &self,
426 _query: &crate::client::query::ListQuery,
427 ) -> Result<
428 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
429 CoreError,
430 > {
431 unreachable!("not part of this action")
432 }
433 async fn vision_clients(
434 &self,
435 _query: &crate::client::query::ListQuery,
436 ) -> Result<
437 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
438 CoreError,
439 > {
440 unreachable!("not part of this action")
441 }
442 async fn terminate_perspective_session(
443 &self,
444 _id: &str,
445 _message: Option<&str>,
446 ) -> Result<(), CoreError> {
447 unreachable!("not part of this action")
448 }
449 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
450 unreachable!("not part of this action")
451 }
452 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
453 unreachable!("not part of this action")
454 }
455 async fn database_connections(
456 &self,
457 ) -> Result<
458 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
459 CoreError,
460 > {
461 unreachable!("not part of this action")
462 }
463 async fn opc_connections(
464 &self,
465 ) -> Result<
466 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
467 CoreError,
468 > {
469 unreachable!("not part of this action")
470 }
471
472 async fn logs(
473 &self,
474 _filter: &crate::client::logs::LogQuery,
475 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
476 {
477 unreachable!("not part of this double's actions")
478 }
479 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
480 unreachable!("not part of this double's actions")
481 }
482 async fn loggers(
483 &self,
484 _query: &crate::client::query::ListQuery,
485 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
486 {
487 unreachable!("not part of this double's actions")
488 }
489 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
490 unreachable!("not part of this double's actions")
491 }
492 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
493 unreachable!("not part of this double's actions")
494 }
495 async fn restart(&self) -> Result<(), CoreError> {
496 unreachable!("not part of this action")
497 }
498 async fn scan_projects(&self) -> Result<(), CoreError> {
499 unreachable!("not part of this action")
500 }
501 async fn security_properties(
502 &self,
503 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
504 unreachable!("not part of this action")
505 }
506 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
507 unreachable!("not part of this action")
508 }
509 async fn webdev_route_call(
510 &self,
511 _project: &str,
512 _route: &str,
513 _body: &serde_json::Value,
514 _extra_headers: &[(&str, &str)],
515 ) -> Result<serde_json::Value, CoreError> {
516 unreachable!("not part of this action")
517 }
518 async fn webdev_route_probe(
519 &self,
520 _project: &str,
521 _route: &str,
522 _extra_headers: &[(&str, &str)],
523 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
524 unreachable!("not part of this action")
525 }
526 async fn projects(
527 &self,
528 _query: &crate::client::query::ListQuery,
529 ) -> Result<
530 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
531 CoreError,
532 > {
533 unreachable!("not part of this action")
534 }
535 async fn project_find(
536 &self,
537 _name: &str,
538 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
539 unreachable!("not part of this action")
540 }
541 async fn project_create(
542 &self,
543 _body: &crate::client::projects::ProjectCreate,
544 ) -> Result<(), CoreError> {
545 unreachable!("not part of this action")
546 }
547 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
548 unreachable!("not part of this action")
549 }
550 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
551 unreachable!("not part of this action")
552 }
553 async fn project_modify(
554 &self,
555 _name: &str,
556 _body: &crate::client::projects::ProjectModify,
557 ) -> Result<(), CoreError> {
558 unreachable!("not part of this action")
559 }
560 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
561 unreachable!("not part of this action")
562 }
563 async fn project_export_to_file(
564 &self,
565 _name: &str,
566 _out: &std::path::Path,
567 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
568 unreachable!("not part of this action")
569 }
570 async fn project_import(
571 &self,
572 _name: &str,
573 _zip: Vec<u8>,
574 _overwrite: bool,
575 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
576 unreachable!("not part of this action")
577 }
578 }
579
580 #[tokio::test]
583 async fn wait_polls_until_a_captured_terminal_state() {
584 let rig = WaitRig::with(&["Generating", "Generating", "Valid"], "Generating");
585 let out = bundle_wait(&rig, Duration::from_millis(1), Duration::from_secs(10))
586 .await
587 .expect("terminal state reached");
588 assert_eq!(out.state, "Valid");
589 assert_eq!(out.file_size, Some(61_053));
590 assert_eq!(rig.calls(), 3, "exactly three probes");
591 }
592
593 #[tokio::test]
596 async fn wait_returns_on_the_first_probe_when_already_terminal() {
597 let rig = WaitRig::with(&["Valid"], "Valid");
598 let out = bundle_wait(&rig, Duration::from_millis(1), Duration::from_secs(10))
599 .await
600 .expect("immediate terminal");
601 assert_eq!(out.state, "Valid");
602 assert_eq!(rig.calls(), 1);
603 }
604
605 #[tokio::test]
611 async fn unknown_states_keep_polling_until_the_deadline() {
612 let rig = WaitRig::with(&["Generating", "Mystery"], "Mystery");
613 let err = bundle_wait(&rig, Duration::from_millis(1), Duration::from_millis(40))
614 .await
615 .expect_err("deadline must expire");
616 assert!(
617 matches!(&err, CoreError::Network { source: None, .. }),
618 "deadline = Network with no transport source: {err}"
619 );
620 assert_eq!(err.exit_code(), 4);
621 assert_eq!(err.code(), "network_error");
622 let message = err.to_string();
623 assert!(
624 message.contains("diagnostics bundle generation"),
625 "subject named: {message}"
626 );
627 assert!(
628 message.contains("unknown state") && message.contains("Mystery"),
629 "the final status rides the deadline observation: {message}"
630 );
631 assert!(
632 !message.contains("unreachable"),
633 "the gateway ANSWERED — the deadline message never claims unreachability (09-07): {message}"
634 );
635 assert!(
636 rig.calls() > 2,
637 "unknown states keep polling: {}",
638 rig.calls()
639 );
640 }
641
642 #[tokio::test]
649 async fn invalid_state_exits_immediately_bundle_not_available() {
650 let rig = WaitRig::with(&["Generating", "Invalid"], "Invalid");
651 let err = bundle_wait(&rig, Duration::from_millis(1), Duration::from_secs(10))
652 .await
653 .expect_err("Invalid is terminal-unavailable");
654 assert_eq!(err.exit_code(), 6, "target state");
655 assert_eq!(err.code(), "bundle_not_available");
656 assert_eq!(
657 rig.calls(),
658 2,
659 "EXACTLY two probes — immediate exit, no further polls, no deadline wait"
660 );
661 let message = err.to_string();
662 assert!(
663 message.contains("Invalid"),
664 "the observed state is named: {message}"
665 );
666 assert!(
667 message.contains("generate"),
668 "the fresh-generate fix is named: {message}"
669 );
670 let hint = err.hint().expect("hint required");
671 assert!(
672 hint.contains("ign diagnostics bundle generate"),
673 "the hint names the generate command verbatim: {hint}"
674 );
675 }
676
677 #[test]
680 fn default_bundle_name_shape() {
681 assert_eq!(
682 super::default_bundle_name(1_788_748_551),
683 "ignition-diagnostics-bundle-1788748551.zip"
684 );
685 }
686
687 #[test]
690 fn basename_sanitizer_strips_separators() {
691 assert_eq!(
692 super::sanitize_basename("diag.zip").as_deref(),
693 Some("diag.zip")
694 );
695 assert_eq!(
696 super::sanitize_basename("../../etc/passwd").as_deref(),
697 Some(".._.._etc_passwd")
698 );
699 assert_eq!(super::sanitize_basename(" "), None, "blank names nothing");
700 }
701}