ignition_core/client/restart.rs
1//! Restart + diagnostics-probe capabilities (02-05, HLTH-09/10/11).
2//!
3//! - [`RESTART_PATH`]: the one big red button — POST with
4//! `confirm=true`, `--yes`-guarded at the CLI seam (research Pitfall
5//! 10: it takes the whole gateway down). The gateway answers 200
6//! with the literal body `true` almost immediately; the ~40 s wait
7//! is POLLER-side (the 02-04 engine owns it).
8//! - [`SCAN_PROJECTS_PATH`]: igw-cli's harmless project-rescan write
9//! probe — `ign doctor --check-write` fires it (2xx = write
10//! permission, 403 = read-only token).
11//! - [`SECURITY_PROPERTIES_PATH`] + [`WEBDEV_ROOT`]: doctor inputs —
12//! the security config singleton (the 403 three-part diagnosis's
13//! part 2: what the gateway's read/write permissions actually are)
14//! and the WebDev route-presence probe root.
15//!
16//! Deliberately ABSENT: `restart-tasks/pending`. Research is explicit
17//! it is *required-restart* config (changes needing a restart), NOT
18//! active-restart status — never use it as a restart-progress signal.
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24/// POST path of the restart capability (query param `confirm=true`).
25pub(crate) const RESTART_PATH: &str = "/data/api/v1/restart-tasks/restart";
26
27/// POST path of the project-scan write probe (doctor `--check-write`).
28pub(crate) const SCAN_PROJECTS_PATH: &str = "/data/api/v1/scan/projects";
29
30/// GET path of the security-properties config singleton — the doctor's
31/// permissions deep-dive (02-RESEARCH §Doctor inputs 5b; the resource
32/// singleton read, same family 02-03 verified for the connection
33/// lists). LIVE-CORRECTED 2026-09-18 (ADOPT-RESEARCH §2): the
34/// previously used non-singleton spelling
35/// `/resources/ignition/security-properties` is the PUT target and
36/// 404s as a GET on live 8.3.6 — the read is the SINGLETON route with
37/// `?defaultIfUndefined=true`.
38pub(crate) const SECURITY_PROPERTIES_PATH: &str =
39 "/data/api/v1/resources/singleton/ignition/security-properties";
40
41/// Root of the WebDev route surface — doctor probes
42/// `/system/webdev/<route>` for presence (404 = absent).
43pub(crate) const WEBDEV_ROOT: &str = "/system/webdev/";
44
45/// GET `/data/api/v1/resources/ignition/security-properties` — the
46/// gateway security config singleton. `readPermissions` /
47/// `writePermissions` are raw passthrough: their populated value shape
48/// was NOT live-captured (the research rig read them as config trees),
49/// so doctor surfaces them verbatim rather than typing a guess.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct SecurityProperties {
52 /// The read-permission wiring (passthrough; e.g. an AnyOf level
53 /// tree), when reported.
54 #[serde(
55 rename = "readPermissions",
56 default,
57 skip_serializing_if = "Option::is_none"
58 )]
59 pub read_permissions: Option<serde_json::Value>,
60 /// The write-permission wiring (passthrough), when reported.
61 #[serde(
62 rename = "writePermissions",
63 default,
64 skip_serializing_if = "Option::is_none"
65 )]
66 pub write_permissions: Option<serde_json::Value>,
67 /// Unknown keys round-trip (passthrough-shaped `--json`).
68 #[serde(flatten)]
69 pub extra: BTreeMap<String, serde_json::Value>,
70}
71
72/// The full probe path for one WebDev route: `/system/webdev/<route>`
73/// (ignition-mcp's verified URL shape; 02-RESEARCH §Sources).
74pub(crate) fn webdev_route_path(route: &str) -> String {
75 format!("{WEBDEV_ROOT}{route}")
76}
77
78#[cfg(test)]
79mod tests {
80 use super::{SecurityProperties, webdev_route_path};
81
82 /// The doctor probe paths, contract-pinned (the plan's key_links:
83 /// `security-properties` + `scan/projects` — every wire path in
84 /// this codebase carries a literal pin).
85 #[test]
86 fn doctor_probe_paths_pinned() {
87 assert_eq!(
88 super::SECURITY_PROPERTIES_PATH,
89 "/data/api/v1/resources/singleton/ignition/security-properties"
90 );
91 assert_eq!(super::WEBDEV_ROOT, "/system/webdev/");
92 assert_eq!(super::SCAN_PROJECTS_PATH, "/data/api/v1/scan/projects");
93 assert_eq!(super::RESTART_PATH, "/data/api/v1/restart-tasks/restart");
94 assert_eq!(webdev_route_path("stacked"), "/system/webdev/stacked");
95 }
96
97 /// THE live-capture regression (ADOPT-RESEARCH §2, 8.3.6 rig):
98 /// the singleton record nests the permissions under `config` —
99 /// the client impl deserializes from there.
100 #[test]
101 fn security_properties_parses_the_live_singleton_capture() {
102 let record: serde_json::Value = serde_json::json!({
103 "type": "ignition/security-properties",
104 "signature": "dee8c946",
105 "config": {
106 "forceIdpAuth": true,
107 "readPermissions": {
108 "type": "AnyOf",
109 "securityLevels": [ { "name": "Authenticated", "children": [] } ]
110 },
111 "writePermissions": {
112 "type": "AnyOf",
113 "securityLevels": [ { "name": "Authenticated", "children": [] } ]
114 }
115 }
116 });
117 let config = record.get("config").cloned().unwrap_or(record);
118 let props: SecurityProperties =
119 serde_json::from_value(config).expect("the live singleton config must parse");
120 assert!(props.read_permissions.is_some());
121 assert!(props.write_permissions.is_some());
122 assert!(props.extra.contains_key("forceIdpAuth"));
123 }
124
125 /// The singleton parses with both permission blocks surfaced under
126 /// their gateway-native names, unknown keys passthrough.
127 #[test]
128 fn security_properties_parses_and_passes_through() {
129 let props: SecurityProperties = serde_json::from_value(serde_json::json!({
130 "readPermissions": {"anyOf": ["Authenticated/Roles/Administrator"]},
131 "writePermissions": {"anyOf": ["Authenticated/Roles/Administrator"]},
132 "secureChannelRequired": true
133 }))
134 .expect("singleton shape must parse");
135 assert!(props.read_permissions.is_some());
136 assert!(props.write_permissions.is_some());
137 assert!(
138 props.extra.contains_key("secureChannelRequired"),
139 "unknown keys round-trip"
140 );
141
142 // Round-trip keeps the gateway-native key names.
143 let round = serde_json::to_value(&props).expect("serialize");
144 assert!(round.get("readPermissions").is_some());
145 assert!(round.get("writePermissions").is_some());
146 }
147
148 /// A sparse singleton (no permission blocks) parses — they are
149 /// Option on purpose.
150 #[test]
151 fn security_properties_tolerates_sparse_bodies() {
152 let props: SecurityProperties =
153 serde_json::from_value(serde_json::json!({"name": "whk"})).expect("sparse body parses");
154 assert_eq!(props.read_permissions, None);
155 assert_eq!(props.write_permissions, None);
156 }
157}