Skip to main content

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}