Skip to main content

ados_protocol/
sidecar.rs

1//! On-disk state sidecars: the soft schema-version check, the atomic writers,
2//! and the one freshness-gated reader.
3//!
4//! A state sidecar is a small JSON snapshot a service drops under the run
5//! directory (`/run/ados/*.json`) so the REST and heartbeat surfaces can read
6//! its state without an IPC round-trip. Unlike the frozen wire contracts, a
7//! sidecar is *best-effort*: a reader must tolerate a file written by an older
8//! or newer agent rather than reject or crash on it.
9//!
10//! So the version discipline here is deliberately softer than the wire
11//! contracts: when a sidecar's declared `version` does not match the value this
12//! build expects, [`check_sidecar_version`] logs a warning and returns `false`
13//! but the reader keeps using the data. A stale sidecar written by an older
14//! agent (which had no `version` field at all, so it reads back as `0`) must
15//! never brick a reader — it warns once and proceeds.
16//!
17//! Each sidecar's expected version is registered in
18//! [`crate::contracts`] and mirrored by a per-file constant on the writer side;
19//! a reader passes both to [`check_sidecar_version`] at its deserialize site.
20//!
21//! Freshness: a producer rewrites its sidecar whole on each tick, so the file's
22//! mtime is the age of the reading inside it, and a producer that died leaves
23//! its last file behind until reboot. [`read_fresh`] reads a registered sidecar
24//! and classifies it against the registry's `max_age_ms` window; a future mtime
25//! (a clock that stepped backwards) makes the age unprovable and reads as
26//! [`Reading::Stale`], never as a fresh measurement.
27
28use std::path::Path;
29use std::time::{Duration, SystemTime};
30
31use serde::de::DeserializeOwned;
32use serde::{Deserialize, Serialize};
33
34/// Compare a sidecar's declared schema version against the version this build
35/// expects.
36///
37/// Returns `true` when they match. On a mismatch it logs a warning naming the
38/// sidecar and both versions, then returns `false` — the caller should still
39/// use the parsed data (sidecars are best-effort). This function never panics
40/// and never rejects: it is a soft, self-describing drift signal, not a gate.
41///
42/// `got` is the version read off the file (an older file with no `version`
43/// field deserializes to `0` via the `#[serde(default)]` mixin), and `ours` is
44/// the version constant this build was compiled against for that sidecar.
45pub fn check_sidecar_version(file: &str, got: u16, ours: u16) -> bool {
46    if got != ours {
47        // Fires in every consumer (tracing is a required dependency), so a
48        // schema drift is never silent regardless of which crate reads the
49        // sidecar; still signal the mismatch via the return value.
50        tracing::warn!(
51            sidecar = file,
52            got,
53            ours,
54            "sidecar schema version mismatch; reading best-effort"
55        );
56        return false;
57    }
58    true
59}
60
61/// A minimal mixin for peeking a sidecar's schema version.
62///
63/// Deserializing a sidecar file into `Versioned` extracts only the `version`
64/// field (serde ignores every other field) and defaults it to `0` when absent,
65/// so a reader can learn a file's schema version without deserializing the whole
66/// snapshot and without failing on an older, version-less file.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct Versioned {
69    /// The sidecar schema version. Defaults to `0` for a file written before the
70    /// field existed.
71    #[serde(default)]
72    pub version: u16,
73}
74
75/// Replace `path` with `data` so a power cut leaves either the old file or the
76/// new one, never a truncated mix. The replacement keeps the target's
77/// permissions when the target exists and is created 0644 otherwise. See
78/// [`write_atomic`] for the write sequence.
79pub fn write_durable(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
80    use std::os::unix::fs::PermissionsExt;
81    let mode = std::fs::metadata(path)
82        .map(|m| m.permissions().mode() & 0o7777)
83        .unwrap_or(0o644);
84    write_atomic(path, data, mode)
85}
86
87/// Serialize `value` as JSON and write it through [`write_atomic`].
88pub fn write_json_atomic<T: Serialize + ?Sized>(
89    path: &std::path::Path,
90    value: &T,
91    mode: u32,
92) -> std::io::Result<()> {
93    let body = serde_json::to_vec(value).map_err(std::io::Error::other)?;
94    write_atomic(path, &body, mode)
95}
96
97/// Atomically replace `path` with `data` at file mode `mode`.
98///
99/// Creates the parent directory, then writes a sibling temp file named
100/// `<name>.tmp-<8 random hex>` opened `create_new` with `mode` (so two
101/// concurrent writers, threads or processes, never share a temp file), sets
102/// the exact mode regardless of umask, fsyncs it, renames it over `path`, and
103/// fsyncs the directory so the rename itself survives a power cut. On any
104/// error the temp file is removed.
105pub fn write_atomic(path: &std::path::Path, data: &[u8], mode: u32) -> std::io::Result<()> {
106    use std::io::Write;
107    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
108    let parent = path
109        .parent()
110        .filter(|p| !p.as_os_str().is_empty())
111        .unwrap_or_else(|| std::path::Path::new("."));
112    std::fs::create_dir_all(parent)?;
113    let mut suffix = [0u8; 4];
114    getrandom::fill(&mut suffix).map_err(|e| std::io::Error::other(e.to_string()))?;
115    let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
116    tmp_name.push(format!(".tmp-{}", hex::encode(suffix)));
117    let tmp = parent.join(tmp_name);
118    let mut f = std::fs::OpenOptions::new()
119        .write(true)
120        .create_new(true)
121        .mode(mode)
122        .open(&tmp)?;
123    let result = (|| {
124        f.set_permissions(std::fs::Permissions::from_mode(mode))?;
125        f.write_all(data)?;
126        f.sync_all()?;
127        std::fs::rename(&tmp, path)?;
128        std::fs::File::open(parent)?.sync_all()
129    })();
130    drop(f);
131    if result.is_err() {
132        let _ = std::fs::remove_file(&tmp);
133    }
134    result
135}
136
137/// The outcome of a freshness-gated sidecar read.
138#[derive(Debug, Clone, PartialEq)]
139pub enum Reading<T> {
140    /// Parsed, and written within the sidecar's freshness window.
141    Fresh(T),
142    /// Parsed, but older than the window or carrying a future or unreadable
143    /// mtime: the last thing a producer wrote, not a current measurement.
144    Stale(T),
145    /// No file.
146    Absent,
147    /// The file exists but could not be read or parsed as `T`.
148    Malformed(String),
149}
150
151impl<T> Reading<T> {
152    /// The value when fresh, else `None`.
153    pub fn fresh(self) -> Option<T> {
154        match self {
155            Reading::Fresh(v) => Some(v),
156            _ => None,
157        }
158    }
159
160    /// Whether the reading is fresh.
161    pub fn is_fresh(&self) -> bool {
162        matches!(self, Reading::Fresh(_))
163    }
164}
165
166/// A file's mtime age relative to `now`, or `None` when the file is absent, its
167/// mtime is unreadable, or its mtime is after `now` (an unprovable age).
168pub fn file_age(path: &Path, now: SystemTime) -> Option<Duration> {
169    let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
170    now.duration_since(modified).ok()
171}
172
173/// Read the registered sidecar `id` from its resolved path
174/// ([`crate::paths::run_path`]) and classify it against its registered
175/// freshness window. An unregistered id reads as [`Reading::Malformed`].
176pub fn read_fresh<T: DeserializeOwned>(id: &str, now: SystemTime) -> Reading<T> {
177    let Some(row) = crate::paths::sidecar(id) else {
178        return Reading::Malformed(format!("sidecar {id:?} is not registered"));
179    };
180    read_fresh_at(&crate::paths::run_path(id), row.max_age(), now)
181}
182
183/// The path-injectable core of [`read_fresh`]: read `path` as `T` and classify
184/// it against `max_age` (`None` = no window, every parsed read is fresh). A
185/// future or unreadable mtime is [`Reading::Stale`].
186pub fn read_fresh_at<T: DeserializeOwned>(
187    path: &Path,
188    max_age: Option<Duration>,
189    now: SystemTime,
190) -> Reading<T> {
191    let bytes = match std::fs::read(path) {
192        Ok(b) => b,
193        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Reading::Absent,
194        Err(e) => return Reading::Malformed(e.to_string()),
195    };
196    let value: T = match serde_json::from_slice(&bytes) {
197        Ok(v) => v,
198        Err(e) => return Reading::Malformed(e.to_string()),
199    };
200    let Some(window) = max_age else {
201        return Reading::Fresh(value);
202    };
203    match file_age(path, now) {
204        Some(age) if age <= window => Reading::Fresh(value),
205        _ => Reading::Stale(value),
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn write_aged(path: &Path, body: &str) -> SystemTime {
214        std::fs::write(path, body).unwrap();
215        std::fs::metadata(path).unwrap().modified().unwrap()
216    }
217
218    #[test]
219    fn read_fresh_within_and_past_the_window() {
220        let dir = tempfile::tempdir().unwrap();
221        let p = dir.path().join("s.json");
222        let written = write_aged(&p, r#"{"a":1}"#);
223        let win = Some(Duration::from_secs(10));
224        let at = |s: u64| -> Reading<serde_json::Value> {
225            read_fresh_at(&p, win, written + Duration::from_secs(s))
226        };
227        assert_eq!(at(1), Reading::Fresh(serde_json::json!({"a": 1})));
228        assert_eq!(at(11), Reading::Stale(serde_json::json!({"a": 1})));
229        // No window: every parsed read is fresh.
230        let r: Reading<serde_json::Value> =
231            read_fresh_at(&p, None, written + Duration::from_secs(3600));
232        assert!(r.is_fresh());
233    }
234
235    #[test]
236    fn read_fresh_future_mtime_is_stale() {
237        let dir = tempfile::tempdir().unwrap();
238        let p = dir.path().join("s.json");
239        let written = write_aged(&p, r#"{"a":1}"#);
240        // "now" before the write: the clock stepped backwards.
241        let earlier = written - Duration::from_secs(60);
242        let r: Reading<serde_json::Value> =
243            read_fresh_at(&p, Some(Duration::from_secs(10)), earlier);
244        assert_eq!(r, Reading::Stale(serde_json::json!({"a": 1})));
245        assert!(file_age(&p, earlier).is_none());
246    }
247
248    #[test]
249    fn absent() {
250        let dir = tempfile::tempdir().unwrap();
251        let r: Reading<serde_json::Value> =
252            read_fresh_at(&dir.path().join("none.json"), None, SystemTime::now());
253        assert_eq!(r, Reading::Absent);
254        assert!(file_age(&dir.path().join("none.json"), SystemTime::now()).is_none());
255    }
256
257    #[test]
258    fn malformed() {
259        let dir = tempfile::tempdir().unwrap();
260        let p = dir.path().join("s.json");
261        std::fs::write(&p, b"{not json").unwrap();
262        let r: Reading<serde_json::Value> = read_fresh_at(&p, None, SystemTime::now());
263        assert!(matches!(r, Reading::Malformed(_)));
264        // A well-formed body of the wrong shape is malformed for that type.
265        std::fs::write(&p, b"[1,2]").unwrap();
266        let r: Reading<serde_json::Map<String, serde_json::Value>> =
267            read_fresh_at(&p, None, SystemTime::now());
268        assert!(matches!(r, Reading::Malformed(_)));
269    }
270
271    #[test]
272    fn read_fresh_rejects_an_unregistered_id() {
273        let r: Reading<serde_json::Value> = read_fresh("no-such-sidecar", SystemTime::now());
274        assert!(matches!(r, Reading::Malformed(_)));
275    }
276
277    #[test]
278    fn registry_resolves_sidecar_and_socket_ids() {
279        assert!(crate::paths::run_path("wfb-stats").ends_with(crate::paths::WFB_STATS_JSON));
280        assert!(crate::paths::run_path("hid-cmd").ends_with(crate::paths::HID_CMD_SOCK));
281        assert_eq!(
282            crate::paths::max_age("wfb-stats"),
283            Some(Duration::from_millis(crate::paths::WFB_STATS_MAX_AGE_MS))
284        );
285        // A path outside the run dir resolves as registered.
286        assert_eq!(
287            crate::paths::run_path("ground-station-input"),
288            std::path::PathBuf::from("/etc/ados/ground-station-input.json")
289        );
290    }
291
292    #[test]
293    fn matching_versions_pass() {
294        assert!(check_sidecar_version("example-sidecar", 1, 1));
295        assert!(check_sidecar_version("example-sidecar", 0, 0));
296    }
297
298    #[test]
299    fn mismatched_versions_fail() {
300        // Older file (version-less, reads back as 0) against a v1 build.
301        assert!(!check_sidecar_version("example-sidecar", 0, 1));
302        // Newer file than this build expects.
303        assert!(!check_sidecar_version("example-sidecar", 2, 1));
304    }
305
306    #[test]
307    fn versioned_defaults_to_zero_and_ignores_other_fields() {
308        let empty: Versioned = serde_json::from_str("{}").unwrap();
309        assert_eq!(empty.version, 0);
310
311        let with_extra: Versioned =
312            serde_json::from_str(r#"{"version":3,"link_state":"healthy"}"#).unwrap();
313        assert_eq!(with_extra.version, 3);
314    }
315
316    #[test]
317    fn write_durable_replaces_the_file_and_leaves_no_temp() {
318        let dir = tempfile::tempdir().unwrap();
319        let p = dir.path().join("extlinux.conf");
320        std::fs::write(&p, b"old").unwrap();
321        write_durable(&p, b"new contents").unwrap();
322        assert_eq!(std::fs::read(&p).unwrap(), b"new contents");
323        let names: Vec<_> = std::fs::read_dir(dir.path())
324            .unwrap()
325            .map(|e| e.unwrap().file_name())
326            .collect();
327        assert_eq!(names.len(), 1, "{names:?}");
328    }
329
330    #[test]
331    fn write_atomic_sets_mode_and_creates_parent() {
332        use std::os::unix::fs::PermissionsExt;
333        let dir = tempfile::tempdir().unwrap();
334        let p = dir.path().join("sub").join("key.bin");
335        write_atomic(&p, &[7u8; 8], 0o600).unwrap();
336        let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
337        assert_eq!(mode, 0o600);
338        // write_durable keeps the existing mode across a rewrite.
339        write_durable(&p, b"x").unwrap();
340        let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
341        assert_eq!(mode, 0o600);
342    }
343
344    #[test]
345    fn concurrent_write_atomic_always_parses_and_leaves_no_temp() {
346        let dir = tempfile::tempdir().unwrap();
347        let p = std::sync::Arc::new(dir.path().join("state.json"));
348        let handles: Vec<_> = (0..8u32)
349            .map(|i| {
350                let p = p.clone();
351                std::thread::spawn(move || {
352                    for j in 0..50u32 {
353                        let v =
354                            serde_json::json!({ "writer": i, "seq": j, "pad": "x".repeat(512) });
355                        write_json_atomic(&p, &v, 0o644).unwrap();
356                        let raw = std::fs::read(&*p).unwrap();
357                        let _: serde_json::Value = serde_json::from_slice(&raw).unwrap();
358                    }
359                })
360            })
361            .collect();
362        for h in handles {
363            h.join().unwrap();
364        }
365        let names: Vec<_> = std::fs::read_dir(dir.path())
366            .unwrap()
367            .map(|e| e.unwrap().file_name())
368            .collect();
369        assert_eq!(names.len(), 1, "{names:?}");
370    }
371}