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:   BUSL-1.1
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/// Read the container CPU limit in cores from cgroups (v1/v2), independent of a
136/// [`ContainerMetrics`] instance. `None` when unlimited or undetectable -- the
137/// scaling engine then falls back to `available_parallelism` for the CPU
138/// utilisation denominator.
139#[cfg(all(feature = "scaling", feature = "expression"))]
140pub(crate) fn cpu_limit_cores() -> Option<f64> {
141    match detect_cgroup_version() {
142        CgroupVersion::V2 => {
143            let content = fs::read_to_string("/sys/fs/cgroup/cpu.max").ok()?;
144            parse_cpu_max_v2(&content)
145        }
146        CgroupVersion::V1 => {
147            let quota = read_cgroup_value("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")?;
148            let period = read_cgroup_value("/sys/fs/cgroup/cpu/cpu.cfs_period_us")?;
149            if quota == u64::MAX || period == 0 {
150                None
151            } else {
152                Some(quota as f64 / period as f64)
153            }
154        }
155        CgroupVersion::Unknown => None,
156    }
157}
158
159/// Detect which cgroup version is in use.
160fn detect_cgroup_version() -> CgroupVersion {
161    // cgroup v2 unified hierarchy
162    if Path::new("/sys/fs/cgroup/cgroup.controllers").exists() {
163        return CgroupVersion::V2;
164    }
165
166    // cgroup v1
167    if Path::new("/sys/fs/cgroup/memory/memory.limit_in_bytes").exists() {
168        return CgroupVersion::V1;
169    }
170
171    CgroupVersion::Unknown
172}
173
174/// Read a numeric value from a cgroup file.
175fn read_cgroup_value(path: &str) -> Option<u64> {
176    let content = fs::read_to_string(path).ok()?;
177    let trimmed = content.trim();
178
179    // Handle "max" (unlimited)
180    if trimmed == "max" {
181        return Some(u64::MAX);
182    }
183
184    trimmed.parse().ok()
185}
186
187/// Parse cpu.max format: "quota period" or "max period".
188fn parse_cpu_max_v2(content: &str) -> Option<f64> {
189    let parts: Vec<&str> = content.split_whitespace().collect();
190    if parts.len() != 2 {
191        return None;
192    }
193
194    let quota = parts[0];
195    let period: u64 = parts[1].parse().ok()?;
196
197    if quota == "max" || period == 0 {
198        return None;
199    }
200
201    let quota_us: u64 = quota.parse().ok()?;
202    Some(quota_us as f64 / period as f64)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_container_metrics_new() {
211        let cm = ContainerMetrics::new("test");
212        assert_eq!(cm.namespace, "test");
213    }
214
215    #[test]
216    fn test_parse_cpu_max_v2() {
217        assert_eq!(parse_cpu_max_v2("100000 100000"), Some(1.0));
218        assert_eq!(parse_cpu_max_v2("50000 100000"), Some(0.5));
219        assert_eq!(parse_cpu_max_v2("max 100000"), None);
220        assert_eq!(parse_cpu_max_v2("invalid"), None);
221    }
222
223    #[test]
224    fn test_container_metrics_update() {
225        let cm = ContainerMetrics::new("test");
226        // Should not panic even if not in a container
227        cm.update();
228    }
229}