Skip to main content

hyperi_rustlib/metrics/
container.rs

1// Project:   hyperi-rustlib
2// File:      src/metrics/container.rs
3// Purpose:   Container-level metrics from cgroups
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Container metrics from cgroups (v1 and v2).
10
11use std::fs;
12use std::path::Path;
13
14/// Container metrics collector.
15#[derive(Debug, Clone)]
16pub struct ContainerMetrics {
17    namespace: String,
18    cgroup_version: CgroupVersion,
19}
20
21#[derive(Debug, Clone, Copy)]
22enum CgroupVersion {
23    V1,
24    V2,
25    Unknown,
26}
27
28impl ContainerMetrics {
29    /// Create a new container metrics collector.
30    #[must_use]
31    pub fn new(namespace: &str) -> Self {
32        let cgroup_version = detect_cgroup_version();
33
34        let this = Self {
35            namespace: namespace.to_string(),
36            cgroup_version,
37        };
38
39        this.register_metrics();
40        this
41    }
42
43    /// Register metric descriptions.
44    fn register_metrics(&self) {
45        let ns = &self.namespace;
46
47        metrics::describe_gauge!(
48            format!("{ns}_container_memory_limit_bytes"),
49            "Container memory limit in bytes".to_string()
50        );
51        metrics::describe_gauge!(
52            format!("{ns}_container_memory_usage_bytes"),
53            "Container memory usage in bytes".to_string()
54        );
55        metrics::describe_gauge!(
56            format!("{ns}_container_cpu_limit_cores"),
57            "Container CPU limit in cores".to_string()
58        );
59    }
60
61    /// Update container metrics.
62    pub fn update(&self) {
63        let ns = &self.namespace;
64
65        // Memory limit
66        if let Some(limit) = self.read_memory_limit() {
67            metrics::gauge!(format!("{ns}_container_memory_limit_bytes")).set(limit as f64);
68        }
69
70        // Memory usage
71        if let Some(usage) = self.read_memory_usage() {
72            metrics::gauge!(format!("{ns}_container_memory_usage_bytes")).set(usage as f64);
73        }
74
75        // CPU limit
76        if let Some(cores) = self.read_cpu_limit() {
77            metrics::gauge!(format!("{ns}_container_cpu_limit_cores")).set(cores);
78        }
79    }
80
81    /// Read memory limit from cgroups.
82    fn read_memory_limit(&self) -> Option<u64> {
83        match self.cgroup_version {
84            CgroupVersion::V2 => {
85                // cgroup v2: /sys/fs/cgroup/memory.max
86                read_cgroup_value("/sys/fs/cgroup/memory.max")
87            }
88            CgroupVersion::V1 => {
89                // cgroup v1: /sys/fs/cgroup/memory/memory.limit_in_bytes
90                read_cgroup_value("/sys/fs/cgroup/memory/memory.limit_in_bytes")
91            }
92            CgroupVersion::Unknown => None,
93        }
94    }
95
96    /// Read memory usage from cgroups.
97    fn read_memory_usage(&self) -> Option<u64> {
98        match self.cgroup_version {
99            CgroupVersion::V2 => {
100                // cgroup v2: /sys/fs/cgroup/memory.current
101                read_cgroup_value("/sys/fs/cgroup/memory.current")
102            }
103            CgroupVersion::V1 => {
104                // cgroup v1: /sys/fs/cgroup/memory/memory.usage_in_bytes
105                read_cgroup_value("/sys/fs/cgroup/memory/memory.usage_in_bytes")
106            }
107            CgroupVersion::Unknown => None,
108        }
109    }
110
111    /// Read CPU limit from cgroups (returns cores).
112    fn read_cpu_limit(&self) -> Option<f64> {
113        match self.cgroup_version {
114            CgroupVersion::V2 => {
115                // cgroup v2: /sys/fs/cgroup/cpu.max contains "quota period"
116                let content = fs::read_to_string("/sys/fs/cgroup/cpu.max").ok()?;
117                parse_cpu_max_v2(&content)
118            }
119            CgroupVersion::V1 => {
120                // cgroup v1: quota and period in separate files
121                let quota = read_cgroup_value("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")?;
122                let period = read_cgroup_value("/sys/fs/cgroup/cpu/cpu.cfs_period_us")?;
123
124                if quota == u64::MAX || period == 0 {
125                    None
126                } else {
127                    Some(quota as f64 / period as f64)
128                }
129            }
130            CgroupVersion::Unknown => None,
131        }
132    }
133}
134
135/// Detect which cgroup version is in use.
136fn detect_cgroup_version() -> CgroupVersion {
137    // cgroup v2 unified hierarchy
138    if Path::new("/sys/fs/cgroup/cgroup.controllers").exists() {
139        return CgroupVersion::V2;
140    }
141
142    // cgroup v1
143    if Path::new("/sys/fs/cgroup/memory/memory.limit_in_bytes").exists() {
144        return CgroupVersion::V1;
145    }
146
147    CgroupVersion::Unknown
148}
149
150/// Read a numeric value from a cgroup file.
151fn read_cgroup_value(path: &str) -> Option<u64> {
152    let content = fs::read_to_string(path).ok()?;
153    let trimmed = content.trim();
154
155    // Handle "max" (unlimited)
156    if trimmed == "max" {
157        return Some(u64::MAX);
158    }
159
160    trimmed.parse().ok()
161}
162
163/// Parse cpu.max format: "quota period" or "max period".
164fn parse_cpu_max_v2(content: &str) -> Option<f64> {
165    let parts: Vec<&str> = content.split_whitespace().collect();
166    if parts.len() != 2 {
167        return None;
168    }
169
170    let quota = parts[0];
171    let period: u64 = parts[1].parse().ok()?;
172
173    if quota == "max" || period == 0 {
174        return None;
175    }
176
177    let quota_us: u64 = quota.parse().ok()?;
178    Some(quota_us as f64 / period as f64)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_container_metrics_new() {
187        let cm = ContainerMetrics::new("test");
188        assert_eq!(cm.namespace, "test");
189    }
190
191    #[test]
192    fn test_parse_cpu_max_v2() {
193        assert_eq!(parse_cpu_max_v2("100000 100000"), Some(1.0));
194        assert_eq!(parse_cpu_max_v2("50000 100000"), Some(0.5));
195        assert_eq!(parse_cpu_max_v2("max 100000"), None);
196        assert_eq!(parse_cpu_max_v2("invalid"), None);
197    }
198
199    #[test]
200    fn test_container_metrics_update() {
201        let cm = ContainerMetrics::new("test");
202        // Should not panic even if not in a container
203        cm.update();
204    }
205}