1use std::collections::BTreeMap;
35
36use serde::{Deserialize, Serialize};
37
38pub const REDUNDANCY_PATH: &str = "/data/api/v1/redundancy";
42
43pub const ACTIVITY_ACTIVE: &str = "Active";
46
47pub const ACTIVITY_COLD: &str = "Cold";
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct RedundancyStatusWire {
56 #[serde(default)]
61 pub role: String,
62 #[serde(
65 rename = "projectState",
66 alias = "project_state",
67 default,
68 skip_serializing_if = "Option::is_none"
69 )]
70 pub project_state: Option<String>,
71 #[serde(
75 rename = "activityLevel",
76 alias = "activity_level",
77 default,
78 skip_serializing_if = "Option::is_none"
79 )]
80 pub activity_level: Option<String>,
81 #[serde(
84 rename = "localId",
85 alias = "local_id",
86 default,
87 skip_serializing_if = "Option::is_none"
88 )]
89 pub local_id: Option<String>,
90 #[serde(
95 rename = "peerId",
96 alias = "peer_id",
97 default,
98 skip_serializing_if = "Option::is_none"
99 )]
100 pub peer_id: Option<String>,
101 #[serde(rename = "peerConnected", alias = "peer_connected", default)]
103 pub peer_connected: bool,
104 #[serde(rename = "hasConfigAccess", alias = "has_config_access", default)]
106 pub has_config_access: bool,
107 #[serde(rename = "syncPending", alias = "sync_pending", default)]
109 pub sync_pending: bool,
110 #[serde(rename = "failoverPending", alias = "failover_pending", default)]
112 pub failover_pending: bool,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub uptime: Option<i64>,
118 #[serde(
123 rename = "lastSyncTimestamp",
124 alias = "last_sync_timestamp",
125 default,
126 skip_serializing_if = "Option::is_none"
127 )]
128 pub last_sync_timestamp: Option<i64>,
129 #[serde(flatten)]
131 pub extra: BTreeMap<String, serde_json::Value>,
132}
133
134impl RedundancyStatusWire {
135 pub fn last_sync_epoch_ms(&self) -> Option<i64> {
139 self.last_sync_timestamp.filter(|value| *value >= 0)
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ActiveNode {
147 OnlyA,
149 OnlyB,
151 Both,
154 Neither,
156}
157
158pub fn active_node(a: &RedundancyStatusWire, b: &RedundancyStatusWire) -> ActiveNode {
166 let is_active = |w: &RedundancyStatusWire| w.activity_level.as_deref() == Some(ACTIVITY_ACTIVE);
167 match (is_active(a), is_active(b)) {
168 (true, false) => ActiveNode::OnlyA,
169 (false, true) => ActiveNode::OnlyB,
170 (true, true) => ActiveNode::Both,
171 (false, false) => ActiveNode::Neither,
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::{ACTIVITY_COLD, ActiveNode, RedundancyStatusWire, active_node};
178
179 fn wire(value: serde_json::Value) -> RedundancyStatusWire {
180 serde_json::from_value(value).expect("the pair body must parse")
181 }
182
183 fn pre_failover() -> (RedundancyStatusWire, RedundancyStatusWire) {
185 (
186 wire(
187 serde_json::json!({"role":"Master","projectState":"Good","activityLevel":"Active","localId":"192.168.97.3","peerId":"spike-a-Backup","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":29279,"lastSyncTimestamp":1791586091827i64}),
188 ),
189 wire(
190 serde_json::json!({"role":"Backup","projectState":"Good","activityLevel":"Cold","localId":"192.168.97.2","peerId":"spike-a-Master","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":-1,"lastSyncTimestamp":1791586091875i64}),
191 ),
192 )
193 }
194
195 fn post_failover() -> (RedundancyStatusWire, RedundancyStatusWire) {
197 (
198 wire(
199 serde_json::json!({"role":"Master","projectState":"Good","activityLevel":"Cold","localId":"192.168.97.3","peerId":"spike-a-Backup","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":-1,"lastSyncTimestamp":1791586358959i64}),
200 ),
201 wire(
202 serde_json::json!({"role":"Backup","projectState":"Good","activityLevel":"Active","localId":"192.168.97.2","peerId":"spike-a-Master","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":30539,"lastSyncTimestamp":1791586359045i64}),
203 ),
204 )
205 }
206
207 #[test]
210 fn active_node_follows_activity_not_role() {
211 let (a, b) = post_failover();
212 assert_eq!(a.role, "Master");
213 assert_eq!(b.role, "Backup");
214 assert_eq!(a.activity_level.as_deref(), Some(ACTIVITY_COLD));
215 assert_eq!(a.uptime, Some(-1), "a cold node reports uptime -1");
216 assert_eq!(active_node(&a, &b), ActiveNode::OnlyB);
217 }
218
219 #[test]
221 fn active_node_pre_failover_is_master() {
222 let (a, b) = pre_failover();
223 assert_eq!(a.role, "Master");
224 assert_eq!(b.role, "Backup");
225 assert_eq!(active_node(&a, &b), ActiveNode::OnlyA);
226 }
227
228 #[test]
231 fn active_node_split_brain_and_none() {
232 let with = |level: Option<&str>| {
233 let mut v = serde_json::json!({"role":"Independent"});
234 if let Some(level) = level {
235 v["activityLevel"] = serde_json::json!(level);
236 }
237 wire(v)
238 };
239 assert_eq!(
240 active_node(&with(Some("Active")), &with(Some("Active"))),
241 ActiveNode::Both
242 );
243 assert_eq!(
244 active_node(&with(Some("Cold")), &with(Some("Cold"))),
245 ActiveNode::Neither
246 );
247 assert_eq!(
248 active_node(&with(None), &with(Some("Cold"))),
249 ActiveNode::Neither
250 );
251 assert_eq!(
252 active_node(&with(Some("Warm")), &with(Some("Cold"))),
253 ActiveNode::Neither
254 );
255 assert_eq!(
256 active_node(&with(Some("Warm")), &with(Some("Active"))),
257 ActiveNode::OnlyB
258 );
259 }
260
261 #[test]
263 fn last_sync_is_epoch_ms_on_the_live_pair() {
264 let (a, _) = pre_failover();
265 assert_eq!(a.last_sync_epoch_ms(), Some(1_791_586_091_827));
266 }
267
268 #[test]
271 fn redundancy_parses_the_live_8_3_6_capture() {
272 let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
273 "role": "Independent", "projectState": "Unknown",
274 "activityLevel": "Active", "localId": "192.168.215.2",
275 "peerConnected": false, "hasConfigAccess": true,
276 "syncPending": false, "failoverPending": false,
277 "uptime": 460959, "lastSyncTimestamp": -1
278 }))
279 .expect("the live 8.3.6 shape must parse");
280 assert_eq!(wire.role, "Independent");
281 assert_eq!(wire.project_state.as_deref(), Some("Unknown"));
282 assert_eq!(wire.activity_level.as_deref(), Some("Active"));
283 assert_eq!(wire.local_id.as_deref(), Some("192.168.215.2"));
284 assert!(!wire.peer_connected);
285 assert!(wire.has_config_access);
286 assert!(!wire.sync_pending);
287 assert!(!wire.failover_pending);
288 assert_eq!(wire.uptime, Some(460_959), "ms since gateway start");
289 assert_eq!(wire.last_sync_timestamp, Some(-1));
290 assert_eq!(
291 wire.last_sync_epoch_ms(),
292 None,
293 "the -1 sentinel never reads as a timestamp"
294 );
295 assert_eq!(wire.peer_id, None, "peer absent on a fresh rig");
296 }
297
298 #[test]
301 fn redundancy_parses_the_live_8_3_3_capture() {
302 let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
303 "role": "Independent", "projectState": "Unknown",
304 "activityLevel": "Active", "localId": "192.168.171.2",
305 "peerConnected": false, "hasConfigAccess": true,
306 "syncPending": false, "failoverPending": false,
307 "uptime": 617422, "lastSyncTimestamp": -1
308 }))
309 .expect("the live 8.3.3 shape must parse");
310 assert_eq!(wire.uptime, Some(617_422));
311 assert_eq!(wire.last_sync_epoch_ms(), None);
312 }
313
314 #[test]
318 fn redundancy_normalizes_and_passes_unknown_keys() {
319 let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
320 "role": "Master", "projectState": "Good",
321 "activityLevel": "Active", "localId": "192.168.97.3",
322 "peerId": "spike-a-Backup",
323 "peerConnected": true, "hasConfigAccess": true,
324 "syncPending": false, "failoverPending": false,
325 "uptime": 29279, "lastSyncTimestamp": 1791586091827i64,
326 "futurePointReleaseField": [1, 2]
327 }))
328 .expect("peer-shaped + unknown-key variants must parse");
329 assert_eq!(wire.role, "Master", "a String role rides, never refuses");
330 assert_eq!(wire.peer_id.as_deref(), Some("spike-a-Backup"));
331 assert_eq!(
332 wire.last_sync_epoch_ms(),
333 Some(1_791_586_091_827),
334 "non-negative values read epoch-ms (block C-S3)"
335 );
336 assert_eq!(
337 wire.extra.get("futurePointReleaseField"),
338 Some(&serde_json::json!([1, 2])),
339 "unknown keys ride flatten passthrough"
340 );
341 }
342}