1use std::net::{TcpStream, ToSocketAddrs};
28use std::time::Duration;
29
30use serde::Serialize;
31
32use crate::client::GatewayApi;
33use crate::client::webdev::RouteProbe;
34use crate::error::CoreError;
35
36const DIAL_TIMEOUT: Duration = Duration::from_secs(3);
39
40const RUNNING: &str = "RUNNING";
42
43#[derive(Debug, Clone, Serialize)]
47pub struct CheckResult {
48 pub name: String,
51 pub status: CheckStatus,
53 pub detail: String,
55 pub hint: Option<String>,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "lowercase")]
62pub enum CheckStatus {
63 Ok,
65 Warn,
68 Fail,
70 Skip,
72}
73
74#[derive(Debug, Serialize)]
76pub struct DoctorResult {
77 pub checks: Vec<CheckResult>,
80}
81
82#[derive(Debug, Default)]
85pub struct DoctorOptions {
86 pub check_write: bool,
88 pub webdev_route: Option<String>,
90}
91
92pub async fn doctor(
98 api: &dyn GatewayApi,
99 profile_url: &str,
100 credential_present: bool,
101 opts: &DoctorOptions,
102) -> DoctorResult {
103 let mut checks = Vec::with_capacity(8);
104
105 checks.push(check_url(profile_url));
107
108 checks.push(check_liveness(api).await);
111
112 let gateway_info = api.gateway_info().await;
116 checks.push(check_commissioned(&gateway_info));
117 let auth_status = check_auth(&gateway_info, credential_present);
118 checks.push(auth_status.clone());
119
120 checks.push(check_permissions(api, &auth_status).await);
126
127 checks.push(check_write(api, opts).await);
129
130 checks.push(check_webdev(api, opts).await);
132
133 checks.push(check_rig());
135
136 DoctorResult { checks }
137}
138
139fn row(name: &str, status: CheckStatus, detail: String, hint: Option<String>) -> CheckResult {
141 CheckResult {
142 name: name.to_string(),
143 status,
144 detail,
145 hint,
146 }
147}
148
149fn check_url(raw: &str) -> CheckResult {
153 let url = match url::Url::parse(raw) {
154 Ok(url) => url,
155 Err(err) => {
156 return row(
157 "url",
158 CheckStatus::Fail,
159 format!("cannot parse the profile url {raw:?}: {err}"),
160 Some("fix the profile url with `ign profile add`".to_string()),
161 );
162 }
163 };
164 let Some(host) = url.host_str().map(str::to_string) else {
165 return row(
166 "url",
167 CheckStatus::Fail,
168 format!("the profile url {raw:?} carries no host"),
169 Some("fix the profile url with `ign profile add`".to_string()),
170 );
171 };
172 let port = url.port_or_known_default().unwrap_or(80);
173 let addrs = match (host.as_str(), port).to_socket_addrs() {
174 Ok(addrs) => addrs.collect::<Vec<_>>(),
175 Err(err) => {
176 return row(
177 "url",
178 CheckStatus::Fail,
179 format!("DNS resolution of {host} failed: {err}"),
180 Some("check the hostname / VPN / DNS".to_string()),
181 );
182 }
183 };
184 let mut last_err = None;
185 for addr in &addrs {
186 match TcpStream::connect_timeout(addr, DIAL_TIMEOUT) {
187 Ok(_) => {
188 return row(
189 "url",
190 CheckStatus::Ok,
191 format!("TCP connect to {host}:{port} succeeded"),
192 None,
193 );
194 }
195 Err(err) => last_err = Some(err),
196 }
197 }
198 let err = last_err.unwrap_or_else(|| {
199 std::io::Error::new(
200 std::io::ErrorKind::AddrNotAvailable,
201 "no addresses resolved",
202 )
203 });
204 row(
205 "url",
206 CheckStatus::Fail,
207 format!("TCP connect to {host}:{port} failed: {err}"),
208 Some(format!(
209 "check the gateway host/port ({host}:{port}) and any firewall/VPN"
210 )),
211 )
212}
213
214async fn check_liveness(api: &dyn GatewayApi) -> CheckResult {
218 match api.status_ping().await {
219 Ok(ping) if ping.state == RUNNING => row(
220 "liveness",
221 CheckStatus::Ok,
222 format!("gateway {RUNNING} (unauthenticated /StatusPing)"),
223 None,
224 ),
225 Ok(ping) => row(
226 "liveness",
227 CheckStatus::Warn,
228 format!(
229 "gateway {} — restarting or not ready (unauthenticated /StatusPing)",
230 ping.state
231 ),
232 Some("gateway not RUNNING yet; try `ign wait gateway`".to_string()),
233 ),
234 Err(CoreError::GatewayRestarting { .. }) => row(
235 "liveness",
236 CheckStatus::Warn,
237 "webserver up but services restarting (503)".to_string(),
238 Some("try `ign wait restart`".to_string()),
239 ),
240 Err(err) => row(
241 "liveness",
242 CheckStatus::Fail,
243 format!("gateway down — no /StatusPing answer: {err}"),
244 Some("check the gateway process/container and the url row above".to_string()),
245 ),
246 }
247}
248
249fn check_commissioned(
253 gateway_info: &Result<crate::client::version::GatewayInfo, CoreError>,
254) -> CheckResult {
255 match gateway_info {
256 Err(CoreError::GatewayNotCommissioned { .. }) => row(
257 "commissioned",
258 CheckStatus::Fail,
259 "every /data route redirects to /welcome — gateway not commissioned".to_string(),
260 Some("open http://<host>:<port>/welcome in a browser and complete the commissioning wizard".to_string()),
261 ),
262 _ => row(
263 "commissioned",
264 CheckStatus::Ok,
265 "no /welcome redirect on /data routes".to_string(),
266 None,
267 ),
268 }
269}
270
271fn check_auth(
274 gateway_info: &Result<crate::client::version::GatewayInfo, CoreError>,
275 credential_present: bool,
276) -> CheckResult {
277 match gateway_info {
278 Ok(info) => row(
279 "auth",
280 CheckStatus::Ok,
281 format!(
282 "gateway-info read succeeded (HTTP 200, gateway {})",
283 info.ignition_version
284 ),
285 None,
286 ),
287 Err(CoreError::Auth { status: 401, .. }) => {
288 let (detail, hint) = if credential_present {
289 (
290 "token not recognized (HTTP 401 on gateway-info)".to_string(),
291 "the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token".to_string(),
292 )
293 } else {
294 (
295 "no credential resolved for this profile (gateway-info answered 401)".to_string(),
296 "set IGNITION_TOKEN (or the profile's token_env / keyring) to an API token".to_string(),
297 )
298 };
299 row("auth", CheckStatus::Fail, detail, Some(hint))
300 }
301 Err(CoreError::Auth { status: 403, .. }) => row(
302 "auth",
303 CheckStatus::Fail,
304 "token recognized but under-permitted (HTTP 403 on gateway-info)".to_string(),
305 Some("Ignition token setup is three parts: (1) the token holds an adequate security level, (2) the gateway's read/write permissions include that level (default: only Authenticated/Roles/Administrator), (3) 'Require secure connections' is unchecked for http gateways — the permissions row below helps with part 2".to_string()),
306 ),
307 Err(CoreError::GatewayNotCommissioned { .. }) => row(
308 "auth",
309 CheckStatus::Skip,
310 "gateway not commissioned — auth not assessable".to_string(),
311 None,
312 ),
313 Err(CoreError::GatewayRestarting { .. }) => row(
314 "auth",
315 CheckStatus::Skip,
316 "gateway restarting — auth not assessable yet".to_string(),
317 Some("re-run doctor once the gateway is RUNNING".to_string()),
318 ),
319 Err(CoreError::Network { .. }) => row(
320 "auth",
321 CheckStatus::Skip,
322 "gateway unreachable — auth not assessable".to_string(),
323 None,
324 ),
325 Err(err) => row(
326 "auth",
327 CheckStatus::Fail,
328 format!("gateway-info probe failed: {err}"),
329 None,
330 ),
331 }
332}
333
334async fn check_permissions(api: &dyn GatewayApi, auth: &CheckResult) -> CheckResult {
340 let attempt = match auth.status {
341 CheckStatus::Ok => true,
342 CheckStatus::Fail if auth.detail.contains("403") => true,
345 _ => false,
346 };
347 if !attempt {
348 return row(
349 "permissions",
350 CheckStatus::Skip,
351 "auth read failed — the security-properties read needs a working token".to_string(),
352 None,
353 );
354 }
355 match api.security_properties().await {
356 Ok(props) => {
357 let read = props
358 .read_permissions
359 .as_ref()
360 .map(|value| serde_json::to_string(value).unwrap_or_default())
361 .unwrap_or_else(|| "(absent)".to_string());
362 let write = props
363 .write_permissions
364 .as_ref()
365 .map(|value| serde_json::to_string(value).unwrap_or_default())
366 .unwrap_or_else(|| "(absent)".to_string());
367 row(
368 "permissions",
369 CheckStatus::Ok,
370 format!("readPermissions: {read}; writePermissions: {write}"),
371 None,
372 )
373 }
374 Err(CoreError::Auth { status: 403, .. }) => row(
375 "permissions",
376 CheckStatus::Warn,
377 "this token cannot read security-properties either (HTTP 403) — the gateway's read/write permissions likely exclude the token's security level (three-part cause 2)".to_string(),
378 Some("in the gateway UI (Platform→Security→Permissions) add the token's security level to the read/write permission lists, or grant the token a level the permissions already include".to_string()),
379 ),
380 Err(err) => row(
381 "permissions",
382 CheckStatus::Warn,
383 format!("could not read security-properties: {err}"),
384 None,
385 ),
386 }
387}
388
389async fn check_write(api: &dyn GatewayApi, opts: &DoctorOptions) -> CheckResult {
394 if !opts.check_write {
395 return row(
396 "write",
397 CheckStatus::Skip,
398 "not requested (--check-write)".to_string(),
399 None,
400 );
401 }
402 match api.scan_projects().await {
403 Ok(()) => row(
404 "write",
405 CheckStatus::Ok,
406 "scan/projects accepted (2xx) — write permitted".to_string(),
407 None,
408 ),
409 Err(CoreError::Auth { status: 403, .. }) => row(
410 "write",
411 CheckStatus::Warn,
412 "read-only token (HTTP 403 on scan/projects)".to_string(),
413 Some(
414 "grant the token write permission or use a token with an adequate security level"
415 .to_string(),
416 ),
417 ),
418 Err(err) => row(
419 "write",
420 CheckStatus::Fail,
421 format!("scan/projects probe failed: {err}"),
422 None,
423 ),
424 }
425}
426
427async fn check_webdev(api: &dyn GatewayApi, opts: &DoctorOptions) -> CheckResult {
435 let Some(route) = opts.webdev_route.as_deref() else {
436 return row(
437 "webdev",
438 CheckStatus::Skip,
439 "not requested (--webdev-route NAME)".to_string(),
440 None,
441 );
442 };
443 match api
444 .webdev_route_probe(crate::client::webdev::DEFAULT_PROJECT, route, &[])
445 .await
446 {
447 Ok(RouteProbe::Present { route_version }) => row(
448 "webdev",
449 CheckStatus::Ok,
450 format!("route {route:?} present (version {route_version})"),
451 None,
452 ),
453 Ok(RouteProbe::Absent) => row(
454 "webdev",
455 CheckStatus::Warn,
456 format!("route {route:?} absent (HTTP 405 — the 8.3 absent marker)"),
457 Some(
458 "run `ign webdev deploy` to install the CLI's routes (or check the \
459 route name)"
460 .to_string(),
461 ),
462 ),
463 Ok(RouteProbe::Unlicensed) => row(
464 "webdev",
465 CheckStatus::Warn,
466 "WebDev module unlicensed (HTTP 402 — trial-expired rigs cannot \
467 serve /system/webdev routes)"
468 .to_string(),
469 Some(
470 "license the gateway; on a rig, `ign rig trial reset --yes` restarts \
471 an expired trial"
472 .to_string(),
473 ),
474 ),
475 Ok(RouteProbe::AuthGated) => row(
476 "webdev",
477 CheckStatus::Ok,
478 format!("route {route:?} present (auth-gated — HTTP 401/403)"),
479 None,
480 ),
481 Ok(RouteProbe::Denied { code, .. }) => row(
482 "webdev",
483 CheckStatus::Ok,
484 format!("route {route:?} present (denied: {code})"),
485 None,
486 ),
487 Err(err) => row(
488 "webdev",
489 CheckStatus::Fail,
490 format!("route {route:?} probe failed: {err}"),
491 None,
492 ),
493 }
494}
495
496fn check_rig() -> CheckResult {
499 match std::process::Command::new("docker")
500 .arg("--version")
501 .output()
502 {
503 Ok(output) if output.status.success() => {
504 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
505 row("rig", CheckStatus::Ok, version, None)
506 }
507 _ => row(
508 "rig",
509 CheckStatus::Skip,
510 "no Docker / Phase 4 rig detection".to_string(),
511 None,
512 ),
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::{CheckStatus, DoctorOptions};
519 use crate::client::GatewayApi;
520 use crate::client::query::ListEnvelope;
521 use crate::client::restart::SecurityProperties;
522 use crate::client::status::StatusPing;
523 use crate::client::version::GatewayInfo;
524 use crate::error::CoreError;
525
526 struct DoctorRig {
530 ping: fn() -> Result<StatusPing, CoreError>,
531 info: fn() -> Result<GatewayInfo, CoreError>,
532 props: fn() -> Result<SecurityProperties, CoreError>,
533 webdev_probe: fn() -> Result<crate::client::webdev::RouteProbe, CoreError>,
534 }
535
536 fn tags_present() -> Result<crate::client::webdev::RouteProbe, CoreError> {
537 Ok(crate::client::webdev::RouteProbe::Present {
538 route_version: crate::webdev::ROUTE_BUNDLE_VERSION.to_string(),
539 })
540 }
541
542 fn tags_absent() -> Result<crate::client::webdev::RouteProbe, CoreError> {
543 Ok(crate::client::webdev::RouteProbe::Absent)
544 }
545
546 fn webdev_unlicensed() -> Result<crate::client::webdev::RouteProbe, CoreError> {
547 Ok(crate::client::webdev::RouteProbe::Unlicensed)
548 }
549
550 fn running() -> Result<StatusPing, CoreError> {
551 Ok(StatusPing {
552 state: "RUNNING".into(),
553 })
554 }
555
556 fn ok_info() -> Result<GatewayInfo, CoreError> {
557 Ok(serde_json::from_value(serde_json::json!({
558 "name": "GW",
559 "edition": "standard",
560 "ignitionVersion": "8.3.6 (b2026042713)"
561 }))
562 .expect("gateway-info fixture parses"))
563 }
564
565 fn ok_props() -> Result<SecurityProperties, CoreError> {
566 Ok(serde_json::from_value(serde_json::json!({
567 "readPermissions": {"anyOf": ["Authenticated/Roles/Administrator"]},
568 "writePermissions": {"anyOf": ["Authenticated/Roles/Administrator"]}
569 }))
570 .expect("security-properties fixture parses"))
571 }
572
573 fn info_403() -> Result<GatewayInfo, CoreError> {
574 Err(CoreError::Auth {
575 status: 403,
576 endpoint: None,
577 })
578 }
579
580 fn info_401() -> Result<GatewayInfo, CoreError> {
581 Err(CoreError::Auth {
582 status: 401,
583 endpoint: None,
584 })
585 }
586
587 fn props_403() -> Result<SecurityProperties, CoreError> {
588 Err(CoreError::Auth {
589 status: 403,
590 endpoint: None,
591 })
592 }
593
594 fn props_401() -> Result<SecurityProperties, CoreError> {
595 Err(CoreError::Auth {
596 status: 401,
597 endpoint: None,
598 })
599 }
600
601 #[async_trait::async_trait]
602 impl GatewayApi for DoctorRig {
603 async fn bundle_generate(
604 &self,
605 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
606 unreachable!("not part of this action")
607 }
608 async fn bundle_status(
609 &self,
610 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
611 unreachable!("not part of this action")
612 }
613 async fn bundle_download(
614 &self,
615 _out: &std::path::Path,
616 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
617 unreachable!("not part of this action")
618 }
619 async fn tag_provider_list(
620 &self,
621 _query: &crate::client::query::ListQuery,
622 ) -> Result<
623 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
624 CoreError,
625 > {
626 unreachable!("not part of this action")
627 }
628 async fn tag_provider_find(
629 &self,
630 _name: &str,
631 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
632 unreachable!("not part of this action")
633 }
634 async fn tag_provider_create(
635 &self,
636 _body: &[crate::client::tags::TagProviderCreate],
637 ) -> Result<(), CoreError> {
638 unreachable!("not part of this action")
639 }
640 async fn tag_provider_delete(
641 &self,
642 _name: &str,
643 _signature: &str,
644 ) -> Result<(), CoreError> {
645 unreachable!("not part of this action")
646 }
647 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
648 unreachable!("not part of this action")
649 }
650 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
651 unreachable!("not part of this action")
652 }
653 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
654 unreachable!("not part of this action")
655 }
656 async fn backup_download(
657 &self,
658 _out: &std::path::Path,
659 _backup_type: crate::client::backup::BackupType,
660 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
661 unreachable!("not part of this action")
662 }
663 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
664 unreachable!("not part of this action")
665 }
666 async fn eam_task_history(
667 &self,
668 _limit: Option<u32>,
669 _search: Option<&str>,
670 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
671 {
672 unreachable!("not part of this action")
673 }
674 async fn eam_task_definitions(
675 &self,
676 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
677 {
678 unreachable!("not part of this action")
679 }
680 async fn eam_task_find(
681 &self,
682 _name: &str,
683 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
684 unreachable!("not part of this action")
685 }
686 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
687 unreachable!("not part of this action")
688 }
689 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
690 unreachable!("not part of this action")
691 }
692 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
693 unreachable!("not part of this action")
694 }
695 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
696 unreachable!("not part of this action")
697 }
698 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
699 unreachable!("not part of this action")
700 }
701 async fn eam_tasks_scheduled(
702 &self,
703 _running: bool,
704 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
705 unreachable!("not part of this action")
706 }
707 async fn eam_task_modify(
708 &self,
709 _definition: &serde_json::Value,
710 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
711 unreachable!("not part of this action")
712 }
713 async fn eam_task_delete(
714 &self,
715 _name: &str,
716 _signature: &str,
717 _confirm: bool,
718 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
719 unreachable!("not part of this action")
720 }
721 async fn api_call(
722 &self,
723 _call: &crate::client::apicall::ApiCallRequest,
724 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
725 unreachable!("not part of this action")
726 }
727 async fn license_status(
728 &self,
729 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
730 unreachable!("not part of this action")
731 }
732 async fn redundancy_status(
733 &self,
734 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
735 unreachable!("not part of this action")
736 }
737 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
738 unreachable!("not part of this action")
739 }
740 async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
741 (self.info)()
742 }
743 async fn status_ping(&self) -> Result<StatusPing, CoreError> {
744 (self.ping)()
745 }
746 async fn security_properties(&self) -> Result<SecurityProperties, CoreError> {
747 (self.props)()
748 }
749 async fn scan_projects(&self) -> Result<(), CoreError> {
750 Err(CoreError::Auth {
751 status: 403,
752 endpoint: None,
753 })
754 }
755 async fn restart(&self) -> Result<(), CoreError> {
756 unreachable!("not part of this action")
757 }
758 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
759 unreachable!("not part of this action")
760 }
761 async fn modules(
762 &self,
763 _quarantined: bool,
764 _query: &crate::client::query::ListQuery,
765 ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
766 unreachable!("not part of this action")
767 }
768 async fn metrics_current(
769 &self,
770 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
771 unreachable!("not part of this action")
772 }
773 async fn metrics_historic(
774 &self,
775 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
776 unreachable!("not part of this action")
777 }
778 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
779 unreachable!("not part of this action")
780 }
781 async fn designers(
782 &self,
783 _query: &crate::client::query::ListQuery,
784 ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
785 unreachable!("not part of this action")
786 }
787 async fn perspective_sessions(
788 &self,
789 _query: &crate::client::query::ListQuery,
790 ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
791 unreachable!("not part of this action")
792 }
793 async fn vision_clients(
794 &self,
795 _query: &crate::client::query::ListQuery,
796 ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
797 unreachable!("not part of this action")
798 }
799 async fn terminate_perspective_session(
800 &self,
801 _id: &str,
802 _message: Option<&str>,
803 ) -> Result<(), CoreError> {
804 unreachable!("not part of this action")
805 }
806 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
807 unreachable!("not part of this action")
808 }
809 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
810 unreachable!("not part of this action")
811 }
812 async fn database_connections(
813 &self,
814 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
815 {
816 unreachable!("not part of this action")
817 }
818 async fn opc_connections(
819 &self,
820 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
821 {
822 unreachable!("not part of this action")
823 }
824 async fn logs(
825 &self,
826 _filter: &crate::client::logs::LogQuery,
827 ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
828 unreachable!("not part of this action")
829 }
830 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
831 unreachable!("not part of this action")
832 }
833 async fn loggers(
834 &self,
835 _query: &crate::client::query::ListQuery,
836 ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
837 unreachable!("not part of this action")
838 }
839 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
840 unreachable!("not part of this action")
841 }
842 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
843 unreachable!("not part of this action")
844 }
845 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
846 unreachable!("not part of this action")
847 }
848 async fn webdev_route_call(
849 &self,
850 _project: &str,
851 _route: &str,
852 _body: &serde_json::Value,
853 _extra_headers: &[(&str, &str)],
854 ) -> Result<serde_json::Value, CoreError> {
855 unreachable!("not part of this action")
856 }
857 async fn webdev_route_probe(
858 &self,
859 _project: &str,
860 _route: &str,
861 _extra_headers: &[(&str, &str)],
862 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
863 (self.webdev_probe)()
864 }
865 async fn projects(
866 &self,
867 _query: &crate::client::query::ListQuery,
868 ) -> Result<
869 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
870 CoreError,
871 > {
872 unreachable!("not part of this action")
873 }
874 async fn project_find(
875 &self,
876 _name: &str,
877 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
878 unreachable!("not part of this action")
879 }
880 async fn project_create(
881 &self,
882 _body: &crate::client::projects::ProjectCreate,
883 ) -> Result<(), CoreError> {
884 unreachable!("not part of this action")
885 }
886 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
887 unreachable!("not part of this action")
888 }
889 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
890 unreachable!("not part of this action")
891 }
892 async fn project_modify(
893 &self,
894 _name: &str,
895 _body: &crate::client::projects::ProjectModify,
896 ) -> Result<(), CoreError> {
897 unreachable!("not part of this action")
898 }
899 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
900 unreachable!("not part of this action")
901 }
902 async fn project_export_to_file(
903 &self,
904 _name: &str,
905 _out: &std::path::Path,
906 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
907 unreachable!("not part of this action")
908 }
909 async fn project_import(
910 &self,
911 _name: &str,
912 _zip: Vec<u8>,
913 _overwrite: bool,
914 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
915 unreachable!("not part of this action")
916 }
917 }
918
919 fn healthy_rig() -> DoctorRig {
920 DoctorRig {
921 ping: running,
922 info: ok_info,
923 props: ok_props,
924 webdev_probe: tags_present,
925 }
926 }
927
928 #[tokio::test]
931 async fn checks_run_in_the_documented_order() {
932 let result = super::doctor(
933 &healthy_rig(),
934 "http://127.0.0.1:1",
935 true,
936 &DoctorOptions::default(),
937 )
938 .await;
939 let names: Vec<&str> = result.checks.iter().map(|c| c.name.as_str()).collect();
940 assert_eq!(
941 names,
942 vec![
943 "url",
944 "liveness",
945 "commissioned",
946 "auth",
947 "permissions",
948 "write",
949 "webdev",
950 "rig"
951 ],
952 );
953 let by_name = |name: &str| {
956 result
957 .checks
958 .iter()
959 .find(|c| c.name == name)
960 .unwrap_or_else(|| panic!("{name} row present"))
961 };
962 assert_eq!(by_name("liveness").status, CheckStatus::Ok);
963 assert_eq!(by_name("commissioned").status, CheckStatus::Ok);
964 assert_eq!(by_name("auth").status, CheckStatus::Ok);
965 assert_eq!(by_name("permissions").status, CheckStatus::Ok);
966 assert_eq!(by_name("write").status, CheckStatus::Skip);
967 assert_eq!(by_name("webdev").status, CheckStatus::Skip);
968 }
969
970 #[tokio::test]
973 async fn healthy_permissions_row_surfaces_the_wiring() {
974 let result = super::doctor(
975 &healthy_rig(),
976 "http://127.0.0.1:1",
977 true,
978 &DoctorOptions::default(),
979 )
980 .await;
981 let perms = result
982 .checks
983 .iter()
984 .find(|c| c.name == "permissions")
985 .unwrap();
986 assert!(
987 perms.detail.contains("readPermissions"),
988 "detail: {}",
989 perms.detail
990 );
991 assert!(
992 perms.detail.contains("Authenticated/Roles/Administrator"),
993 "the wiring value surfaces verbatim: {}",
994 perms.detail
995 );
996 }
997
998 #[tokio::test]
1002 async fn the_403_case_carries_the_three_part_hint_and_permissions_detail() {
1003 let rig = DoctorRig {
1004 ping: running,
1005 info: info_403,
1006 props: props_403,
1007 webdev_probe: tags_present,
1008 };
1009 let result =
1010 super::doctor(&rig, "http://127.0.0.1:1", true, &DoctorOptions::default()).await;
1011 let auth = result.checks.iter().find(|c| c.name == "auth").unwrap();
1012 assert_eq!(auth.status, CheckStatus::Fail);
1013 let hint = auth.hint.as_deref().unwrap();
1014 assert!(hint.contains("three parts"), "hint: {hint}");
1015 assert!(hint.contains("permissions"), "hint: {hint}");
1016 let perms = result
1017 .checks
1018 .iter()
1019 .find(|c| c.name == "permissions")
1020 .unwrap();
1021 assert_eq!(perms.status, CheckStatus::Warn);
1022 assert!(
1023 perms.detail.contains("cannot read security-properties"),
1024 "detail: {}",
1025 perms.detail
1026 );
1027 }
1028
1029 #[tokio::test]
1033 async fn the_no_credential_401_is_its_own_diagnosis() {
1034 let rig = DoctorRig {
1035 ping: running,
1036 info: info_401,
1037 props: props_401,
1038 webdev_probe: tags_present,
1039 };
1040 let result =
1041 super::doctor(&rig, "http://127.0.0.1:1", false, &DoctorOptions::default()).await;
1042 let auth = result.checks.iter().find(|c| c.name == "auth").unwrap();
1043 assert_eq!(auth.status, CheckStatus::Fail);
1044 assert!(
1045 auth.detail.contains("no credential resolved"),
1046 "detail: {}",
1047 auth.detail
1048 );
1049 assert!(
1050 auth.hint.as_deref().unwrap().contains("IGNITION_TOKEN"),
1051 "hint names the fix"
1052 );
1053 let perms = result
1054 .checks
1055 .iter()
1056 .find(|c| c.name == "permissions")
1057 .unwrap();
1058 assert_eq!(perms.status, CheckStatus::Skip);
1059 }
1060
1061 #[tokio::test]
1063 async fn the_token_401_names_the_name_key_format() {
1064 let rig = DoctorRig {
1065 ping: running,
1066 info: info_401,
1067 props: props_401,
1068 webdev_probe: tags_present,
1069 };
1070 let result =
1071 super::doctor(&rig, "http://127.0.0.1:1", true, &DoctorOptions::default()).await;
1072 let auth = result.checks.iter().find(|c| c.name == "auth").unwrap();
1073 assert!(
1074 auth.hint.as_deref().unwrap().contains("name:key"),
1075 "hint: {:?}",
1076 auth.hint
1077 );
1078 }
1079
1080 #[tokio::test]
1083 async fn url_check_dials_and_reports_a_dead_port() {
1084 let result = super::doctor(
1085 &healthy_rig(),
1086 "http://127.0.0.1:1",
1087 true,
1088 &DoctorOptions::default(),
1089 )
1090 .await;
1091 let url = result.checks.first().unwrap();
1092 assert_eq!(url.status, CheckStatus::Fail);
1093 assert!(url.detail.contains("TCP connect"), "detail: {}", url.detail);
1094 }
1095
1096 #[tokio::test]
1099 async fn write_probe_warns_read_only_on_403() {
1100 let result = super::doctor(
1101 &healthy_rig(),
1102 "http://127.0.0.1:1",
1103 true,
1104 &DoctorOptions {
1105 check_write: true,
1106 webdev_route: None,
1107 },
1108 )
1109 .await;
1110 let write = result.checks.iter().find(|c| c.name == "write").unwrap();
1111 assert_eq!(write.status, CheckStatus::Warn);
1112 assert!(
1113 write.detail.contains("read-only token"),
1114 "detail: {}",
1115 write.detail
1116 );
1117 }
1118
1119 #[tokio::test]
1123 async fn webdev_405_means_absent_with_a_deploy_hint() {
1124 let rig = DoctorRig {
1125 webdev_probe: tags_absent,
1126 ..healthy_rig()
1127 };
1128 let result = super::doctor(
1129 &rig,
1130 "http://127.0.0.1:1",
1131 true,
1132 &DoctorOptions {
1133 check_write: false,
1134 webdev_route: Some("tags".into()),
1135 },
1136 )
1137 .await;
1138 let webdev = result.checks.iter().find(|c| c.name == "webdev").unwrap();
1139 assert_eq!(webdev.status, CheckStatus::Warn);
1140 assert!(
1141 webdev.detail.contains("405"),
1142 "the 405 marker surfaces: {}",
1143 webdev.detail
1144 );
1145 assert!(
1146 webdev
1147 .hint
1148 .as_deref()
1149 .unwrap()
1150 .contains("ign webdev deploy"),
1151 "hint names the fix"
1152 );
1153 }
1154
1155 #[tokio::test]
1158 async fn webdev_present_ok_and_402_unlicensed() {
1159 let result = super::doctor(
1160 &healthy_rig(),
1161 "http://127.0.0.1:1",
1162 true,
1163 &DoctorOptions {
1164 check_write: false,
1165 webdev_route: Some("tags".into()),
1166 },
1167 )
1168 .await;
1169 let webdev = result.checks.iter().find(|c| c.name == "webdev").unwrap();
1170 assert_eq!(webdev.status, CheckStatus::Ok);
1171 assert!(
1172 webdev.detail.contains("present (version"),
1173 "detail carries the handshake version: {}",
1174 webdev.detail
1175 );
1176
1177 let rig = DoctorRig {
1178 webdev_probe: webdev_unlicensed,
1179 ..healthy_rig()
1180 };
1181 let result = super::doctor(
1182 &rig,
1183 "http://127.0.0.1:1",
1184 true,
1185 &DoctorOptions {
1186 check_write: false,
1187 webdev_route: Some("tags".into()),
1188 },
1189 )
1190 .await;
1191 let webdev = result.checks.iter().find(|c| c.name == "webdev").unwrap();
1192 assert_eq!(webdev.status, CheckStatus::Warn);
1193 assert!(
1194 webdev.detail.contains("unlicensed"),
1195 "detail: {}",
1196 webdev.detail
1197 );
1198 }
1199
1200 #[test]
1203 fn check_result_serializes_with_exactly_four_keys() {
1204 let body = serde_json::to_value(super::CheckResult {
1205 name: "auth".into(),
1206 status: CheckStatus::Fail,
1207 detail: "detail".into(),
1208 hint: None,
1209 })
1210 .expect("serialize");
1211 assert_eq!(
1212 body,
1213 serde_json::json!({
1214 "name": "auth",
1215 "status": "fail",
1216 "detail": "detail",
1217 "hint": null
1218 })
1219 );
1220 }
1221}