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)) => {
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
150fn 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
167pub 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 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), ..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 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 assert!(update.build_cgroup_commands().is_empty());
417 }
418
419 #[test]
420 fn test_cpu_weight_clamped_for_oversized_shares() {
421 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 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 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 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}