1use a3s_box_core::config::ResourceLimits;
10use a3s_box_core::error::{BoxError, Result};
11
12#[derive(Debug, Clone, Default)]
16pub struct ResourceUpdate {
17 pub vcpus: Option<u32>,
19 pub memory_mb: Option<u32>,
21 pub limits: ResourceLimits,
23}
24
25#[derive(Debug)]
27pub struct ResizeResult {
28 pub applied: Vec<String>,
30 pub rejected: Vec<(String, String)>,
32}
33
34impl ResourceUpdate {
35 pub fn has_tier1_changes(&self) -> bool {
37 self.vcpus.is_some() || self.memory_mb.is_some()
38 }
39
40 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 pub fn build_cgroup_commands(&self) -> Vec<String> {
59 let mut cmds = Vec::new();
60
61 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 if let Some(shares) = self.limits.cpu_shares {
80 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 if let Some(reservation) = self.limits.memory_reservation {
92 cmds.push(cgroup_write_cmd("memory.low", &reservation.to_string()));
93 }
94
95 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 if let Some(pids) = self.limits.pids_limit {
107 cmds.push(cgroup_write_cmd("pids.max", &pids.to_string()));
108 }
109
110 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
127fn 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
153fn 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
170pub 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
192pub fn validate_update_values(update: &ResourceUpdate) -> Result<()> {
198 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), ..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 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 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 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 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 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 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}