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, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113#[allow(clippy::struct_excessive_bools)]
114pub struct MetricCapabilitiesV2 {
115    /// Whether aggregate CPU I/O wait is reported.
116    pub cpu_iowait: bool,
117    /// Whether one-/five-/fifteen-minute load averages are reported.
118    pub load_average: bool,
119    /// Whether swap utilization is reported.
120    pub swap: bool,
121    /// Whether memory commit charge is reported.
122    pub memory_commit: bool,
123}
124
125/// CPU utilization snapshot for schema version 2.
126///
127/// Identical to v1 `CpuMetrics` but placed in the v2 module for
128/// independent evolution.
129#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub struct CpuMetricsV2 {
132    /// Number of logical CPU cores available to the kernel.
133    pub logical_cores: u32,
134    /// Total CPU busy percentage, `0.0..=100.0`.
135    pub usage_pct: f32,
136    /// Aggregate CPU I/O-wait percentage, `0.0..=100.0`. `None` when
137    /// [`MetricCapabilitiesV2::cpu_iowait`] is `false`.
138    pub iowait_pct: Option<f32>,
139}
140
141/// Swap utilization for schema version 2.
142///
143/// When `total_bytes` is zero, `usage_pct` is `0.0` rather than `NaN`.
144#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub struct SwapMetrics {
147    /// Used swap in bytes. Never exceeds `total_bytes`.
148    pub used_bytes: u64,
149    /// Total swap in bytes.
150    pub total_bytes: u64,
151    /// Swap utilization percentage, `0.0..=100.0`.
152    pub usage_pct: f32,
153}
154
155/// Commit charge metrics (Windows memory commit accounting).
156///
157/// Represents the system's commit charge: the total bytes committed by all
158/// processes, the commit limit, and the derived percentage. This is
159/// conceptually distinct from swap and must not be serialized as swap.
160#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub struct CommitMetrics {
163    /// Committed bytes in use.
164    pub used_bytes: u64,
165    /// Maximum commit limit in bytes.
166    pub limit_bytes: u64,
167    /// Commit utilization percentage, `0.0..=100.0`.
168    pub usage_pct: f32,
169}
170
171/// Health and readiness response for schema version 2.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub struct HealthResponseV2 {
175    /// Daemon schema version, always [`SCHEMA_VERSION_V2`].
176    pub schema_version: u16,
177    /// Current readiness state.
178    pub state: crate::ReadinessState,
179    /// Coarse category for non-ready responses. `None` when `state == Ready`.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub category: Option<crate::HealthCategory>,
182    /// Short human-readable message. Never includes filesystem paths or
183    /// internal error chains.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub message: Option<String>,
186    /// Cached v2 snapshot, present only when `state == Ready`.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub snapshot: Option<StatusSnapshotV2>,
189}
190
191impl HealthResponseV2 {
192    /// A `Ready` response wrapping the supplied v2 snapshot.
193    #[must_use]
194    pub fn ready(snapshot: StatusSnapshotV2) -> Self {
195        Self {
196            schema_version: SCHEMA_VERSION_V2,
197            state: crate::ReadinessState::Ready,
198            category: None,
199            message: None,
200            snapshot: Some(snapshot),
201        }
202    }
203
204    /// A `Warming` response with a default message.
205    #[must_use]
206    pub fn warming() -> Self {
207        Self::warming_with_message("collector warming up")
208    }
209
210    /// A `Warming` response with a custom message.
211    #[must_use]
212    pub fn warming_with_message(message: impl Into<String>) -> Self {
213        Self {
214            schema_version: SCHEMA_VERSION_V2,
215            state: crate::ReadinessState::Warming,
216            category: Some(crate::HealthCategory::Warming),
217            message: Some(message.into()),
218            snapshot: None,
219        }
220    }
221
222    /// A `Failed` response with the given category and message.
223    #[must_use]
224    pub fn failed(category: crate::HealthCategory, message: impl Into<String>) -> Self {
225        Self {
226            schema_version: SCHEMA_VERSION_V2,
227            state: crate::ReadinessState::Failed,
228            category: Some(category),
229            message: Some(message.into()),
230            snapshot: None,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::{HealthCategory, ReadinessState};
239
240    fn v2_identity() -> SystemIdentity {
241        SystemIdentity {
242            name: "test".into(),
243            hostname: "test.local".into(),
244            os_name: "linux".into(),
245            os_version: "1.0".into(),
246            kernel_name: "Linux".into(),
247            kernel_release: "6.0.0".into(),
248            architecture: "x86_64".into(),
249        }
250    }
251
252    #[test]
253    fn v2_linux_snapshot_round_trips() {
254        let snap = StatusSnapshotV2 {
255            schema_version: SCHEMA_VERSION_V2,
256            observed_at_unix_ms: 1_716_460_800_000,
257            sample_interval_ms: 1000,
258            capabilities: MetricCapabilitiesV2 {
259                cpu_iowait: true,
260                load_average: true,
261                swap: true,
262                memory_commit: false,
263            },
264            system: v2_identity(),
265            cpu: CpuMetricsV2 {
266                logical_cores: 8,
267                usage_pct: 25.2,
268                iowait_pct: Some(0.4),
269            },
270            load: Some(LoadAverage {
271                one: 1.32,
272                five: 0.91,
273                fifteen: 0.62,
274            }),
275            memory: crate::MemoryMetrics {
276                used_bytes: 5_900_000_000,
277                total_bytes: 15_600_000_000,
278                usage_pct: 37.8,
279            },
280            swap: Some(SwapMetrics {
281                used_bytes: 0,
282                total_bytes: 4_000_000_000,
283                usage_pct: 0.0,
284            }),
285            commit: None,
286        };
287        let json = serde_json::to_string(&snap).unwrap();
288        let parsed: StatusSnapshotV2 = serde_json::from_str(&json).unwrap();
289        assert_eq!(snap, parsed);
290    }
291
292    #[test]
293    fn v2_windows_snapshot_no_load_no_swap() {
294        let snap = StatusSnapshotV2 {
295            schema_version: SCHEMA_VERSION_V2,
296            observed_at_unix_ms: 1_716_460_800_000,
297            sample_interval_ms: 1000,
298            capabilities: MetricCapabilitiesV2 {
299                cpu_iowait: false,
300                load_average: false,
301                swap: false,
302                memory_commit: true,
303            },
304            system: v2_identity(),
305            cpu: CpuMetricsV2 {
306                logical_cores: 4,
307                usage_pct: 12.5,
308                iowait_pct: None,
309            },
310            load: None,
311            memory: crate::MemoryMetrics {
312                used_bytes: 2_000_000_000,
313                total_bytes: 8_000_000_000,
314                usage_pct: 25.0,
315            },
316            swap: None,
317            commit: Some(CommitMetrics {
318                used_bytes: 3_000_000_000,
319                limit_bytes: 8_000_000_000,
320                usage_pct: 37.5,
321            }),
322        };
323        let json = serde_json::to_string(&snap).unwrap();
324        assert!(json.contains("\"load_average\":false"));
325        assert!(json.contains("\"swap\":false"));
326        assert!(json.contains("\"memory_commit\":true"));
327        assert!(!json.contains("\"load\":"));
328        assert!(!json.contains("\"swap_used_bytes\""));
329        assert!(json.contains("\"commit\""));
330        assert!(json.contains("\"used_bytes\""));
331
332        let parsed: StatusSnapshotV2 = serde_json::from_str(&json).unwrap();
333        assert_eq!(snap, parsed);
334    }
335
336    #[test]
337    fn v2_health_ready_round_trips() {
338        let snap = StatusSnapshotV2 {
339            schema_version: SCHEMA_VERSION_V2,
340            observed_at_unix_ms: 1,
341            sample_interval_ms: 1000,
342            capabilities: MetricCapabilitiesV2 {
343                cpu_iowait: false,
344                load_average: true,
345                swap: false,
346                memory_commit: true,
347            },
348            system: v2_identity(),
349            cpu: CpuMetricsV2 {
350                logical_cores: 4,
351                usage_pct: 10.0,
352                iowait_pct: None,
353            },
354            load: Some(LoadAverage {
355                one: 1.0,
356                five: 0.5,
357                fifteen: 0.3,
358            }),
359            memory: crate::MemoryMetrics {
360                used_bytes: 1_000_000_000,
361                total_bytes: 4_000_000_000,
362                usage_pct: 25.0,
363            },
364            swap: None,
365            commit: Some(CommitMetrics {
366                used_bytes: 2_000_000_000,
367                limit_bytes: 8_000_000_000,
368                usage_pct: 25.0,
369            }),
370        };
371        let health = HealthResponseV2::ready(snap);
372        let json = serde_json::to_string(&health).unwrap();
373        let parsed: HealthResponseV2 = serde_json::from_str(&json).unwrap();
374        assert_eq!(health, parsed);
375        assert_eq!(parsed.state, ReadinessState::Ready);
376        assert!(parsed.snapshot.is_some());
377    }
378
379    #[test]
380    fn v2_health_warming_round_trips() {
381        let health = HealthResponseV2::warming();
382        let json = serde_json::to_string(&health).unwrap();
383        let parsed: HealthResponseV2 = serde_json::from_str(&json).unwrap();
384        assert_eq!(health, parsed);
385        assert_eq!(parsed.state, ReadinessState::Warming);
386        assert!(parsed.snapshot.is_none());
387    }
388
389    #[test]
390    fn v2_health_failed_round_trips() {
391        let health = HealthResponseV2::failed(HealthCategory::CollectorFailure, "boom");
392        let json = serde_json::to_string(&health).unwrap();
393        let parsed: HealthResponseV2 = serde_json::from_str(&json).unwrap();
394        assert_eq!(health, parsed);
395        assert_eq!(parsed.state, ReadinessState::Failed);
396        assert_eq!(parsed.message.as_deref(), Some("boom"));
397    }
398
399    #[test]
400    fn v2_schema_version_constant() {
401        assert_eq!(SCHEMA_VERSION_V2, 2);
402    }
403}