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)) => parse_cpu_index(lo)
140                .zip(parse_cpu_index(hi))
141                .is_some_and(|(lo, hi)| lo <= hi),
142            None => parse_cpu_index(element).is_some(),
143        }
144    })
145}
146
147fn parse_cpu_index(value: &str) -> Option<u32> {
148    (!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
149        .then(|| value.parse().ok())
150        .flatten()
151}
152
153/// Build a `sh` command that writes `value` to cgroup v2 control file `file` in
154/// the container's per-container cgroup slice.
155///
156/// The resize exec runs in the guest root cgroup and this exec channel carries
157/// no container id, so the command resolves the slice at runtime: when there is
158/// exactly one `box-*` slice (every CLI box and single-container pod) it writes
159/// there. Otherwise it FAILS (exit 1) — it must never fall back to writing the
160/// bare root cgroup, which either errors (e.g. root has no `cpu.max`) or applies
161/// the limit to the whole root hierarchy instead of the container, while the CLI
162/// still reported success. A non-zero exit surfaces as a visible warning at the
163/// call site (container_update) instead of a silent mis-apply.
164fn cgroup_write_cmd(file: &str, value: &str) -> String {
165    format!(
166        "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}\""
167    )
168}
169
170/// Validate a resource update request.
171///
172/// Returns `Err` if Tier 1 changes are requested (not supported by libkrun).
173/// Returns `Ok(())` if only Tier 2 changes or no changes.
174pub fn validate_update(update: &ResourceUpdate) -> Result<()> {
175    if let Some(vcpus) = update.vcpus {
176        return Err(BoxError::ResizeError(format!(
177            "Cannot change vCPU count to {} on a running VM: libkrun does not support \
178             hot-plug vCPUs. Stop and recreate the box with the desired CPU count.",
179            vcpus
180        )));
181    }
182    if let Some(memory_mb) = update.memory_mb {
183        return Err(BoxError::ResizeError(format!(
184            "Cannot change memory to {}MB on a running VM: libkrun does not support \
185             memory ballooning. Stop and recreate the box with the desired memory size.",
186            memory_mb
187        )));
188    }
189    validate_update_values(update)
190}
191
192/// Validate resource values independently from whether they can be changed on
193/// a running VM.
194///
195/// Callers that persist limits for a stopped VM must still call this function:
196/// lifecycle state only controls hot-resize support, not input validity.
197pub fn validate_update_values(update: &ResourceUpdate) -> Result<()> {
198    // Reject a malformed cpuset before it can be persisted or interpolated into
199    // the resize shell command (cgroup `cpuset.cpus` accepts only indices/ranges).
200    if let Some(ref cpuset) = update.limits.cpuset_cpus {
201        if !is_valid_cpuset(cpuset) {
202            return Err(BoxError::ResizeError(format!(
203                "Invalid cpuset.cpus value {cpuset:?}: expected a comma-separated list of CPU \
204                 indices in the range 0..={} or ascending ranges such as \"0-3\" or \"0,2,4\".",
205                u32::MAX
206            )));
207        }
208    }
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_empty_update_has_no_changes() {
218        let update = ResourceUpdate::default();
219        assert!(!update.has_tier1_changes());
220        assert!(!update.has_tier2_changes());
221        assert!(update.build_cgroup_commands().is_empty());
222    }
223
224    #[test]
225    fn test_tier1_vcpus_detected() {
226        let update = ResourceUpdate {
227            vcpus: Some(4),
228            ..Default::default()
229        };
230        assert!(update.has_tier1_changes());
231        assert!(!update.has_tier2_changes());
232    }
233
234    #[test]
235    fn test_tier1_memory_detected() {
236        let update = ResourceUpdate {
237            memory_mb: Some(2048),
238            ..Default::default()
239        };
240        assert!(update.has_tier1_changes());
241    }
242
243    #[test]
244    fn test_validate_rejects_vcpu_change() {
245        let update = ResourceUpdate {
246            vcpus: Some(8),
247            ..Default::default()
248        };
249        let err = validate_update(&update).unwrap_err();
250        assert!(err.to_string().contains("vCPU count"));
251        assert!(err.to_string().contains("libkrun"));
252    }
253
254    #[test]
255    fn test_validate_rejects_memory_change() {
256        let update = ResourceUpdate {
257            memory_mb: Some(4096),
258            ..Default::default()
259        };
260        let err = validate_update(&update).unwrap_err();
261        assert!(err.to_string().contains("memory"));
262        assert!(err.to_string().contains("ballooning"));
263    }
264
265    #[test]
266    fn test_validate_allows_tier2_only() {
267        let update = ResourceUpdate {
268            limits: ResourceLimits {
269                cpu_shares: Some(512),
270                pids_limit: Some(100),
271                ..Default::default()
272            },
273            ..Default::default()
274        };
275        assert!(validate_update(&update).is_ok());
276    }
277
278    #[test]
279    fn test_cpu_max_command() {
280        let update = ResourceUpdate {
281            limits: ResourceLimits {
282                cpu_quota: Some(50000),
283                cpu_period: Some(100000),
284                ..Default::default()
285            },
286            ..Default::default()
287        };
288        let cmds = update.build_cgroup_commands();
289        assert_eq!(cmds.len(), 1);
290        assert!(cmds[0].contains("50000 100000"));
291        assert!(cmds[0].contains("cpu.max"));
292    }
293
294    #[test]
295    fn test_cpu_max_unlimited_quota() {
296        let update = ResourceUpdate {
297            limits: ResourceLimits {
298                cpu_quota: Some(-1),
299                ..Default::default()
300            },
301            ..Default::default()
302        };
303        let cmds = update.build_cgroup_commands();
304        assert_eq!(cmds.len(), 1);
305        assert!(cmds[0].contains("max 100000"));
306    }
307
308    #[test]
309    fn test_cpu_weight_conversion() {
310        let update = ResourceUpdate {
311            limits: ResourceLimits {
312                cpu_shares: Some(1024),
313                ..Default::default()
314            },
315            ..Default::default()
316        };
317        let cmds = update.build_cgroup_commands();
318        assert_eq!(cmds.len(), 1);
319        assert!(cmds[0].contains("cpu.weight"));
320    }
321
322    #[test]
323    fn test_cpu_weight_minimum() {
324        let update = ResourceUpdate {
325            limits: ResourceLimits {
326                cpu_shares: Some(2),
327                ..Default::default()
328            },
329            ..Default::default()
330        };
331        let cmds = update.build_cgroup_commands();
332        assert!(cmds[0].contains("'1'"));
333    }
334
335    #[test]
336    fn test_memory_reservation_command() {
337        let update = ResourceUpdate {
338            limits: ResourceLimits {
339                memory_reservation: Some(536870912), // 512MB
340                ..Default::default()
341            },
342            ..Default::default()
343        };
344        let cmds = update.build_cgroup_commands();
345        assert_eq!(cmds.len(), 1);
346        assert!(cmds[0].contains("536870912"));
347        assert!(cmds[0].contains("memory.low"));
348    }
349
350    #[test]
351    fn test_memory_swap_unlimited() {
352        let update = ResourceUpdate {
353            limits: ResourceLimits {
354                memory_swap: Some(-1),
355                ..Default::default()
356            },
357            ..Default::default()
358        };
359        let cmds = update.build_cgroup_commands();
360        assert!(cmds[0].contains("'max'"));
361        assert!(cmds[0].contains("memory.swap.max"));
362    }
363
364    #[test]
365    fn test_pids_max_command() {
366        let update = ResourceUpdate {
367            limits: ResourceLimits {
368                pids_limit: Some(256),
369                ..Default::default()
370            },
371            ..Default::default()
372        };
373        let cmds = update.build_cgroup_commands();
374        assert!(cmds[0].contains("256"));
375        assert!(cmds[0].contains("pids.max"));
376    }
377
378    #[test]
379    fn test_cpuset_command() {
380        let update = ResourceUpdate {
381            limits: ResourceLimits {
382                cpuset_cpus: Some("0,1,3".to_string()),
383                ..Default::default()
384            },
385            ..Default::default()
386        };
387        let cmds = update.build_cgroup_commands();
388        assert!(cmds[0].contains("0,1,3"));
389        assert!(cmds[0].contains("cpuset.cpus"));
390    }
391
392    #[test]
393    fn test_cpuset_valid_forms_accepted() {
394        for ok in ["0", "0,1,3", "0-3", "0-1,4-7", " 0 , 2 ", "4294967295"] {
395            assert!(is_valid_cpuset(ok), "{ok:?} should be valid");
396        }
397    }
398
399    #[test]
400    fn test_cpuset_injection_rejected() {
401        // Shell-injection payloads and other malformed values must be rejected so
402        // they never reach `echo '…'` in the resize command.
403        for bad in [
404            "",
405            "0'$(id >>/tmp/pwned)",
406            "0; rm -rf /",
407            "0`whoami`",
408            "0\nmalicious",
409            "all",
410            "0-",
411            "-3",
412            "3-1",
413            "4294967296",
414        ] {
415            assert!(!is_valid_cpuset(bad), "{bad:?} should be rejected");
416        }
417    }
418
419    #[test]
420    fn test_validate_rejects_malformed_cpuset() {
421        let update = ResourceUpdate {
422            limits: ResourceLimits {
423                cpuset_cpus: Some("0'$(id)".to_string()),
424                ..Default::default()
425            },
426            ..Default::default()
427        };
428        let err = validate_update(&update).unwrap_err();
429        assert!(err.to_string().contains("cpuset"));
430        // And the dangerous value never makes it into a shell command.
431        assert!(update.build_cgroup_commands().is_empty());
432    }
433
434    #[test]
435    fn test_value_validation_rejects_reversed_cpuset_without_hot_resize() {
436        let update = ResourceUpdate {
437            limits: ResourceLimits {
438                cpuset_cpus: Some("7-3".to_string()),
439                ..Default::default()
440            },
441            ..Default::default()
442        };
443
444        let err = validate_update_values(&update).unwrap_err();
445        assert!(err.to_string().contains("ascending ranges"));
446    }
447
448    #[test]
449    fn test_cpu_weight_clamped_for_oversized_shares() {
450        // Absurd shares must not overflow the `* 9999` nor exceed cgroup's max
451        // weight of 10000.
452        let update = ResourceUpdate {
453            limits: ResourceLimits {
454                cpu_shares: Some(u64::MAX),
455                ..Default::default()
456            },
457            ..Default::default()
458        };
459        let cmds = update.build_cgroup_commands();
460        assert!(cmds[0].contains("'10000'"), "got {}", cmds[0]);
461    }
462
463    #[test]
464    fn test_multiple_tier2_commands() {
465        let update = ResourceUpdate {
466            limits: ResourceLimits {
467                cpu_shares: Some(512),
468                pids_limit: Some(100),
469                memory_reservation: Some(268435456),
470                ..Default::default()
471            },
472            ..Default::default()
473        };
474        let cmds = update.build_cgroup_commands();
475        assert_eq!(cmds.len(), 3);
476    }
477
478    #[test]
479    fn test_cgroup_commands_target_per_container_slice() {
480        let update = ResourceUpdate {
481            limits: ResourceLimits {
482                pids_limit: Some(50),
483                ..Default::default()
484            },
485            ..Default::default()
486        };
487        let cmds = update.build_cgroup_commands();
488        assert_eq!(cmds.len(), 1);
489        // Must resolve the per-container `box-*` slice, not write a bare root path.
490        assert!(cmds[0].contains("/sys/fs/cgroup/box-*"), "got {}", cmds[0]);
491        assert!(cmds[0].contains("pids.max"));
492        assert!(cmds[0].contains("'50'"));
493    }
494
495    #[test]
496    fn test_cgroup_command_fails_instead_of_writing_root() {
497        // When the per-container slice can't be uniquely resolved the command
498        // must exit non-zero (surfacing a warning at the call site), NOT fall
499        // back to writing the bare root cgroup, which mis-applies the limit to
500        // the whole hierarchy while the CLI reports success.
501        let update = ResourceUpdate {
502            limits: ResourceLimits {
503                cpu_quota: Some(50_000),
504                cpu_period: Some(100_000),
505                ..Default::default()
506            },
507            ..Default::default()
508        };
509        let cmds = update.build_cgroup_commands();
510        assert_eq!(cmds.len(), 1);
511        assert!(cmds[0].contains("exit 1"), "must fail loudly: {}", cmds[0]);
512        // The dangerous root-cgroup fallback must be gone: no assignment that
513        // points the write target `d` at the bare root.
514        assert!(
515            !cmds[0].contains("d=\"/sys/fs/cgroup/\""),
516            "must not fall back to the root cgroup: {}",
517            cmds[0]
518        );
519    }
520
521    #[test]
522    fn test_resize_result_structure() {
523        let result = ResizeResult {
524            applied: vec!["cpu.weight".to_string()],
525            rejected: vec![("vcpus".to_string(), "not supported".to_string())],
526        };
527        assert_eq!(result.applied.len(), 1);
528        assert_eq!(result.rejected.len(), 1);
529    }
530}