Skip to main content

a3s_box_runtime/
resize.rs

1//! Live resource resize for running Box backends.
2//!
3//! Tier 1 (provisioned vCPU count and memory size) is immutable for a running
4//! Box. MicroVMs cannot hot-resize these libkrun settings, and the public Box
5//! lifecycle keeps the same stop/recreate contract across backends.
6//!
7//! Tier 2 (cgroup-based limits): MicroVMs write the guest workload cgroup via
8//! the exec channel. Host Sandboxes send one complete resource contract through
9//! the exact-generation A3S OCI SDK.
10
11use a3s_box_core::config::{BoxConfig, ResourceLimits};
12use a3s_box_core::error::{BoxError, Result};
13
14/// A resource update request.
15///
16/// Fields set to `None` are left unchanged.
17#[derive(Debug, Clone, Default)]
18pub struct ResourceUpdate {
19    /// vCPU count change (Tier 1 — will be rejected).
20    pub vcpus: Option<u32>,
21    /// Memory in MiB change (Tier 1 — will be rejected).
22    pub memory_mb: Option<u32>,
23    /// Cgroup-based limits (Tier 2 — applied by the selected backend).
24    pub limits: ResourceLimits,
25}
26
27/// Result of a resize attempt.
28#[derive(Debug)]
29pub struct ResizeResult {
30    /// Fields that were successfully applied.
31    pub applied: Vec<String>,
32    /// Fields that were rejected with reasons.
33    pub rejected: Vec<(String, String)>,
34}
35
36impl ResourceUpdate {
37    /// Check if any Tier 1 (immutable) fields are requested.
38    pub fn has_tier1_changes(&self) -> bool {
39        self.vcpus.is_some() || self.memory_mb.is_some()
40    }
41
42    /// Check if any Tier 2 (cgroup) fields are requested.
43    pub fn has_tier2_changes(&self) -> bool {
44        self.limits.cpu_shares.is_some()
45            || self.limits.cpu_quota.is_some()
46            || self.limits.cpu_period.is_some()
47            || self.limits.memory_reservation.is_some()
48            || self.limits.memory_swap.is_some()
49            || self.limits.pids_limit.is_some()
50            || self.limits.cpuset_cpus.is_some()
51    }
52
53    /// Merge every explicitly requested value into a complete Box config.
54    ///
55    /// Sandbox live updates use the resulting full snapshot because the A3S
56    /// OCI update contract replaces `linux.resources` atomically rather than
57    /// applying a sequence of partial cgroup writes.
58    pub fn apply_to_config(&self, config: &mut BoxConfig) {
59        if let Some(vcpus) = self.vcpus {
60            config.resources.vcpus = vcpus;
61        }
62        if let Some(memory_mb) = self.memory_mb {
63            config.resources.memory_mb = memory_mb;
64        }
65        self.apply_to_limits(&mut config.resource_limits);
66    }
67
68    /// Merge every explicitly requested Tier 2 value into existing limits.
69    pub fn apply_to_limits(&self, limits: &mut ResourceLimits) {
70        if let Some(value) = self.limits.memory_reservation {
71            limits.memory_reservation = Some(value);
72        }
73        if let Some(value) = self.limits.memory_swap {
74            limits.memory_swap = Some(value);
75        }
76        if let Some(value) = self.limits.pids_limit {
77            limits.pids_limit = Some(value);
78        }
79        if let Some(value) = self.limits.cpu_shares {
80            limits.cpu_shares = Some(value);
81        }
82        if let Some(value) = self.limits.cpu_quota {
83            limits.cpu_quota = Some(value);
84        }
85        if let Some(value) = self.limits.cpu_period {
86            limits.cpu_period = Some(value);
87        }
88        if let Some(value) = self.limits.cpuset_cpus.as_ref() {
89            limits.cpuset_cpus = Some(value.clone());
90        }
91    }
92
93    /// Stable names for Tier 2 fields carried by this request.
94    pub fn tier2_change_names(&self) -> Vec<&'static str> {
95        let mut names = Vec::new();
96        if self.limits.memory_reservation.is_some() {
97            names.push("memory_reservation");
98        }
99        if self.limits.memory_swap.is_some() {
100            names.push("memory_swap");
101        }
102        if self.limits.pids_limit.is_some() {
103            names.push("pids_limit");
104        }
105        if self.limits.cpu_shares.is_some() {
106            names.push("cpu_shares");
107        }
108        if self.limits.cpu_quota.is_some() {
109            names.push("cpu_quota");
110        }
111        if self.limits.cpu_period.is_some() {
112            names.push("cpu_period");
113        }
114        if self.limits.cpuset_cpus.is_some() {
115            names.push("cpuset_cpus");
116        }
117        names
118    }
119
120    /// Build shell commands to apply Tier 2 cgroup changes inside a MicroVM guest.
121    ///
122    /// Host Sandbox updates must use the A3S OCI SDK and never call this method.
123    /// For a MicroVM, the resize exec runs in the guest root cgroup, so each
124    /// command resolves the per-container `box-<pid>-<seq>` slice at runtime.
125    /// A bare `/sys/fs/cgroup/<file>` write would hit the root cgroup and silently
126    /// leave the container's limits unchanged.
127    pub fn build_microvm_cgroup_commands(&self) -> Vec<String> {
128        let mut cmds = Vec::new();
129
130        // cpu.max: "$QUOTA $PERIOD" (or "max $PERIOD" for unlimited)
131        if self.limits.cpu_quota.is_some() || self.limits.cpu_period.is_some() {
132            let quota = self
133                .limits
134                .cpu_quota
135                .map(|q| {
136                    if q < 0 {
137                        "max".to_string()
138                    } else {
139                        q.to_string()
140                    }
141                })
142                .unwrap_or_else(|| "max".to_string());
143            let period = self.limits.cpu_period.unwrap_or(100_000);
144            cmds.push(cgroup_write_cmd("cpu.max", &format!("{quota} {period}")));
145        }
146
147        // cpu.weight: 1-10000 (maps from Docker's cpu-shares 2-262144)
148        if let Some(shares) = self.limits.cpu_shares {
149            // Docker shares (2-262144) → cgroup v2 weight (1-10000), runc's
150            // mapping. Clamp shares into range FIRST (so the `* 9999` cannot
151            // overflow for absurd inputs near u64::MAX) and clamp the final
152            // result to [1, 10000] (the bare `1 + …` can reach 10001). Mirrors
153            // the guest `cgroup::shares_to_weight`.
154            let shares = shares.clamp(2, 262_144);
155            let weight = (1 + ((shares - 2) * 9999) / 262_142).clamp(1, 10_000);
156            cmds.push(cgroup_write_cmd("cpu.weight", &weight.to_string()));
157        }
158
159        // memory.low (soft limit / reservation)
160        if let Some(reservation) = self.limits.memory_reservation {
161            cmds.push(cgroup_write_cmd("memory.low", &reservation.to_string()));
162        }
163
164        // memory.swap.max
165        if let Some(swap) = self.limits.memory_swap {
166            let val = if swap < 0 {
167                "max".to_string()
168            } else {
169                swap.to_string()
170            };
171            cmds.push(cgroup_write_cmd("memory.swap.max", &val));
172        }
173
174        // pids.max
175        if let Some(pids) = self.limits.pids_limit {
176            cmds.push(cgroup_write_cmd("pids.max", &pids.to_string()));
177        }
178
179        // cpuset.cpus — only emit a known-good value. `validate_update` already
180        // rejects malformed cpusets, but guard here too since the value is
181        // interpolated into the resize shell command: a stray quote/`$`/`;`
182        // could otherwise break out of `echo '…'` and run arbitrary shell in the
183        // guest.
184        if let Some(ref cpuset) = self.limits.cpuset_cpus {
185            if is_valid_cpuset(cpuset) {
186                cmds.push(cgroup_write_cmd("cpuset.cpus", cpuset));
187            } else {
188                tracing::warn!(cpuset = %cpuset, "Skipping malformed cpuset.cpus value");
189            }
190        }
191
192        cmds
193    }
194}
195
196/// Validate a cgroup `cpuset.cpus` value: a comma-separated list of CPU indices
197/// and ranges, e.g. `0`, `0,2,4`, `0-3`, `0-1,4-7`. Only ASCII digits, `,` and
198/// `-` are allowed, so no shell metacharacter can survive — the kernel rejects
199/// anything else anyway. Surrounding whitespace per element is tolerated.
200fn is_valid_cpuset(cpuset: &str) -> bool {
201    let cpuset = cpuset.trim();
202    if cpuset.is_empty() {
203        return false;
204    }
205    cpuset.split(',').all(|element| {
206        let element = element.trim();
207        match element.split_once('-') {
208            Some((lo, hi)) => parse_cpu_index(lo)
209                .zip(parse_cpu_index(hi))
210                .is_some_and(|(lo, hi)| lo <= hi),
211            None => parse_cpu_index(element).is_some(),
212        }
213    })
214}
215
216fn parse_cpu_index(value: &str) -> Option<u32> {
217    (!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
218        .then(|| value.parse().ok())
219        .flatten()
220}
221
222/// Build a `sh` command that writes `value` to cgroup v2 control file `file` in
223/// the container's per-container cgroup slice.
224///
225/// The resize exec runs in the guest root cgroup and this exec channel carries
226/// no container id, so the command resolves the slice at runtime: when there is
227/// exactly one `box-*` slice (every CLI box and single-container pod) it writes
228/// there. Otherwise it FAILS (exit 1) — it must never fall back to writing the
229/// bare root cgroup, which either errors (e.g. root has no `cpu.max`) or applies
230/// the limit to the whole root hierarchy instead of the container, while the CLI
231/// still reported success. A non-zero exit surfaces as a visible warning at the
232/// call site (container_update) instead of a silent mis-apply.
233fn cgroup_write_cmd(file: &str, value: &str) -> String {
234    format!(
235        "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}\""
236    )
237}
238
239/// Validate a resource update request.
240///
241/// Returns `Err` if immutable Tier 1 provisioning changes are requested.
242/// Returns `Ok(())` if only Tier 2 changes or no changes.
243pub fn validate_update(update: &ResourceUpdate) -> Result<()> {
244    if let Some(vcpus) = update.vcpus {
245        return Err(BoxError::ResizeError(format!(
246            "Cannot change provisioned vCPU count to {} on a running Box. Stop and recreate \
247             the Box with the desired CPU count.",
248            vcpus
249        )));
250    }
251    if let Some(memory_mb) = update.memory_mb {
252        return Err(BoxError::ResizeError(format!(
253            "Cannot change provisioned memory to {}MB on a running Box. Stop and recreate \
254             the Box with the desired memory size.",
255            memory_mb
256        )));
257    }
258    validate_update_values(update)
259}
260
261/// Validate resource values independently from whether they can be changed on
262/// a running Box.
263///
264/// Callers that persist limits for a stopped Box must still call this function:
265/// lifecycle state only controls hot-resize support, not input validity.
266pub fn validate_update_values(update: &ResourceUpdate) -> Result<()> {
267    // Reject a malformed cpuset before it can be persisted or interpolated into
268    // the resize shell command (cgroup `cpuset.cpus` accepts only indices/ranges).
269    if let Some(ref cpuset) = update.limits.cpuset_cpus {
270        if !is_valid_cpuset(cpuset) {
271            return Err(BoxError::ResizeError(format!(
272                "Invalid cpuset.cpus value {cpuset:?}: expected a comma-separated list of CPU \
273                 indices in the range 0..={} or ascending ranges such as \"0-3\" or \"0,2,4\".",
274                u32::MAX
275            )));
276        }
277    }
278    Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_empty_update_has_no_changes() {
287        let update = ResourceUpdate::default();
288        assert!(!update.has_tier1_changes());
289        assert!(!update.has_tier2_changes());
290        assert!(update.build_microvm_cgroup_commands().is_empty());
291        assert!(update.tier2_change_names().is_empty());
292    }
293
294    #[test]
295    fn update_merges_only_explicit_values_into_a_complete_config() {
296        let mut config = BoxConfig::default();
297        config.resources.vcpus = 2;
298        config.resources.memory_mb = 512;
299        config.resource_limits.cpu_quota = Some(20_000);
300        config.resource_limits.pids_limit = Some(64);
301        let update = ResourceUpdate {
302            limits: ResourceLimits {
303                cpu_shares: Some(512),
304                pids_limit: Some(96),
305                ..Default::default()
306            },
307            ..Default::default()
308        };
309
310        update.apply_to_config(&mut config);
311
312        assert_eq!(config.resources.vcpus, 2);
313        assert_eq!(config.resources.memory_mb, 512);
314        assert_eq!(config.resource_limits.cpu_quota, Some(20_000));
315        assert_eq!(config.resource_limits.cpu_shares, Some(512));
316        assert_eq!(config.resource_limits.pids_limit, Some(96));
317        assert_eq!(update.tier2_change_names(), ["pids_limit", "cpu_shares"]);
318    }
319
320    #[test]
321    fn test_tier1_vcpus_detected() {
322        let update = ResourceUpdate {
323            vcpus: Some(4),
324            ..Default::default()
325        };
326        assert!(update.has_tier1_changes());
327        assert!(!update.has_tier2_changes());
328    }
329
330    #[test]
331    fn test_tier1_memory_detected() {
332        let update = ResourceUpdate {
333            memory_mb: Some(2048),
334            ..Default::default()
335        };
336        assert!(update.has_tier1_changes());
337    }
338
339    #[test]
340    fn test_validate_rejects_vcpu_change() {
341        let update = ResourceUpdate {
342            vcpus: Some(8),
343            ..Default::default()
344        };
345        let err = validate_update(&update).unwrap_err();
346        assert!(err.to_string().contains("vCPU count"));
347        assert!(err.to_string().contains("running Box"));
348        assert!(err.to_string().contains("Stop and recreate"));
349    }
350
351    #[test]
352    fn test_validate_rejects_memory_change() {
353        let update = ResourceUpdate {
354            memory_mb: Some(4096),
355            ..Default::default()
356        };
357        let err = validate_update(&update).unwrap_err();
358        assert!(err.to_string().contains("memory"));
359        assert!(err.to_string().contains("running Box"));
360        assert!(err.to_string().contains("Stop and recreate"));
361    }
362
363    #[test]
364    fn test_validate_allows_tier2_only() {
365        let update = ResourceUpdate {
366            limits: ResourceLimits {
367                cpu_shares: Some(512),
368                pids_limit: Some(100),
369                ..Default::default()
370            },
371            ..Default::default()
372        };
373        assert!(validate_update(&update).is_ok());
374    }
375
376    #[test]
377    fn test_cpu_max_command() {
378        let update = ResourceUpdate {
379            limits: ResourceLimits {
380                cpu_quota: Some(50000),
381                cpu_period: Some(100000),
382                ..Default::default()
383            },
384            ..Default::default()
385        };
386        let cmds = update.build_microvm_cgroup_commands();
387        assert_eq!(cmds.len(), 1);
388        assert!(cmds[0].contains("50000 100000"));
389        assert!(cmds[0].contains("cpu.max"));
390    }
391
392    #[test]
393    fn test_cpu_max_unlimited_quota() {
394        let update = ResourceUpdate {
395            limits: ResourceLimits {
396                cpu_quota: Some(-1),
397                ..Default::default()
398            },
399            ..Default::default()
400        };
401        let cmds = update.build_microvm_cgroup_commands();
402        assert_eq!(cmds.len(), 1);
403        assert!(cmds[0].contains("max 100000"));
404    }
405
406    #[test]
407    fn test_cpu_weight_conversion() {
408        let update = ResourceUpdate {
409            limits: ResourceLimits {
410                cpu_shares: Some(1024),
411                ..Default::default()
412            },
413            ..Default::default()
414        };
415        let cmds = update.build_microvm_cgroup_commands();
416        assert_eq!(cmds.len(), 1);
417        assert!(cmds[0].contains("cpu.weight"));
418    }
419
420    #[test]
421    fn test_cpu_weight_minimum() {
422        let update = ResourceUpdate {
423            limits: ResourceLimits {
424                cpu_shares: Some(2),
425                ..Default::default()
426            },
427            ..Default::default()
428        };
429        let cmds = update.build_microvm_cgroup_commands();
430        assert!(cmds[0].contains("'1'"));
431    }
432
433    #[test]
434    fn test_memory_reservation_command() {
435        let update = ResourceUpdate {
436            limits: ResourceLimits {
437                memory_reservation: Some(536870912), // 512MB
438                ..Default::default()
439            },
440            ..Default::default()
441        };
442        let cmds = update.build_microvm_cgroup_commands();
443        assert_eq!(cmds.len(), 1);
444        assert!(cmds[0].contains("536870912"));
445        assert!(cmds[0].contains("memory.low"));
446    }
447
448    #[test]
449    fn test_memory_swap_unlimited() {
450        let update = ResourceUpdate {
451            limits: ResourceLimits {
452                memory_swap: Some(-1),
453                ..Default::default()
454            },
455            ..Default::default()
456        };
457        let cmds = update.build_microvm_cgroup_commands();
458        assert!(cmds[0].contains("'max'"));
459        assert!(cmds[0].contains("memory.swap.max"));
460    }
461
462    #[test]
463    fn test_pids_max_command() {
464        let update = ResourceUpdate {
465            limits: ResourceLimits {
466                pids_limit: Some(256),
467                ..Default::default()
468            },
469            ..Default::default()
470        };
471        let cmds = update.build_microvm_cgroup_commands();
472        assert!(cmds[0].contains("256"));
473        assert!(cmds[0].contains("pids.max"));
474    }
475
476    #[test]
477    fn test_cpuset_command() {
478        let update = ResourceUpdate {
479            limits: ResourceLimits {
480                cpuset_cpus: Some("0,1,3".to_string()),
481                ..Default::default()
482            },
483            ..Default::default()
484        };
485        let cmds = update.build_microvm_cgroup_commands();
486        assert!(cmds[0].contains("0,1,3"));
487        assert!(cmds[0].contains("cpuset.cpus"));
488    }
489
490    #[test]
491    fn test_cpuset_valid_forms_accepted() {
492        for ok in ["0", "0,1,3", "0-3", "0-1,4-7", " 0 , 2 ", "4294967295"] {
493            assert!(is_valid_cpuset(ok), "{ok:?} should be valid");
494        }
495    }
496
497    #[test]
498    fn test_cpuset_injection_rejected() {
499        // Shell-injection payloads and other malformed values must be rejected so
500        // they never reach `echo '…'` in the resize command.
501        for bad in [
502            "",
503            "0'$(id >>/tmp/pwned)",
504            "0; rm -rf /",
505            "0`whoami`",
506            "0\nmalicious",
507            "all",
508            "0-",
509            "-3",
510            "3-1",
511            "4294967296",
512        ] {
513            assert!(!is_valid_cpuset(bad), "{bad:?} should be rejected");
514        }
515    }
516
517    #[test]
518    fn test_validate_rejects_malformed_cpuset() {
519        let update = ResourceUpdate {
520            limits: ResourceLimits {
521                cpuset_cpus: Some("0'$(id)".to_string()),
522                ..Default::default()
523            },
524            ..Default::default()
525        };
526        let err = validate_update(&update).unwrap_err();
527        assert!(err.to_string().contains("cpuset"));
528        // And the dangerous value never makes it into a shell command.
529        assert!(update.build_microvm_cgroup_commands().is_empty());
530    }
531
532    #[test]
533    fn test_value_validation_rejects_reversed_cpuset_without_hot_resize() {
534        let update = ResourceUpdate {
535            limits: ResourceLimits {
536                cpuset_cpus: Some("7-3".to_string()),
537                ..Default::default()
538            },
539            ..Default::default()
540        };
541
542        let err = validate_update_values(&update).unwrap_err();
543        assert!(err.to_string().contains("ascending ranges"));
544    }
545
546    #[test]
547    fn test_cpu_weight_clamped_for_oversized_shares() {
548        // Absurd shares must not overflow the `* 9999` nor exceed cgroup's max
549        // weight of 10000.
550        let update = ResourceUpdate {
551            limits: ResourceLimits {
552                cpu_shares: Some(u64::MAX),
553                ..Default::default()
554            },
555            ..Default::default()
556        };
557        let cmds = update.build_microvm_cgroup_commands();
558        assert!(cmds[0].contains("'10000'"), "got {}", cmds[0]);
559    }
560
561    #[test]
562    fn test_multiple_tier2_commands() {
563        let update = ResourceUpdate {
564            limits: ResourceLimits {
565                cpu_shares: Some(512),
566                pids_limit: Some(100),
567                memory_reservation: Some(268435456),
568                ..Default::default()
569            },
570            ..Default::default()
571        };
572        let cmds = update.build_microvm_cgroup_commands();
573        assert_eq!(cmds.len(), 3);
574    }
575
576    #[test]
577    fn test_cgroup_commands_target_per_container_slice() {
578        let update = ResourceUpdate {
579            limits: ResourceLimits {
580                pids_limit: Some(50),
581                ..Default::default()
582            },
583            ..Default::default()
584        };
585        let cmds = update.build_microvm_cgroup_commands();
586        assert_eq!(cmds.len(), 1);
587        // Must resolve the per-container `box-*` slice, not write a bare root path.
588        assert!(cmds[0].contains("/sys/fs/cgroup/box-*"), "got {}", cmds[0]);
589        assert!(cmds[0].contains("pids.max"));
590        assert!(cmds[0].contains("'50'"));
591    }
592
593    #[test]
594    fn test_cgroup_command_fails_instead_of_writing_root() {
595        // When the per-container slice can't be uniquely resolved the command
596        // must exit non-zero (surfacing a warning at the call site), NOT fall
597        // back to writing the bare root cgroup, which mis-applies the limit to
598        // the whole hierarchy while the CLI reports success.
599        let update = ResourceUpdate {
600            limits: ResourceLimits {
601                cpu_quota: Some(50_000),
602                cpu_period: Some(100_000),
603                ..Default::default()
604            },
605            ..Default::default()
606        };
607        let cmds = update.build_microvm_cgroup_commands();
608        assert_eq!(cmds.len(), 1);
609        assert!(cmds[0].contains("exit 1"), "must fail loudly: {}", cmds[0]);
610        // The dangerous root-cgroup fallback must be gone: no assignment that
611        // points the write target `d` at the bare root.
612        assert!(
613            !cmds[0].contains("d=\"/sys/fs/cgroup/\""),
614            "must not fall back to the root cgroup: {}",
615            cmds[0]
616        );
617    }
618
619    #[test]
620    fn test_resize_result_structure() {
621        let result = ResizeResult {
622            applied: vec!["cpu.weight".to_string()],
623            rejected: vec![("vcpus".to_string(), "not supported".to_string())],
624        };
625        assert_eq!(result.applied.len(), 1);
626        assert_eq!(result.rejected.len(), 1);
627    }
628}