use a3s_box_core::config::ResourceLimits;
use a3s_box_core::error::{BoxError, Result};
#[derive(Debug, Clone, Default)]
pub struct ResourceUpdate {
pub vcpus: Option<u32>,
pub memory_mb: Option<u32>,
pub limits: ResourceLimits,
}
#[derive(Debug)]
pub struct ResizeResult {
pub applied: Vec<String>,
pub rejected: Vec<(String, String)>,
}
impl ResourceUpdate {
pub fn has_tier1_changes(&self) -> bool {
self.vcpus.is_some() || self.memory_mb.is_some()
}
pub fn has_tier2_changes(&self) -> bool {
self.limits.cpu_shares.is_some()
|| self.limits.cpu_quota.is_some()
|| self.limits.cpu_period.is_some()
|| self.limits.memory_reservation.is_some()
|| self.limits.memory_swap.is_some()
|| self.limits.pids_limit.is_some()
|| self.limits.cpuset_cpus.is_some()
}
pub fn build_cgroup_commands(&self) -> Vec<String> {
let mut cmds = Vec::new();
if self.limits.cpu_quota.is_some() || self.limits.cpu_period.is_some() {
let quota = self
.limits
.cpu_quota
.map(|q| {
if q < 0 {
"max".to_string()
} else {
q.to_string()
}
})
.unwrap_or_else(|| "max".to_string());
let period = self.limits.cpu_period.unwrap_or(100_000);
cmds.push(cgroup_write_cmd("cpu.max", &format!("{quota} {period}")));
}
if let Some(shares) = self.limits.cpu_shares {
let shares = shares.clamp(2, 262_144);
let weight = (1 + ((shares - 2) * 9999) / 262_142).clamp(1, 10_000);
cmds.push(cgroup_write_cmd("cpu.weight", &weight.to_string()));
}
if let Some(reservation) = self.limits.memory_reservation {
cmds.push(cgroup_write_cmd("memory.low", &reservation.to_string()));
}
if let Some(swap) = self.limits.memory_swap {
let val = if swap < 0 {
"max".to_string()
} else {
swap.to_string()
};
cmds.push(cgroup_write_cmd("memory.swap.max", &val));
}
if let Some(pids) = self.limits.pids_limit {
cmds.push(cgroup_write_cmd("pids.max", &pids.to_string()));
}
if let Some(ref cpuset) = self.limits.cpuset_cpus {
if is_valid_cpuset(cpuset) {
cmds.push(cgroup_write_cmd("cpuset.cpus", cpuset));
} else {
tracing::warn!(cpuset = %cpuset, "Skipping malformed cpuset.cpus value");
}
}
cmds
}
}
fn is_valid_cpuset(cpuset: &str) -> bool {
let cpuset = cpuset.trim();
if cpuset.is_empty() {
return false;
}
cpuset.split(',').all(|element| {
let element = element.trim();
match element.split_once('-') {
Some((lo, hi)) => {
!lo.is_empty()
&& !hi.is_empty()
&& lo.bytes().all(|b| b.is_ascii_digit())
&& hi.bytes().all(|b| b.is_ascii_digit())
}
None => !element.is_empty() && element.bytes().all(|b| b.is_ascii_digit()),
}
})
}
fn cgroup_write_cmd(file: &str, value: &str) -> String {
format!(
"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}\""
)
}
pub fn validate_update(update: &ResourceUpdate) -> Result<()> {
if let Some(vcpus) = update.vcpus {
return Err(BoxError::ResizeError(format!(
"Cannot change vCPU count to {} on a running VM: libkrun does not support \
hot-plug vCPUs. Stop and recreate the box with the desired CPU count.",
vcpus
)));
}
if let Some(memory_mb) = update.memory_mb {
return Err(BoxError::ResizeError(format!(
"Cannot change memory to {}MB on a running VM: libkrun does not support \
memory ballooning. Stop and recreate the box with the desired memory size.",
memory_mb
)));
}
if let Some(ref cpuset) = update.limits.cpuset_cpus {
if !is_valid_cpuset(cpuset) {
return Err(BoxError::ResizeError(format!(
"Invalid cpuset.cpus value {cpuset:?}: expected a comma-separated list of CPU \
indices/ranges such as \"0-3\" or \"0,2,4\"."
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_update_has_no_changes() {
let update = ResourceUpdate::default();
assert!(!update.has_tier1_changes());
assert!(!update.has_tier2_changes());
assert!(update.build_cgroup_commands().is_empty());
}
#[test]
fn test_tier1_vcpus_detected() {
let update = ResourceUpdate {
vcpus: Some(4),
..Default::default()
};
assert!(update.has_tier1_changes());
assert!(!update.has_tier2_changes());
}
#[test]
fn test_tier1_memory_detected() {
let update = ResourceUpdate {
memory_mb: Some(2048),
..Default::default()
};
assert!(update.has_tier1_changes());
}
#[test]
fn test_validate_rejects_vcpu_change() {
let update = ResourceUpdate {
vcpus: Some(8),
..Default::default()
};
let err = validate_update(&update).unwrap_err();
assert!(err.to_string().contains("vCPU count"));
assert!(err.to_string().contains("libkrun"));
}
#[test]
fn test_validate_rejects_memory_change() {
let update = ResourceUpdate {
memory_mb: Some(4096),
..Default::default()
};
let err = validate_update(&update).unwrap_err();
assert!(err.to_string().contains("memory"));
assert!(err.to_string().contains("ballooning"));
}
#[test]
fn test_validate_allows_tier2_only() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpu_shares: Some(512),
pids_limit: Some(100),
..Default::default()
},
..Default::default()
};
assert!(validate_update(&update).is_ok());
}
#[test]
fn test_cpu_max_command() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpu_quota: Some(50000),
cpu_period: Some(100000),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert_eq!(cmds.len(), 1);
assert!(cmds[0].contains("50000 100000"));
assert!(cmds[0].contains("cpu.max"));
}
#[test]
fn test_cpu_max_unlimited_quota() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpu_quota: Some(-1),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert_eq!(cmds.len(), 1);
assert!(cmds[0].contains("max 100000"));
}
#[test]
fn test_cpu_weight_conversion() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpu_shares: Some(1024),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert_eq!(cmds.len(), 1);
assert!(cmds[0].contains("cpu.weight"));
}
#[test]
fn test_cpu_weight_minimum() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpu_shares: Some(2),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert!(cmds[0].contains("'1'"));
}
#[test]
fn test_memory_reservation_command() {
let update = ResourceUpdate {
limits: ResourceLimits {
memory_reservation: Some(536870912), ..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert_eq!(cmds.len(), 1);
assert!(cmds[0].contains("536870912"));
assert!(cmds[0].contains("memory.low"));
}
#[test]
fn test_memory_swap_unlimited() {
let update = ResourceUpdate {
limits: ResourceLimits {
memory_swap: Some(-1),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert!(cmds[0].contains("'max'"));
assert!(cmds[0].contains("memory.swap.max"));
}
#[test]
fn test_pids_max_command() {
let update = ResourceUpdate {
limits: ResourceLimits {
pids_limit: Some(256),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert!(cmds[0].contains("256"));
assert!(cmds[0].contains("pids.max"));
}
#[test]
fn test_cpuset_command() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpuset_cpus: Some("0,1,3".to_string()),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert!(cmds[0].contains("0,1,3"));
assert!(cmds[0].contains("cpuset.cpus"));
}
#[test]
fn test_cpuset_valid_forms_accepted() {
for ok in ["0", "0,1,3", "0-3", "0-1,4-7", " 0 , 2 "] {
assert!(is_valid_cpuset(ok), "{ok:?} should be valid");
}
}
#[test]
fn test_cpuset_injection_rejected() {
for bad in [
"",
"0'$(id >>/tmp/pwned)",
"0; rm -rf /",
"0`whoami`",
"0\nmalicious",
"all",
"0-",
"-3",
] {
assert!(!is_valid_cpuset(bad), "{bad:?} should be rejected");
}
}
#[test]
fn test_validate_rejects_malformed_cpuset() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpuset_cpus: Some("0'$(id)".to_string()),
..Default::default()
},
..Default::default()
};
let err = validate_update(&update).unwrap_err();
assert!(err.to_string().contains("cpuset"));
assert!(update.build_cgroup_commands().is_empty());
}
#[test]
fn test_cpu_weight_clamped_for_oversized_shares() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpu_shares: Some(u64::MAX),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert!(cmds[0].contains("'10000'"), "got {}", cmds[0]);
}
#[test]
fn test_multiple_tier2_commands() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpu_shares: Some(512),
pids_limit: Some(100),
memory_reservation: Some(268435456),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert_eq!(cmds.len(), 3);
}
#[test]
fn test_cgroup_commands_target_per_container_slice() {
let update = ResourceUpdate {
limits: ResourceLimits {
pids_limit: Some(50),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert_eq!(cmds.len(), 1);
assert!(cmds[0].contains("/sys/fs/cgroup/box-*"), "got {}", cmds[0]);
assert!(cmds[0].contains("pids.max"));
assert!(cmds[0].contains("'50'"));
}
#[test]
fn test_cgroup_command_fails_instead_of_writing_root() {
let update = ResourceUpdate {
limits: ResourceLimits {
cpu_quota: Some(50_000),
cpu_period: Some(100_000),
..Default::default()
},
..Default::default()
};
let cmds = update.build_cgroup_commands();
assert_eq!(cmds.len(), 1);
assert!(cmds[0].contains("exit 1"), "must fail loudly: {}", cmds[0]);
assert!(
!cmds[0].contains("d=\"/sys/fs/cgroup/\""),
"must not fall back to the root cgroup: {}",
cmds[0]
);
}
#[test]
fn test_resize_result_structure() {
let result = ResizeResult {
applied: vec!["cpu.weight".to_string()],
rejected: vec![("vcpus".to_string(), "not supported".to_string())],
};
assert_eq!(result.applied.len(), 1);
assert_eq!(result.rejected.len(), 1);
}
}