Skip to main content

cellos_supervisor/
linux_cgroup.rs

1//! Helpers that translate `spec.run.limits` into cgroup v2 controller-file payloads.
2//!
3//! **Doctrine — D1 (no ambient defaults):**
4//! - `spec.run.limits.cpuMax` is the **primary** source for the cgroup `cpu.max`
5//!   controller value applied to the host-subprocess backend.
6//! - `CELLOS_CGROUP_CPU_MAX` is an **explicit** operator override. It only
7//!   applies when the spec did **not** set `cpuMax`. Setting the env var alone
8//!   is *not* a default — operators opt in by exporting it.
9//!
10//! These helpers are pure (no I/O, no `std::env::var` reads at the call sites
11//! that exercise them in unit tests) so the resolution + normalization rules
12//! can be tested directly without a real cgroup v2 hierarchy. The actual file
13//! write is performed by `supervisor::linux_cgroup_write_optional_controller_files`.
14//!
15//! These helpers are platform-agnostic at the type level — they operate on
16//! strings — but only the Linux subprocess path consumes their output.
17
18use cellos_core::types::RunLimits;
19
20/// Default cgroup v2 CPU period when the spec omits `periodMicros`. Matches the
21/// schema description (`Defaults to 100000 when omitted by the runtime.`) and
22/// the kernel default for `cpu.max`.
23pub const DEFAULT_CPU_PERIOD_MICROS: u64 = 100_000;
24
25/// Resolve the `cpu.max` payload for the host-subprocess cgroup leaf.
26///
27/// Resolution order (highest priority first):
28/// 1. `spec.run.limits.cpuMax` — typed source of truth from the contract.
29/// 2. `env_cpu_max` — operator-supplied `CELLOS_CGROUP_CPU_MAX` override; used
30///    only when the spec is silent. The caller passes the env value verbatim
31///    (or `None` when unset / empty); validation/normalization happens here.
32///
33/// Returns `Some(payload)` when a value should be written to the leaf's
34/// `cpu.max` file (without a trailing newline; the writer adds one). Returns
35/// `None` when neither source produced an applicable value, or when the env
36/// override was syntactically invalid (caller should warn — see
37/// `cpu_max_env_validation_error` for the diagnostic string).
38///
39/// Accepted env-override syntaxes (per cgroup v2 `cpu.max`):
40/// - `max`                              → `"max 100000"`
41/// - `max <period>`                     → `"max <period>"`
42/// - `<quota>`                          → `"<quota> 100000"`
43/// - `<quota> <period>`                 → `"<quota> <period>"`
44///
45/// Whitespace is collapsed; `quota` and `period` must parse as `u64` and be
46/// `>= 1` (cgroup v2 rejects 0). Anything else returns `None` here and the
47/// caller is expected to log a warning.
48pub fn cpu_max_to_write(
49    spec_limits: Option<&RunLimits>,
50    env_cpu_max: Option<&str>,
51) -> Option<String> {
52    // Spec wins.
53    if let Some(cpu) = spec_limits.and_then(|l| l.cpu_max.as_ref()) {
54        if cpu.quota_micros == 0 {
55            // Spec validation should have rejected this; defend in depth.
56            return None;
57        }
58        let period = cpu
59            .period_micros
60            .unwrap_or(DEFAULT_CPU_PERIOD_MICROS)
61            .max(1);
62        let quota = cpu.quota_micros;
63        return Some(format!("{quota} {period}"));
64    }
65    // Fall back to explicit env override.
66    let raw = env_cpu_max?.trim();
67    if raw.is_empty() {
68        return None;
69    }
70    normalize_env_cpu_max(raw)
71}
72
73/// Validate + normalize `CELLOS_CGROUP_CPU_MAX` into the cgroup v2 wire format.
74///
75/// Returns `None` for syntactically invalid input — the caller should warn.
76fn normalize_env_cpu_max(raw: &str) -> Option<String> {
77    let mut parts = raw.split_ascii_whitespace();
78    let first = parts.next()?;
79    let second = parts.next();
80    if parts.next().is_some() {
81        return None; // too many tokens
82    }
83    let quota = match first {
84        "max" => "max".to_string(),
85        n => {
86            let v: u64 = n.parse().ok()?;
87            if v == 0 {
88                return None;
89            }
90            v.to_string()
91        }
92    };
93    let period: u64 = match second {
94        Some(p) => {
95            let v: u64 = p.parse().ok()?;
96            if v == 0 {
97                return None;
98            }
99            v
100        }
101        None => DEFAULT_CPU_PERIOD_MICROS,
102    };
103    Some(format!("{quota} {period}"))
104}
105
106/// Whether the env override produced an unusable value. Useful for the
107/// supervisor's warn log when the spec is silent and the env value is
108/// malformed.
109pub fn cpu_max_env_validation_error(env_cpu_max: Option<&str>) -> Option<&'static str> {
110    let raw = env_cpu_max?.trim();
111    if raw.is_empty() {
112        return None;
113    }
114    if normalize_env_cpu_max(raw).is_none() {
115        Some("expected `max`, `<quota_micros>`, `max <period_micros>`, or `<quota_micros> <period_micros>` (each integer >= 1)")
116    } else {
117        None
118    }
119}
120
121/// Outcome of [`apply_cpu_max_to_leaf`] — useful for tests and structured logs.
122#[derive(Debug, PartialEq, Eq)]
123pub enum CpuMaxApplyOutcome {
124    /// `cpu.max` was written. Carries the source label and the exact payload
125    /// (without trailing newline) that hit the file.
126    Wrote {
127        /// Which input produced the payload (spec vs. env override).
128        source: CpuMaxSource,
129        /// The exact `cpu.max` payload written (no trailing newline).
130        payload: String,
131    },
132    /// No applicable input — spec silent and env unset/empty.
133    Skipped,
134    /// Spec silent and env override was malformed; caller should warn.
135    InvalidEnvOverride,
136    /// Write failed (permissions, controller not delegated, etc.). Carries a
137    /// human-readable diagnostic.
138    WriteError(String),
139}
140
141/// Which source produced the `cpu.max` payload.
142#[derive(Debug, PartialEq, Eq, Clone, Copy)]
143pub enum CpuMaxSource {
144    /// Value came from `spec.run.limits.cpuMax`.
145    Spec,
146    /// Value came from `CELLOS_CGROUP_CPU_MAX`.
147    EnvOverride,
148}
149
150/// Apply the resolved `cpu.max` payload to `<leaf>/cpu.max`, if any.
151///
152/// This is the side-effecting wrapper used by the host-subprocess backend; it
153/// is intentionally separated from [`cpu_max_to_write`] so the resolution
154/// rules stay testable on every platform while the file write is exercised by
155/// Linux-only integration tests.
156///
157/// The function never panics. Callers translate the returned outcome into
158/// `tracing::warn!` events with their own structured fields.
159pub fn apply_cpu_max_to_leaf(
160    leaf: &std::path::Path,
161    spec_limits: Option<&RunLimits>,
162    env_cpu_max: Option<&str>,
163) -> CpuMaxApplyOutcome {
164    if let Some(payload) = cpu_max_to_write(spec_limits, env_cpu_max) {
165        let source = if spec_limits.and_then(|l| l.cpu_max.as_ref()).is_some() {
166            CpuMaxSource::Spec
167        } else {
168            CpuMaxSource::EnvOverride
169        };
170        let target = leaf.join("cpu.max");
171        match std::fs::write(&target, format!("{payload}\n")) {
172            Ok(()) => CpuMaxApplyOutcome::Wrote { source, payload },
173            Err(e) => CpuMaxApplyOutcome::WriteError(format!("{}: {e}", target.display())),
174        }
175    } else if spec_limits.and_then(|l| l.cpu_max.as_ref()).is_none()
176        && cpu_max_env_validation_error(env_cpu_max).is_some()
177    {
178        CpuMaxApplyOutcome::InvalidEnvOverride
179    } else {
180        CpuMaxApplyOutcome::Skipped
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use cellos_core::types::{RunCpuMax, RunLimits};
188
189    fn limits_with_cpu(quota: u64, period: Option<u64>) -> RunLimits {
190        RunLimits {
191            memory_max_bytes: None,
192            cpu_max: Some(RunCpuMax {
193                quota_micros: quota,
194                period_micros: period,
195            }),
196            graceful_shutdown_seconds: None,
197        }
198    }
199
200    #[test]
201    fn spec_with_quota_and_period_wins() {
202        let l = limits_with_cpu(50_000, Some(100_000));
203        assert_eq!(
204            cpu_max_to_write(Some(&l), Some("max 200000")),
205            Some("50000 100000".to_string())
206        );
207    }
208
209    #[test]
210    fn spec_quota_only_uses_default_period() {
211        let l = limits_with_cpu(75_000, None);
212        assert_eq!(
213            cpu_max_to_write(Some(&l), None),
214            Some("75000 100000".to_string())
215        );
216    }
217
218    #[test]
219    fn spec_quota_zero_returns_none_defense_in_depth() {
220        let l = limits_with_cpu(0, None);
221        assert_eq!(cpu_max_to_write(Some(&l), Some("50000 100000")), None);
222    }
223
224    #[test]
225    fn env_override_used_when_spec_absent() {
226        assert_eq!(
227            cpu_max_to_write(None, Some("50000 100000")),
228            Some("50000 100000".to_string())
229        );
230    }
231
232    #[test]
233    fn env_max_alone_normalizes_with_default_period() {
234        assert_eq!(
235            cpu_max_to_write(None, Some("max")),
236            Some("max 100000".to_string())
237        );
238    }
239
240    #[test]
241    fn env_max_with_period() {
242        assert_eq!(
243            cpu_max_to_write(None, Some("max 200000")),
244            Some("max 200000".to_string())
245        );
246    }
247
248    #[test]
249    fn env_quota_alone_uses_default_period() {
250        assert_eq!(
251            cpu_max_to_write(None, Some("25000")),
252            Some("25000 100000".to_string())
253        );
254    }
255
256    #[test]
257    fn env_whitespace_collapsed() {
258        assert_eq!(
259            cpu_max_to_write(None, Some("  50000\t  100000  ")),
260            Some("50000 100000".to_string())
261        );
262    }
263
264    #[test]
265    fn env_invalid_returns_none() {
266        assert_eq!(cpu_max_to_write(None, Some("not-a-number")), None);
267        assert_eq!(cpu_max_to_write(None, Some("0 100000")), None);
268        assert_eq!(cpu_max_to_write(None, Some("50000 0")), None);
269        assert_eq!(cpu_max_to_write(None, Some("50000 100000 200000")), None);
270        assert_eq!(cpu_max_to_write(None, Some("-50000")), None);
271    }
272
273    #[test]
274    fn env_empty_treated_as_unset() {
275        assert_eq!(cpu_max_to_write(None, Some("")), None);
276        assert_eq!(cpu_max_to_write(None, Some("   ")), None);
277        assert_eq!(cpu_max_to_write(None, None), None);
278    }
279
280    #[test]
281    fn env_validation_error_diagnostic() {
282        assert!(cpu_max_env_validation_error(Some("not-a-number")).is_some());
283        assert!(cpu_max_env_validation_error(Some("0")).is_some());
284        assert!(cpu_max_env_validation_error(Some("50000 100000")).is_none());
285        assert!(cpu_max_env_validation_error(Some("max")).is_none());
286        assert!(cpu_max_env_validation_error(None).is_none());
287        assert!(cpu_max_env_validation_error(Some("")).is_none());
288    }
289
290    /// Doctrine D1 — env var alone cannot create a default; spec wins when both
291    /// are present.
292    #[test]
293    fn spec_overrides_env_when_both_present() {
294        let l = limits_with_cpu(10_000, Some(50_000));
295        assert_eq!(
296            cpu_max_to_write(Some(&l), Some("max 200000")),
297            Some("10000 50000".to_string())
298        );
299    }
300}