Skip to main content

a3s_box_runtime/
resize.rs

1//! Live resource resize for running MicroVMs.
2//!
3//! Tier 1 (vCPU count, memory size): NOT supported — libkrun sets these at
4//! boot via `krun_set_vm_config` and exposes no hot-resize API.
5//!
6//! Tier 2 (cgroup-based limits): Supported on Linux guests by writing to
7//! cgroup v2 control files inside the guest via the exec channel.
8
9use a3s_box_core::config::ResourceLimits;
10use a3s_box_core::error::{BoxError, Result};
11
12/// A resource update request.
13///
14/// Fields set to `None` are left unchanged.
15#[derive(Debug, Clone, Default)]
16pub struct ResourceUpdate {
17    /// vCPU count change (Tier 1 — will be rejected).
18    pub vcpus: Option<u32>,
19    /// Memory in MiB change (Tier 1 — will be rejected).
20    pub memory_mb: Option<u32>,
21    /// Cgroup-based limits (Tier 2 — applied via exec).
22    pub limits: ResourceLimits,
23}
24
25/// Result of a resize attempt.
26#[derive(Debug)]
27pub struct ResizeResult {
28    /// Fields that were successfully applied.
29    pub applied: Vec<String>,
30    /// Fields that were rejected with reasons.
31    pub rejected: Vec<(String, String)>,
32}
33
34impl ResourceUpdate {
35    /// Check if any Tier 1 (immutable) fields are requested.
36    pub fn has_tier1_changes(&self) -> bool {
37        self.vcpus.is_some() || self.memory_mb.is_some()
38    }
39
40    /// Check if any Tier 2 (cgroup) fields are requested.
41    pub fn has_tier2_changes(&self) -> bool {
42        self.limits.cpu_shares.is_some()
43            || self.limits.cpu_quota.is_some()
44            || self.limits.cpu_period.is_some()
45            || self.limits.memory_reservation.is_some()
46            || self.limits.memory_swap.is_some()
47            || self.limits.pids_limit.is_some()
48            || self.limits.cpuset_cpus.is_some()
49    }
50
51    /// Build shell commands to apply Tier 2 cgroup changes inside the guest.
52    ///
53    /// Returns one `sh` command per cgroup v2 control file. The resize exec runs
54    /// in the guest ROOT cgroup, so each command resolves the per-container
55    /// cgroup slice (`box-<pid>-<seq>`, joined by the container at spawn) at
56    /// runtime and writes there — a bare `/sys/fs/cgroup/<file>` write would hit
57    /// the root cgroup and silently leave the container's limits unchanged.
58    pub fn build_cgroup_commands(&self) -> Vec<String> {
59        let mut cmds = Vec::new();
60
61        // cpu.max: "$QUOTA $PERIOD" (or "max $PERIOD" for unlimited)
62        if self.limits.cpu_quota.is_some() || self.limits.cpu_period.is_some() {
63            let quota = self
64                .limits
65                .cpu_quota
66                .map(|q| {
67                    if q < 0 {
68                        "max".to_string()
69                    } else {
70                        q.to_string()
71                    }
72                })
73                .unwrap_or_else(|| "max".to_string());
74            let period = self.limits.cpu_period.unwrap_or(100_000);
75            cmds.push(cgroup_write_cmd("cpu.max", &format!("{quota} {period}")));
76        }
77
78        // cpu.weight: 1-10000 (maps from Docker's cpu-shares 2-262144)
79        if let Some(shares) = self.limits.cpu_shares {
80            // Docker shares (2-262144) → cgroup v2 weight (1-10000), runc's
81            // mapping. Clamp shares into range FIRST (so the `* 9999` cannot
82            // overflow for absurd inputs near u64::MAX) and clamp the final
83            // result to [1, 10000] (the bare `1 + …` can reach 10001). Mirrors
84            // the guest `cgroup::shares_to_weight`.
85            let shares = shares.clamp(2, 262_144);
86            let weight = (1 + ((shares - 2) * 9999) / 262_142).clamp(1, 10_000);
87            cmds.push(cgroup_write_cmd("cpu.weight", &weight.to_string()));
88        }
89
90        // memory.low (soft limit / reservation)
91        if let Some(reservation) = self.limits.memory_reservation {
92            cmds.push(cgroup_write_cmd("memory.low", &reservation.to_string()));
93        }
94
95        // memory.swap.max
96        if let Some(swap) = self.limits.memory_swap {
97            let val = if swap < 0 {
98                "max".to_string()
99            } else {
100                swap.to_string()
101            };
102            cmds.push(cgroup_write_cmd("memory.swap.max", &val));
103        }
104
105        // pids.max
106        if let Some(pids) = self.limits.pids_limit {
107            cmds.push(cgroup_write_cmd("pids.max", &pids.to_string()));
108        }
109
110        // cpuset.cpus — only emit a known-good value. `validate_update` already
111        // rejects malformed cpusets, but guard here too since the value is
112        // interpolated into the resize shell command: a stray quote/`$`/`;`
113        // could otherwise break out of `echo '…'` and run arbitrary shell in the
114        // guest.
115        if let Some(ref cpuset) = self.limits.cpuset_cpus {
116            if is_valid_cpuset(cpuset) {
117                cmds.push(cgroup_write_cmd("cpuset.cpus", cpuset));
118            } else {
119                tracing::warn!(cpuset = %cpuset, "Skipping malformed cpuset.cpus value");
120            }
121        }
122
123        cmds
124    }
125}
126
127/// Validate a cgroup `cpuset.cpus` value: a comma-separated list of CPU indices
128/// and ranges, e.g. `0`, `0,2,4`, `0-3`, `0-1,4-7`. Only ASCII digits, `,` and
129/// `-` are allowed, so no shell metacharacter can survive — the kernel rejects
130/// anything else anyway. Surrounding whitespace per element is tolerated.
131fn is_valid_cpuset(cpuset: &str) -> bool {
132    let cpuset = cpuset.trim();
133    if cpuset.is_empty() {
134        return false;
135    }
136    cpuset.split(',').all(|element| {
137        let element = element.trim();
138        match element.split_once('-') {
139            Some((lo, hi)) => {
140                !lo.is_empty()
141                    && !hi.is_empty()
142                    && lo.bytes().all(|b| b.is_ascii_digit())
143                    && hi.bytes().all(|b| b.is_ascii_digit())
144            }
145            None => !element.is_empty() && element.bytes().all(|b| b.is_ascii_digit()),
146        }
147    })
148}
149
150/// Build a `sh` command that writes `value` to cgroup v2 control file `file` in
151/// the container's per-container cgroup slice.
152///
153/// The resize exec runs in the guest root cgroup and this exec channel carries
154/// no container id, so the command resolves the slice at runtime: when there is
155/// exactly one `box-*` slice (every CLI box and single-container pod) it writes
156/// there. Otherwise it FAILS (exit 1) — it must never fall back to writing the
157/// bare root cgroup, which either errors (e.g. root has no `cpu.max`) or applies
158/// the limit to the whole root hierarchy instead of the container, while the CLI
159/// still reported success. A non-zero exit surfaces as a visible warning at the
160/// call site (container_update) instead of a silent mis-apply.
161fn cgroup_write_cmd(file: &str, value: &str) -> String {
162    format!(
163        "d=\"\"; n=0; for x in /sys/fs/cgroup/box-*/; do [ -d \"$x\" ] && {{ d=\"$x\"; n=$((n+1)); }}; done; [ \"$n\" = 1 ] || {{ echo \"a3s-resize: cannot resolve a unique per-container cgroup ($n box-* slices) to set {file}\" >&2; exit 1; }}; echo '{value}' > \"${{d}}{file}\""
164    )
165}
166
167/// Validate a resource update request.
168///
169/// Returns `Err` if Tier 1 changes are requested (not supported by libkrun).
170/// Returns `Ok(())` if only Tier 2 changes or no changes.
171pub fn validate_update(update: &ResourceUpdate) -> Result<()> {
172    if let Some(vcpus) = update.vcpus {
173        return Err(BoxError::ResizeError(format!(
174            "Cannot change vCPU count to {} on a running VM: libkrun does not support \
175             hot-plug vCPUs. Stop and recreate the box with the desired CPU count.",
176            vcpus
177        )));
178    }
179    if let Some(memory_mb) = update.memory_mb {
180        return Err(BoxError::ResizeError(format!(
181            "Cannot change memory to {}MB on a running VM: libkrun does not support \
182             memory ballooning. Stop and recreate the box with the desired memory size.",
183            memory_mb
184        )));
185    }
186    // Reject a malformed cpuset before it can be interpolated into the resize
187    // shell command (cgroup `cpuset.cpus` accepts only indices/ranges anyway).
188    if let Some(ref cpuset) = update.limits.cpuset_cpus {
189        if !is_valid_cpuset(cpuset) {
190            return Err(BoxError::ResizeError(format!(
191                "Invalid cpuset.cpus value {cpuset:?}: expected a comma-separated list of CPU \
192                 indices/ranges such as \"0-3\" or \"0,2,4\"."
193            )));
194        }
195    }
196    Ok(())
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_empty_update_has_no_changes() {
205        let update = ResourceUpdate::default();
206        assert!(!update.has_tier1_changes());
207        assert!(!update.has_tier2_changes());
208        assert!(update.build_cgroup_commands().is_empty());
209    }
210
211    #[test]
212    fn test_tier1_vcpus_detected() {
213        let update = ResourceUpdate {
214            vcpus: Some(4),
215            ..Default::default()
216        };
217        assert!(update.has_tier1_changes());
218        assert!(!update.has_tier2_changes());
219    }
220
221    #[test]
222    fn test_tier1_memory_detected() {
223        let update = ResourceUpdate {
224            memory_mb: Some(2048),
225            ..Default::default()
226        };
227        assert!(update.has_tier1_changes());
228    }
229
230    #[test]
231    fn test_validate_rejects_vcpu_change() {
232        let update = ResourceUpdate {
233            vcpus: Some(8),
234            ..Default::default()
235        };
236        let err = validate_update(&update).unwrap_err();
237        assert!(err.to_string().contains("vCPU count"));
238        assert!(err.to_string().contains("libkrun"));
239    }
240
241    #[test]
242    fn test_validate_rejects_memory_change() {
243        let update = ResourceUpdate {
244            memory_mb: Some(4096),
245            ..Default::default()
246        };
247        let err = validate_update(&update).unwrap_err();
248        assert!(err.to_string().contains("memory"));
249        assert!(err.to_string().contains("ballooning"));
250    }
251
252    #[test]
253    fn test_validate_allows_tier2_only() {
254        let update = ResourceUpdate {
255            limits: ResourceLimits {
256                cpu_shares: Some(512),
257                pids_limit: Some(100),
258                ..Default::default()
259            },
260            ..Default::default()
261        };
262        assert!(validate_update(&update).is_ok());
263    }
264
265    #[test]
266    fn test_cpu_max_command() {
267        let update = ResourceUpdate {
268            limits: ResourceLimits {
269                cpu_quota: Some(50000),
270                cpu_period: Some(100000),
271                ..Default::default()
272            },
273            ..Default::default()
274        };
275        let cmds = update.build_cgroup_commands();
276        assert_eq!(cmds.len(), 1);
277        assert!(cmds[0].contains("50000 100000"));
278        assert!(cmds[0].contains("cpu.max"));
279    }
280
281    #[test]
282    fn test_cpu_max_unlimited_quota() {
283        let update = ResourceUpdate {
284            limits: ResourceLimits {
285                cpu_quota: Some(-1),
286                ..Default::default()
287            },
288            ..Default::default()
289        };
290        let cmds = update.build_cgroup_commands();
291        assert_eq!(cmds.len(), 1);
292        assert!(cmds[0].contains("max 100000"));
293    }
294
295    #[test]
296    fn test_cpu_weight_conversion() {
297        let update = ResourceUpdate {
298            limits: ResourceLimits {
299                cpu_shares: Some(1024),
300                ..Default::default()
301            },
302            ..Default::default()
303        };
304        let cmds = update.build_cgroup_commands();
305        assert_eq!(cmds.len(), 1);
306        assert!(cmds[0].contains("cpu.weight"));
307    }
308
309    #[test]
310    fn test_cpu_weight_minimum() {
311        let update = ResourceUpdate {
312            limits: ResourceLimits {
313                cpu_shares: Some(2),
314                ..Default::default()
315            },
316            ..Default::default()
317        };
318        let cmds = update.build_cgroup_commands();
319        assert!(cmds[0].contains("'1'"));
320    }
321
322    #[test]
323    fn test_memory_reservation_command() {
324        let update = ResourceUpdate {
325            limits: ResourceLimits {
326                memory_reservation: Some(536870912), // 512MB
327                ..Default::default()
328            },
329            ..Default::default()
330        };
331        let cmds = update.build_cgroup_commands();
332        assert_eq!(cmds.len(), 1);
333        assert!(cmds[0].contains("536870912"));
334        assert!(cmds[0].contains("memory.low"));
335    }
336
337    #[test]
338    fn test_memory_swap_unlimited() {
339        let update = ResourceUpdate {
340            limits: ResourceLimits {
341                memory_swap: Some(-1),
342                ..Default::default()
343            },
344            ..Default::default()
345        };
346        let cmds = update.build_cgroup_commands();
347        assert!(cmds[0].contains("'max'"));
348        assert!(cmds[0].contains("memory.swap.max"));
349    }
350
351    #[test]
352    fn test_pids_max_command() {
353        let update = ResourceUpdate {
354            limits: ResourceLimits {
355                pids_limit: Some(256),
356                ..Default::default()
357            },
358            ..Default::default()
359        };
360        let cmds = update.build_cgroup_commands();
361        assert!(cmds[0].contains("256"));
362        assert!(cmds[0].contains("pids.max"));
363    }
364
365    #[test]
366    fn test_cpuset_command() {
367        let update = ResourceUpdate {
368            limits: ResourceLimits {
369                cpuset_cpus: Some("0,1,3".to_string()),
370                ..Default::default()
371            },
372            ..Default::default()
373        };
374        let cmds = update.build_cgroup_commands();
375        assert!(cmds[0].contains("0,1,3"));
376        assert!(cmds[0].contains("cpuset.cpus"));
377    }
378
379    #[test]
380    fn test_cpuset_valid_forms_accepted() {
381        for ok in ["0", "0,1,3", "0-3", "0-1,4-7", " 0 , 2 "] {
382            assert!(is_valid_cpuset(ok), "{ok:?} should be valid");
383        }
384    }
385
386    #[test]
387    fn test_cpuset_injection_rejected() {
388        // Shell-injection payloads and other malformed values must be rejected so
389        // they never reach `echo '…'` in the resize command.
390        for bad in [
391            "",
392            "0'$(id >>/tmp/pwned)",
393            "0; rm -rf /",
394            "0`whoami`",
395            "0\nmalicious",
396            "all",
397            "0-",
398            "-3",
399        ] {
400            assert!(!is_valid_cpuset(bad), "{bad:?} should be rejected");
401        }
402    }
403
404    #[test]
405    fn test_validate_rejects_malformed_cpuset() {
406        let update = ResourceUpdate {
407            limits: ResourceLimits {
408                cpuset_cpus: Some("0'$(id)".to_string()),
409                ..Default::default()
410            },
411            ..Default::default()
412        };
413        let err = validate_update(&update).unwrap_err();
414        assert!(err.to_string().contains("cpuset"));
415        // And the dangerous value never makes it into a shell command.
416        assert!(update.build_cgroup_commands().is_empty());
417    }
418
419    #[test]
420    fn test_cpu_weight_clamped_for_oversized_shares() {
421        // Absurd shares must not overflow the `* 9999` nor exceed cgroup's max
422        // weight of 10000.
423        let update = ResourceUpdate {
424            limits: ResourceLimits {
425                cpu_shares: Some(u64::MAX),
426                ..Default::default()
427            },
428            ..Default::default()
429        };
430        let cmds = update.build_cgroup_commands();
431        assert!(cmds[0].contains("'10000'"), "got {}", cmds[0]);
432    }
433
434    #[test]
435    fn test_multiple_tier2_commands() {
436        let update = ResourceUpdate {
437            limits: ResourceLimits {
438                cpu_shares: Some(512),
439                pids_limit: Some(100),
440                memory_reservation: Some(268435456),
441                ..Default::default()
442            },
443            ..Default::default()
444        };
445        let cmds = update.build_cgroup_commands();
446        assert_eq!(cmds.len(), 3);
447    }
448
449    #[test]
450    fn test_cgroup_commands_target_per_container_slice() {
451        let update = ResourceUpdate {
452            limits: ResourceLimits {
453                pids_limit: Some(50),
454                ..Default::default()
455            },
456            ..Default::default()
457        };
458        let cmds = update.build_cgroup_commands();
459        assert_eq!(cmds.len(), 1);
460        // Must resolve the per-container `box-*` slice, not write a bare root path.
461        assert!(cmds[0].contains("/sys/fs/cgroup/box-*"), "got {}", cmds[0]);
462        assert!(cmds[0].contains("pids.max"));
463        assert!(cmds[0].contains("'50'"));
464    }
465
466    #[test]
467    fn test_cgroup_command_fails_instead_of_writing_root() {
468        // When the per-container slice can't be uniquely resolved the command
469        // must exit non-zero (surfacing a warning at the call site), NOT fall
470        // back to writing the bare root cgroup, which mis-applies the limit to
471        // the whole hierarchy while the CLI reports success.
472        let update = ResourceUpdate {
473            limits: ResourceLimits {
474                cpu_quota: Some(50_000),
475                cpu_period: Some(100_000),
476                ..Default::default()
477            },
478            ..Default::default()
479        };
480        let cmds = update.build_cgroup_commands();
481        assert_eq!(cmds.len(), 1);
482        assert!(cmds[0].contains("exit 1"), "must fail loudly: {}", cmds[0]);
483        // The dangerous root-cgroup fallback must be gone: no assignment that
484        // points the write target `d` at the bare root.
485        assert!(
486            !cmds[0].contains("d=\"/sys/fs/cgroup/\""),
487            "must not fall back to the root cgroup: {}",
488            cmds[0]
489        );
490    }
491
492    #[test]
493    fn test_resize_result_structure() {
494        let result = ResizeResult {
495            applied: vec!["cpu.weight".to_string()],
496            rejected: vec![("vcpus".to_string(), "not supported".to_string())],
497        };
498        assert_eq!(result.applied.len(), 1);
499        assert_eq!(result.rejected.len(), 1);
500    }
501}