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