Skip to main content

gregg_protocol/
v2.rs

1//! Schema-version-2 wire types.
2//!
3//! Version 2 extends the version-1 snapshot with explicit capability flags
4//! for load average, swap, and memory commit. This allows the protocol to
5//! truthfully represent Linux, macOS, and Windows metric differences without
6//! fabricating unsupported values.
7//!
8//! V2 snapshots are served from the daemon on a separate endpoint
9//! (`/v2/status`). V1 endpoints remain unchanged. Clients prefer v2 but
10//! fall back to v1 when the daemon does not support v2 (404 response).
11
12use serde::{Deserialize, Serialize};
13
14pub use crate::{LoadAverage, MemoryMetrics, SystemIdentity};
15
16/// Schema major version 2.
17pub const SCHEMA_VERSION_V2: u16 = 2;
18
19/// Maximum number of drive records in a v2 status payload.
20pub const MAX_DRIVE_ENTRIES: usize = 32;
21
22/// Maximum UTF-8 byte length of a drive display name.
23pub const MAX_DRIVE_NAME_BYTES: usize = 512;
24
25/// Capacity metrics for one operator-visible mounted filesystem.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub struct DriveMetrics {
29    /// Owned display name supplied by the platform collector.
30    pub name: String,
31    /// Bytes currently used.
32    pub used_bytes: u64,
33    /// Total capacity in bytes.
34    pub total_bytes: u64,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub available_bytes: Option<u64>,
37}
38
39/// Flat v2 status response with optional drive capacity data.
40///
41/// The base snapshot is flattened so the JSON shape remains compatible with
42/// existing v2 clients. Keeping drives in this wrapper also preserves source
43/// compatibility for downstream Rust code that constructs `StatusSnapshotV2`
44/// literals. Missing or null `drives` means unavailable/legacy; an empty list
45/// means enumeration succeeded and found no eligible filesystems.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub struct StatusPayloadV2 {
49    #[serde(flatten)]
50    pub snapshot: StatusSnapshotV2,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub drives: Option<Vec<DriveMetrics>>,
53}
54
55impl StatusPayloadV2 {
56    /// Validate the base snapshot and every optional drive record.
57    pub fn validate(&self) -> Result<(), Vec<crate::ValidationViolationV2>> {
58        crate::validate_v2::validate_payload_v2(self)
59    }
60}
61
62/// Top-level daemon snapshot for schema version 2.
63///
64/// V2 extends v1 by making `load`, `swap`, and `commit` optional with
65/// explicit capability flags. Platforms that do not support a metric report
66/// `false` for the capability and `None` for the value.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub struct StatusSnapshotV2 {
70    /// Schema major version. Must equal [`SCHEMA_VERSION_V2`].
71    pub schema_version: u16,
72    /// Unix epoch in milliseconds at which the snapshot was produced.
73    pub observed_at_unix_ms: u64,
74    /// Sampling cadence in milliseconds used to derive percentage metrics.
75    pub sample_interval_ms: u64,
76    /// Per-metric capability flags for v2.
77    pub capabilities: MetricCapabilitiesV2,
78    /// Stable identity fields.
79    pub system: SystemIdentity,
80    /// CPU utilization.
81    pub cpu: CpuMetricsV2,
82    /// Load averages. `None` when `capabilities.load_average` is `false`.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub load: Option<LoadAverage>,
85    /// Physical memory utilization.
86    pub memory: MemoryMetrics,
87    /// Swap utilization. `None` when `capabilities.swap` is `false`.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub swap: Option<SwapMetrics>,
90    /// Windows commit charge (or similar commit accounting).
91    /// `None` when `capabilities.memory_commit` is `false`.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub commit: Option<CommitMetrics>,
94}
95
96impl StatusSnapshotV2 {
97    /// Validate that every field satisfies the version-2 protocol invariants.
98    ///
99    /// Returns `Ok(())` or a list of structured violations. Each violation
100    /// carries a field path and a [`crate::ViolationKindV2`].
101    pub fn validate(&self) -> Result<(), Vec<crate::ValidationViolationV2>> {
102        crate::validate_v2::validate_v2(self)
103    }
104}
105
106/// Per-metric capability flags for schema version 2.
107///
108/// A `false` flag means the metric is **unsupported on this platform**.
109/// Servers must report `None` for the corresponding value rather than
110/// fabricating a zero or placeholder.
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113#[serde(default)]
114#[allow(clippy::struct_excessive_bools)]
115pub struct MetricCapabilitiesV2 {
116    /// Whether aggregate CPU I/O wait is reported.
117    #[serde(default)]
118    pub cpu_iowait: bool,
119    /// Whether one-/five-/fifteen-minute load averages are reported.
120    #[serde(default)]
121    pub load_average: bool,
122    /// Whether swap utilization is reported.
123    #[serde(default)]
124    pub swap: bool,
125    /// Whether memory commit charge is reported.
126    #[serde(default)]
127    pub memory_commit: bool,
128}
129
130/// CPU utilization snapshot for schema version 2.
131///
132/// Identical to v1 `CpuMetrics` but placed in the v2 module for
133/// independent evolution.
134#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub struct CpuMetricsV2 {
137    /// Number of logical CPU cores available to the kernel.
138    pub logical_cores: u32,
139    /// Total CPU busy percentage, `0.0..=100.0`.
140    pub usage_pct: f32,
141    /// Aggregate CPU I/O-wait percentage, `0.0..=100.0`. `None` when
142    /// [`MetricCapabilitiesV2::cpu_iowait`] is `false`.
143    pub iowait_pct: Option<f32>,
144}
145
146/// Swap utilization for schema version 2.
147///
148/// When `total_bytes` is zero, `usage_pct` is `0.0` rather than `NaN`.
149#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub struct SwapMetrics {
152    /// Used swap in bytes. Never exceeds `total_bytes`.
153    pub used_bytes: u64,
154    /// Total swap in bytes.
155    pub total_bytes: u64,
156    /// Swap utilization percentage, `0.0..=100.0`.
157    pub usage_pct: f32,
158}
159
160/// Commit charge metrics (Windows memory commit accounting).
161///
162/// Represents the system's commit charge: the total bytes committed by all
163/// processes, the commit limit, and the derived percentage. This is
164/// conceptually distinct from swap and must not be serialized as swap.
165#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub struct CommitMetrics {
168    /// Committed bytes in use.
169    pub used_bytes: u64,
170    /// Maximum commit limit in bytes.
171    pub limit_bytes: u64,
172    /// Commit utilization percentage, `0.0..=100.0`.
173    pub usage_pct: f32,
174}
175
176/// Health and readiness response for schema version 2.
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub struct HealthResponseV2 {
180    /// Daemon schema version, always [`SCHEMA_VERSION_V2`].
181    pub schema_version: u16,
182    /// Current readiness state.
183    pub state: crate::ReadinessState,
184    /// Coarse category for non-ready responses. `None` when `state == Ready`.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub category: Option<crate::HealthCategory>,
187    /// Short human-readable message. Never includes filesystem paths or
188    /// internal error chains.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub message: Option<String>,
191    /// Cached v2 snapshot, present only when `state == Ready`.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub snapshot: Option<StatusSnapshotV2>,
194}
195
196impl HealthResponseV2 {
197    /// A `Ready` response wrapping the supplied v2 snapshot.
198    ///
199    /// Callers must validate `snapshot` before constructing a ready response.
200    #[must_use]
201    pub fn ready(snapshot: StatusSnapshotV2) -> Self {
202        Self {
203            schema_version: SCHEMA_VERSION_V2,
204            state: crate::ReadinessState::Ready,
205            category: None,
206            message: None,
207            snapshot: Some(snapshot),
208        }
209    }
210
211    /// A `Warming` response with a default message.
212    #[must_use]
213    pub fn warming() -> Self {
214        Self::warming_with_message("collector warming up")
215    }
216
217    /// A `Warming` response with a custom message.
218    #[must_use]
219    pub fn warming_with_message(message: impl Into<String>) -> Self {
220        Self {
221            schema_version: SCHEMA_VERSION_V2,
222            state: crate::ReadinessState::Warming,
223            category: Some(crate::HealthCategory::Warming),
224            message: Some(message.into()),
225            snapshot: None,
226        }
227    }
228
229    /// A `Failed` response with the given category and message.
230    #[must_use]
231    pub fn failed(category: crate::HealthCategory, message: impl Into<String>) -> Self {
232        Self {
233            schema_version: SCHEMA_VERSION_V2,
234            state: crate::ReadinessState::Failed,
235            category: Some(category),
236            message: Some(message.into()),
237            snapshot: None,
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::{HealthCategory, ReadinessState};
246
247    fn v2_identity() -> SystemIdentity {
248        SystemIdentity {
249            name: "test".into(),
250            hostname: "test.local".into(),
251            os_name: "linux".into(),
252            os_version: "1.0".into(),
253            kernel_name: "Linux".into(),
254            kernel_release: "6.0.0".into(),
255            architecture: "x86_64".into(),
256        }
257    }
258
259    #[test]
260    fn v2_linux_snapshot_round_trips() {
261        let snap = StatusSnapshotV2 {
262            schema_version: SCHEMA_VERSION_V2,
263            observed_at_unix_ms: 1_716_460_800_000,
264            sample_interval_ms: 1000,
265            capabilities: MetricCapabilitiesV2 {
266                cpu_iowait: true,
267                load_average: true,
268                swap: true,
269                memory_commit: false,
270            },
271            system: v2_identity(),
272            cpu: CpuMetricsV2 {
273                logical_cores: 8,
274                usage_pct: 25.2,
275                iowait_pct: Some(0.4),
276            },
277            load: Some(LoadAverage {
278                one: 1.32,
279                five: 0.91,
280                fifteen: 0.62,
281            }),
282            memory: crate::MemoryMetrics {
283                used_bytes: 5_900_000_000,
284                total_bytes: 15_600_000_000,
285                usage_pct: 37.8,
286            },
287            swap: Some(SwapMetrics {
288                used_bytes: 0,
289                total_bytes: 4_000_000_000,
290                usage_pct: 0.0,
291            }),
292            commit: None,
293        };
294        let json = serde_json::to_string(&snap).unwrap();
295        let parsed: StatusSnapshotV2 = serde_json::from_str(&json).unwrap();
296        assert_eq!(snap, parsed);
297    }
298
299    #[test]
300    fn v2_windows_snapshot_no_load_no_swap() {
301        let snap = StatusSnapshotV2 {
302            schema_version: SCHEMA_VERSION_V2,
303            observed_at_unix_ms: 1_716_460_800_000,
304            sample_interval_ms: 1000,
305            capabilities: MetricCapabilitiesV2 {
306                cpu_iowait: false,
307                load_average: false,
308                swap: false,
309                memory_commit: true,
310            },
311            system: v2_identity(),
312            cpu: CpuMetricsV2 {
313                logical_cores: 4,
314                usage_pct: 12.5,
315                iowait_pct: None,
316            },
317            load: None,
318            memory: crate::MemoryMetrics {
319                used_bytes: 2_000_000_000,
320                total_bytes: 8_000_000_000,
321                usage_pct: 25.0,
322            },
323            swap: None,
324            commit: Some(CommitMetrics {
325                used_bytes: 3_000_000_000,
326                limit_bytes: 8_000_000_000,
327                usage_pct: 37.5,
328            }),
329        };
330        let json = serde_json::to_string(&snap).unwrap();
331        assert!(json.contains("\"load_average\":false"));
332        assert!(json.contains("\"swap\":false"));
333        assert!(json.contains("\"memory_commit\":true"));
334        assert!(!json.contains("\"load\":"));
335        assert!(!json.contains("\"swap_used_bytes\""));
336        assert!(json.contains("\"commit\""));
337        assert!(json.contains("\"used_bytes\""));
338
339        let parsed: StatusSnapshotV2 = serde_json::from_str(&json).unwrap();
340        assert_eq!(snap, parsed);
341    }
342
343    #[test]
344    fn v2_health_ready_round_trips() {
345        let snap = StatusSnapshotV2 {
346            schema_version: SCHEMA_VERSION_V2,
347            observed_at_unix_ms: 1,
348            sample_interval_ms: 1000,
349            capabilities: MetricCapabilitiesV2 {
350                cpu_iowait: false,
351                load_average: true,
352                swap: false,
353                memory_commit: true,
354            },
355            system: v2_identity(),
356            cpu: CpuMetricsV2 {
357                logical_cores: 4,
358                usage_pct: 10.0,
359                iowait_pct: None,
360            },
361            load: Some(LoadAverage {
362                one: 1.0,
363                five: 0.5,
364                fifteen: 0.3,
365            }),
366            memory: crate::MemoryMetrics {
367                used_bytes: 1_000_000_000,
368                total_bytes: 4_000_000_000,
369                usage_pct: 25.0,
370            },
371            swap: None,
372            commit: Some(CommitMetrics {
373                used_bytes: 2_000_000_000,
374                limit_bytes: 8_000_000_000,
375                usage_pct: 25.0,
376            }),
377        };
378        let health = HealthResponseV2::ready(snap);
379        let json = serde_json::to_string(&health).unwrap();
380        let parsed: HealthResponseV2 = serde_json::from_str(&json).unwrap();
381        assert_eq!(health, parsed);
382        assert_eq!(parsed.state, ReadinessState::Ready);
383        assert!(parsed.snapshot.is_some());
384    }
385
386    #[test]
387    fn v2_health_warming_round_trips() {
388        let health = HealthResponseV2::warming();
389        let json = serde_json::to_string(&health).unwrap();
390        let parsed: HealthResponseV2 = serde_json::from_str(&json).unwrap();
391        assert_eq!(health, parsed);
392        assert_eq!(parsed.state, ReadinessState::Warming);
393        assert!(parsed.snapshot.is_none());
394    }
395
396    #[test]
397    fn v2_health_failed_round_trips() {
398        let health = HealthResponseV2::failed(HealthCategory::CollectorFailure, "boom");
399        let json = serde_json::to_string(&health).unwrap();
400        let parsed: HealthResponseV2 = serde_json::from_str(&json).unwrap();
401        assert_eq!(health, parsed);
402        assert_eq!(parsed.state, ReadinessState::Failed);
403        assert_eq!(parsed.message.as_deref(), Some("boom"));
404    }
405
406    #[test]
407    fn v2_schema_version_constant() {
408        assert_eq!(SCHEMA_VERSION_V2, 2);
409    }
410}