Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
3 changed files with 24 additions and 86 deletions

View file

@ -260,32 +260,18 @@ pub fn validate_cpu_quota(value: &str) -> Result<(), String> {
)) ))
} }
/// Validate a systemd `MemoryMax=` value — a byte count with an optional /// Validate a systemd `MemoryMax=` value: a byte count with an optional
/// `K`/`M`/`G`/`T` suffix, a percentage of physical memory, or the literal /// `K`/`M`/`G`/`T` suffix, a percentage of physical memory, or the
/// `infinity` — and return the exact string to store and pass to systemd. /// literal `infinity`.
///
/// That's not always `value` itself: a size is accepted case-insensitively
/// and with an optional redundant trailing `B` (`8gb`, `8Gb`, `8GB`, `8g`,
/// `8G` are all the same size to a human), but only `8G` is what systemd's
/// own parser actually takes — confirmed directly against a running
/// systemd 260: `MemoryMax=8GB` and `MemoryMax=8g` both fail unit
/// activation with "Invalid argument", while `MemoryMax=8G` starts fine.
/// Accepting the friendly spellings and normalizing them here means every
/// caller downstream only ever sees the one form systemd accepts, instead
/// of everyone re-deriving that systemd is this particular about case and
/// the redundant `B`.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns a human-readable message naming the offending value and the /// Returns a human-readable message naming the offending value and the
/// three accepted shapes. Same operator-facing contract as /// three accepted shapes. Same operator-facing contract as
/// [`validate_cpu_quota`]. /// [`validate_cpu_quota`].
pub fn validate_memory_max(value: &str) -> Result<String, String> { pub fn validate_memory_max(value: &str) -> Result<(), String> {
if value == "infinity" || is_percentage(value) { if value == "infinity" || is_percentage(value) || is_byte_size(value) {
return Ok(value.to_owned()); return Ok(());
}
if let Some(normalized) = normalized_byte_size(value) {
return Ok(normalized);
} }
Err(format!( Err(format!(
"invalid MemoryMax {value:?}: expected a size such as \"8G\", a percentage \ "invalid MemoryMax {value:?}: expected a size such as \"8G\", a percentage \
@ -298,32 +284,12 @@ fn is_percentage(value: &str) -> bool {
value.strip_suffix('%').is_some_and(is_plain_number) value.strip_suffix('%').is_some_and(is_plain_number)
} }
/// A decimal number with an optional `K`/`M`/`G`/`T` suffix — accepted /// A decimal number with an optional single-letter binary suffix.
/// case-insensitively and with an optional trailing `B` (`gb`/`Gb`/`GB`/`g` fn is_byte_size(value: &str) -> bool {
/// are all read as `G`) — returned in the single uppercase-letter-no- let mantissa = value
/// redundant-`B` form systemd itself accepts. See [`validate_memory_max`] .strip_suffix(['K', 'M', 'G', 'T', 'k', 'm', 'g', 't'])
/// for why. A trailing `B` with no multiplier before it (`1048576B`) is a .unwrap_or(value);
/// bare byte count and kept as-is — systemd accepts `B` as a unit on its is_plain_number(mantissa)
/// own, unlike the redundant `KB`/`MB`/`GB`/`TB` combinations.
fn normalized_byte_size(value: &str) -> Option<String> {
if let Some(before_b) = value.strip_suffix(['B', 'b']) {
return match before_b.strip_suffix(['K', 'M', 'G', 'T', 'k', 'm', 'g', 't']) {
Some(mantissa) if is_plain_number(mantissa) => {
let unit = before_b[mantissa.len()..].to_ascii_uppercase();
Some(format!("{mantissa}{unit}"))
}
Some(_) => None,
None => is_plain_number(before_b).then(|| format!("{before_b}B")),
};
}
match value.strip_suffix(['K', 'M', 'G', 'T', 'k', 'm', 'g', 't']) {
Some(mantissa) if is_plain_number(mantissa) => {
let unit = value[mantissa.len()..].to_ascii_uppercase();
Some(format!("{mantissa}{unit}"))
}
Some(_) => None,
None => is_plain_number(value).then(|| value.to_owned()),
}
} }
/// Digits, optionally followed by a single `.` and more digits. Hand /// Digits, optionally followed by a single `.` and more digits. Hand
@ -436,31 +402,9 @@ mod tests {
} }
} }
/// mara hit this directly: "16GB" reads as an obviously
/// valid size to a human, but systemd's own parser takes only the
/// single-letter form — confirmed against a running systemd 260,
/// `MemoryMax=16GB` and `MemoryMax=16g` both fail unit activation,
/// `MemoryMax=16G` doesn't. Accept the friendly spellings and normalize
/// them to what systemd actually wants, rather than rejecting input a
/// human reasonably expects to work.
#[test]
fn friendly_size_spellings_normalize_to_systemds_own_form() {
for (input, want) in [
("8GB", "8G"),
("8Gb", "8G"),
("8gb", "8G"),
("8g", "8G"),
("512mb", "512M"),
("1048576b", "1048576B"),
("1.5gb", "1.5G"),
] {
assert_eq!(validate_memory_max(input).as_deref(), Ok(want));
}
}
#[test] #[test]
fn rejects_invalid_memory_maxes() { fn rejects_invalid_memory_maxes() {
for v in ["", "G", "abc", "-8G", "8 G", "Infinity", "8Gi", "8KG"] { for v in ["", "8GB", "G", "abc", "-8G", "8 G", "Infinity", "8Gi"] {
assert!(validate_memory_max(v).is_err(), "{v} should be rejected"); assert!(validate_memory_max(v).is_err(), "{v} should be rejected");
} }
} }
@ -476,7 +420,7 @@ mod tests {
parse_bytes("1.5G"), parse_bytes("1.5G"),
Some(1024 * 1024 * 1024 + 512 * 1024 * 1024) Some(1024 * 1024 * 1024 + 512 * 1024 * 1024)
); );
// Lowercase suffixes accepted, matching `normalized_byte_size`. // Lowercase suffixes accepted, matching `is_byte_size`.
assert_eq!(parse_bytes("4g"), Some(4 * 1024 * 1024 * 1024)); assert_eq!(parse_bytes("4g"), Some(4 * 1024 * 1024 * 1024));
} }

View file

@ -376,16 +376,14 @@ pub(super) async fn post_resource_limits(
{ {
return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response(); return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response();
} }
// Returns the value to store, not just an ok/err verdict — see if let Some(v) = memory_max
// `validate_memory_max`'s own doc for why that isn't always `v`. && let Err(e) = crate::resource_limits::validate_memory_max(v)
let memory_max = match memory_max.map(crate::resource_limits::validate_memory_max) { {
Some(Err(e)) => return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response(), return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response();
Some(Ok(v)) => Some(v), }
None => None,
};
let limits = crate::resource_limits::AgentLimits { let limits = crate::resource_limits::AgentLimits {
cpu_quota: cpu_quota.map(str::to_owned), cpu_quota: cpu_quota.map(str::to_owned),
memory_max, memory_max: memory_max.map(str::to_owned),
}; };
if let Err(e) = crate::meta::commit_resource_limits(ident.as_str(), &limits).await { if let Err(e) = crate::meta::commit_resource_limits(ident.as_str(), &limits).await {
return error_response(&format!("set limits {logical}: {e:#}")); return error_response(&format!("set limits {logical}: {e:#}"));

View file

@ -502,17 +502,13 @@ async fn handle_set_resource_limits(
if let Some(value) = cpu_quota { if let Some(value) = cpu_quota {
crate::resource_limits::validate_cpu_quota(value).map_err(anyhow::Error::msg)?; crate::resource_limits::validate_cpu_quota(value).map_err(anyhow::Error::msg)?;
} }
// `validate_memory_max` returns the value to store, not just an if let Some(value) = memory_max {
// ok/err verdict: a friendly spelling like "8GB" is accepted but crate::resource_limits::validate_memory_max(value).map_err(anyhow::Error::msg)?;
// normalized to the "8G" systemd's own parser actually takes. }
let memory_max = memory_max
.map(crate::resource_limits::validate_memory_max)
.transpose()
.map_err(anyhow::Error::msg)?;
tracing::info!(%name, ?cpu_quota, ?memory_max, "set_resource_limits"); tracing::info!(%name, ?cpu_quota, ?memory_max, "set_resource_limits");
let limits = crate::resource_limits::AgentLimits { let limits = crate::resource_limits::AgentLimits {
cpu_quota: cpu_quota.map(ToOwned::to_owned), cpu_quota: cpu_quota.map(ToOwned::to_owned),
memory_max, memory_max: memory_max.map(ToOwned::to_owned),
}; };
// Goes through `meta::commit_resource_limits`, not the bare // Goes through `meta::commit_resource_limits`, not the bare
// `resource_limits::set_limits`: the write has to be staged + // `resource_limits::set_limits`: the write has to be staged +