Skip to main content

ignition_core/client/
redundancy.rs

1//! Redundancy capability model (09-04, EXT-02) — `GET
2//! /data/api/v1/redundancy`, the FLAT 11-field body live-captured on
3//! both rigs (09-LIVE-CAPTURES §3: 8.3.6 rig A + 8.3.3 rig B). The
4//! TrialWire recipe throughout: `rename` + snake_case `alias` +
5//! `default` on every camelCase key, unknown keys never refuse the
6//! parse, flatten remainder.
7//!
8//! **The units are capture-locked (09-LIVE-CAPTURES §3 + Decisions 4):**
9//! - `uptime` = **milliseconds since gateway (JVM) start** — wall-clock
10//!   proven TWICE: rig A 460 959 ms + gateway start 02:29:30Z = exactly
11//!   the 02:37:11Z capture moment; rig B 617 422 ms same proof. A
12//!   minutes-old-restarted rig rules out epoch units AND
13//!   seconds-since-start.
14//! - `lastSyncTimestamp` = `-1` sentinel on BOTH fresh Independent rigs
15//!   ("never synced"). Its unit is therefore NOT capture-proven — no
16//!   sync ever happened. The sibling timestamp (`licenses.effective.
17//!   lastUpdated`) is epoch-ms, so non-negative values are interpreted
18//!   as epoch-ms flagged as INFERENCE, and `-1`/absent normalizes to
19//!   `None` via [`RedundancyStatusWire::last_sync_epoch_ms`].
20//!
21//! `role` is a String, NOT an enum: only `Independent` was captured
22//! (fresh single rig); `Primary`/`Backup` come from `GatewayInfo.
23//! redundancy_role` — an unknown future role must RIDE, never refuse.
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29/// GET path of the redundancy capability (83-api collection + live
30/// capture; the /config, /events, /providers and /gwaction routes are
31/// out of this phase's scope — `api call` covers them).
32pub const REDUNDANCY_PATH: &str = "/data/api/v1/redundancy";
33
34/// GET `/data/api/v1/redundancy` — the redundancy status. Live-captured
35/// 200 on BOTH rigs (09-LIVE-CAPTURES §3); byte-shape identical, values
36/// differ only in `localId` + `uptime`.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct RedundancyStatusWire {
39    /// `"Independent"` (captured on both fresh rigs) / `"Primary"` /
40    /// `"Backup"` (GatewayInfo.redundancy_role vocabulary) — a String
41    /// on purpose: an unknown future role must ride, not refuse.
42    #[serde(default)]
43    pub role: String,
44    /// `"Unknown"` on both captures (a fresh Independent rig has no
45    /// project state to report).
46    #[serde(
47        rename = "projectState",
48        alias = "project_state",
49        default,
50        skip_serializing_if = "Option::is_none"
51    )]
52    pub project_state: Option<String>,
53    /// `"Active"` on both captures.
54    #[serde(
55        rename = "activityLevel",
56        alias = "activity_level",
57        default,
58        skip_serializing_if = "Option::is_none"
59    )]
60    pub activity_level: Option<String>,
61    /// This gateway's id — the container IP on both captures
62    /// (`192.168.215.2` / `192.168.171.2`).
63    #[serde(
64        rename = "localId",
65        alias = "local_id",
66        default,
67        skip_serializing_if = "Option::is_none"
68    )]
69    pub local_id: Option<String>,
70    /// The peer's id — ABSENT on both fresh-rig captures (no peer
71    /// exists); the flat research shape documents it, so it rides
72    /// Option. Peer-connected shapes were NOT capturable on a single
73    /// fresh rig — everything peer-shaped stays Option/lenient.
74    #[serde(
75        rename = "peerId",
76        alias = "peer_id",
77        default,
78        skip_serializing_if = "Option::is_none"
79    )]
80    pub peer_id: Option<String>,
81    /// `false` on both captures (no peer on a fresh rig).
82    #[serde(rename = "peerConnected", alias = "peer_connected", default)]
83    pub peer_connected: bool,
84    /// `true` on both captures (an Independent gateway owns its config).
85    #[serde(rename = "hasConfigAccess", alias = "has_config_access", default)]
86    pub has_config_access: bool,
87    /// `false` on both captures.
88    #[serde(rename = "syncPending", alias = "sync_pending", default)]
89    pub sync_pending: bool,
90    /// `false` on both captures.
91    #[serde(rename = "failoverPending", alias = "failover_pending", default)]
92    pub failover_pending: bool,
93    /// **Milliseconds since gateway (JVM) start** — capture-proven by
94    /// wall-clock cross-check on BOTH rigs (module doc; 09-LIVE-CAPTURES
95    /// §3). Option: present on both captures, defaulted for tolerance.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub uptime: Option<i64>,
98    /// `-1` on both captures = the NEVER-SYNCED sentinel. Unit NOT
99    /// capture-proven (no sync ever happened on a fresh rig); the ms
100    /// interpretation is INFERENCE from the sibling
101    /// `licenses.effective.lastUpdated` epoch-ms. Use
102    /// [`Self::last_sync_epoch_ms`] — raw `-1` rides here, never
103    /// misread as a real timestamp.
104    #[serde(
105        rename = "lastSyncTimestamp",
106        alias = "last_sync_timestamp",
107        default,
108        skip_serializing_if = "Option::is_none"
109    )]
110    pub last_sync_timestamp: Option<i64>,
111    /// Unknown keys round-trip (version tolerance).
112    #[serde(flatten)]
113    pub extra: BTreeMap<String, serde_json::Value>,
114}
115
116impl RedundancyStatusWire {
117    /// `lastSyncTimestamp` normalized: the `-1` sentinel ("never
118    /// synced" — both fresh rigs) and absent keys → `None`; a
119    /// non-negative value is epoch-ms PER THE SIBLING PATTERN — an
120    /// INFERENCE (flagged at the field), never a capture-proven fact.
121    pub fn last_sync_epoch_ms(&self) -> Option<i64> {
122        self.last_sync_timestamp.filter(|value| *value >= 0)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::RedundancyStatusWire;
129
130    /// THE 8.3.6 live capture (rig A, 09-LIVE-CAPTURES §3) — verbatim
131    /// one-line body.
132    #[test]
133    fn redundancy_parses_the_live_8_3_6_capture() {
134        let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
135            "role": "Independent", "projectState": "Unknown",
136            "activityLevel": "Active", "localId": "192.168.215.2",
137            "peerConnected": false, "hasConfigAccess": true,
138            "syncPending": false, "failoverPending": false,
139            "uptime": 460959, "lastSyncTimestamp": -1
140        }))
141        .expect("the live 8.3.6 shape must parse");
142        assert_eq!(wire.role, "Independent");
143        assert_eq!(wire.project_state.as_deref(), Some("Unknown"));
144        assert_eq!(wire.activity_level.as_deref(), Some("Active"));
145        assert_eq!(wire.local_id.as_deref(), Some("192.168.215.2"));
146        assert!(!wire.peer_connected);
147        assert!(wire.has_config_access);
148        assert!(!wire.sync_pending);
149        assert!(!wire.failover_pending);
150        assert_eq!(wire.uptime, Some(460_959), "ms since gateway start");
151        assert_eq!(wire.last_sync_timestamp, Some(-1));
152        assert_eq!(
153            wire.last_sync_epoch_ms(),
154            None,
155            "the -1 sentinel never reads as a timestamp"
156        );
157        assert_eq!(wire.peer_id, None, "peer absent on a fresh rig");
158    }
159
160    /// THE 8.3.3 live capture (rig B, 09-LIVE-CAPTURES §3) — same
161    /// shape, different localId + uptime (no point-release drift).
162    #[test]
163    fn redundancy_parses_the_live_8_3_3_capture() {
164        let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
165            "role": "Independent", "projectState": "Unknown",
166            "activityLevel": "Active", "localId": "192.168.171.2",
167            "peerConnected": false, "hasConfigAccess": true,
168            "syncPending": false, "failoverPending": false,
169            "uptime": 617422, "lastSyncTimestamp": -1
170        }))
171        .expect("the live 8.3.3 shape must parse");
172        assert_eq!(wire.uptime, Some(617_422));
173        assert_eq!(wire.last_sync_epoch_ms(), None);
174    }
175
176    /// A non-negative `lastSyncTimestamp` (never captured — the ms
177    /// unit is the flagged INFERENCE) normalizes through, and unknown
178    /// keys ride `extra` (the round-trip proof).
179    #[test]
180    fn redundancy_normalizes_and_passes_unknown_keys() {
181        let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
182            "role": "Primary", "projectState": "ReadyToGo",
183            "activityLevel": "Active", "localId": "10.0.0.1",
184            "peerId": "10.0.0.2",
185            "peerConnected": true, "hasConfigAccess": true,
186            "syncPending": false, "failoverPending": false,
187            "uptime": 123456, "lastSyncTimestamp": 1788748551614i64,
188            "futurePointReleaseField": [1, 2]
189        }))
190        .expect("peer-shaped + unknown-key variants must parse");
191        assert_eq!(wire.role, "Primary", "a String role rides, never refuses");
192        assert_eq!(wire.peer_id.as_deref(), Some("10.0.0.2"));
193        assert_eq!(
194            wire.last_sync_epoch_ms(),
195            Some(1_788_748_551_614),
196            "non-negative values read epoch-ms (flagged inference)"
197        );
198        assert_eq!(
199            wire.extra.get("futurePointReleaseField"),
200            Some(&serde_json::json!([1, 2])),
201            "unknown keys ride flatten passthrough"
202        );
203    }
204}