Skip to main content

ados_protocol/
cloud_link.rs

1//! The cloud-link sidecar: whether this node's cloud relay is actually talking
2//! to the cloud.
3//!
4//! `ados-cloud` rewrites it on every heartbeat tick with the MQTT broker
5//! session state (the transport's ConnAck-driven flag, never "the task is
6//! alive") and the outcome of the last `/agent/status` POST. `ados-control`
7//! serves it at `GET /api/cloud/link` for the dashboard. A file not rewritten
8//! within [`CLOUD_LINK_STALE_MS`] reads as absent, so a dead relay process never
9//! leaves a frozen "connected" behind.
10
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15/// The cloud-link sidecar path (tmpfs, cleared on boot).
16pub const CLOUD_LINK_SIDECAR: &str = "/run/ados/cloud-link.json";
17
18/// The sidecar schema version, stamped on write and checked (warn-only) on read.
19pub const CLOUD_LINK_SIDECAR_VERSION: u16 = 1;
20
21/// Four heartbeat ticks (5 s each): past this the writer is gone.
22pub const CLOUD_LINK_STALE_MS: i64 = 20_000;
23
24/// The relay's live link state.
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26pub struct CloudLink {
27    /// The sidecar schema version (absent means `0`, an older writer).
28    #[serde(default)]
29    pub version: u16,
30    /// The writer's clock at write time (epoch ms).
31    pub generated_at_ms: Option<i64>,
32    /// The node holds a pairing key.
33    #[serde(default)]
34    pub paired: bool,
35    /// A cloud URL is configured. Without one the relay is local-only.
36    #[serde(default)]
37    pub cloud_url_set: bool,
38    /// The broker session is confirmed up. `None` when no session is running
39    /// (unpaired, local-only, or no broker in this posture).
40    pub broker_connected: Option<bool>,
41    /// When the last `/agent/status` POST was answered with a 2xx (epoch ms).
42    pub last_heartbeat_ok_ms: Option<i64>,
43    /// The HTTP status of the last `/agent/status` answer, 2xx or not.
44    pub last_heartbeat_status: Option<u16>,
45    /// The transport error of the last POST that got no answer at all.
46    pub last_heartbeat_error: Option<String>,
47}
48
49/// Read the sidecar at `path`, or `None` when absent, unparseable, undated or
50/// stale at `now_ms`.
51pub fn read_cloud_link_from(path: &Path, now_ms: i64) -> Option<CloudLink> {
52    let text = std::fs::read_to_string(path).ok()?;
53    let link: CloudLink = serde_json::from_str(&text).ok()?;
54    let generated = link.generated_at_ms?;
55    // A future stamp is a clock fault, not a fresh file.
56    if generated > now_ms || now_ms - generated > CLOUD_LINK_STALE_MS {
57        return None;
58    }
59    crate::sidecar::check_sidecar_version("cloud-link", link.version, CLOUD_LINK_SIDECAR_VERSION);
60    Some(link)
61}
62
63/// Read the sidecar from its default path.
64pub fn read_cloud_link(now_ms: i64) -> Option<CloudLink> {
65    read_cloud_link_from(Path::new(CLOUD_LINK_SIDECAR), now_ms)
66}
67
68/// Atomically write `link` to `path` (tmp + rename).
69pub fn write_cloud_link_to(path: &Path, link: &CloudLink) -> std::io::Result<()> {
70    if let Some(parent) = path.parent() {
71        std::fs::create_dir_all(parent)?;
72    }
73    let body = serde_json::to_vec(link).map_err(std::io::Error::other)?;
74    let tmp = path.with_extension("json.tmp");
75    std::fs::write(&tmp, body)?;
76    std::fs::rename(&tmp, path)
77}
78
79/// Atomically write `link` to the default path.
80pub fn write_cloud_link(link: &CloudLink) -> std::io::Result<()> {
81    write_cloud_link_to(Path::new(CLOUD_LINK_SIDECAR), link)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn a_fresh_link_reads_back_and_a_stale_or_future_one_does_not() {
90        let dir = tempfile::tempdir().unwrap();
91        let path = dir.path().join("cloud-link.json");
92        let now = 1_700_000_000_000i64;
93        let link = CloudLink {
94            version: CLOUD_LINK_SIDECAR_VERSION,
95            generated_at_ms: Some(now),
96            paired: true,
97            cloud_url_set: true,
98            broker_connected: Some(true),
99            last_heartbeat_ok_ms: Some(now - 2_000),
100            last_heartbeat_status: Some(200),
101            last_heartbeat_error: None,
102        };
103        write_cloud_link_to(&path, &link).unwrap();
104        assert_eq!(read_cloud_link_from(&path, now + 1_000), Some(link));
105        assert!(read_cloud_link_from(&path, now + CLOUD_LINK_STALE_MS + 1).is_none());
106        assert!(read_cloud_link_from(&path, now - 1).is_none());
107    }
108}