1use a3s_box_core::config::{BoxConfig, ResourceLimits};
12use a3s_box_core::error::{BoxError, Result};
13
14#[derive(Debug, Clone, Default)]
18pub struct ResourceUpdate {
19 pub vcpus: Option<u32>,
21 pub memory_mb: Option<u32>,
23 pub limits: ResourceLimits,
25}
26
27#[derive(Debug)]
29pub struct ResizeResult {
30 pub applied: Vec<String>,
32 pub rejected: Vec<(String, String)>,
34}
35
36impl ResourceUpdate {
37 pub fn has_tier1_changes(&self) -> bool {
39 self.vcpus.is_some() || self.memory_mb.is_some()
40 }
41
42 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 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 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 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 pub fn build_microvm_cgroup_commands(&self) -> Vec<String> {
128 let mut cmds = Vec::new();
129
130 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 if let Some(shares) = self.limits.cpu_shares {
149 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 if let Some(reservation) = self.limits.memory_reservation {
161 cmds.push(cgroup_write_cmd("memory.low", &reservation.to_string()));
162 }
163
164 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 if let Some(pids) = self.limits.pids_limit {
176 cmds.push(cgroup_write_cmd("pids.max", &pids.to_string()));
177 }
178
179 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
196fn 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
222fn 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
239pub 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
261pub fn validate_update_values(update: &ResourceUpdate) -> Result<()> {
267 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), ..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 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 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 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 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 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 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}