use crate::{paths, sys, units};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EnvCapture {
#[default]
All,
Minimal,
None,
}
impl std::str::FromStr for EnvCapture {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s.trim().to_ascii_lowercase().as_str() {
"all" => Ok(Self::All),
"minimal" => Ok(Self::Minimal),
"none" => Ok(Self::None),
other => Err(format!(
"unknown env capture mode `{other}`; expected all, minimal or none"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum OversizedPolicy {
#[default]
RunWhenIdle,
Reject,
Queue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EnforceMode {
#[default]
Off,
Soft,
Hard,
}
impl EnforceMode {
pub fn is_on(self) -> bool {
self != Self::Off
}
}
fn text_or_number<'de, D>(d: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Visitor};
use std::fmt;
struct Either;
impl Visitor<'_> for Either {
type Value = String;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a number such as 2, or text such as \"75%\" or \"8GB\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
Ok(v.to_string())
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<String, E> {
Ok(v.to_string())
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<String, E> {
Ok(v.to_string())
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<String, E> {
Ok(v.to_string())
}
}
d.deserialize_any(Either)
}
fn text_or_number_opt<'de, D>(d: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
text_or_number(d).map(Some)
}
fn whole_number_opt<'de, D>(d: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Visitor};
use std::fmt;
struct WholeNumber;
impl Visitor<'_> for WholeNumber {
type Value = u64;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a whole number such as 1, with or without quotation marks")
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
Ok(v)
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<u64, E> {
u64::try_from(v).map_err(|_| {
de::Error::custom(format!(
"the number is {v}, and a count cannot be below zero. qex cannot \
calculate a size from it, and it stops. Write a whole number of \
0 or more."
))
})
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
let t = v.trim();
if t.ends_with('%') {
return Err(de::Error::custom(format!(
"the value is `{v}`, and this field does not take a percentage. It \
gives the cores for ONE job, and a part of the machine names no \
number of cores. Write a whole number, such as 1. To give a part of \
the machine, use `[budget] cpu`, which controls all the jobs \
together."
)));
}
t.parse::<u64>().map_err(|_| {
de::Error::custom(format!(
"the value is `{v}`, and qex cannot read a whole number from it. qex \
cannot calculate the size of a job, and it stops. Write a whole \
number, such as 1."
))
})
}
}
d.deserialize_any(WholeNumber).map(Some)
}
fn decimal_number<'de, D>(d: D) -> Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Visitor};
use std::fmt;
fn refuse<E: de::Error>(wrote: &dyn fmt::Display) -> E {
de::Error::custom(format!(
"the value is `{wrote}`, and qex cannot read a number from it. A limit that \
is not a number is false against every measurement, so it never operates \
and qex holds no job back. Write a number, such as 1.5."
))
}
struct DecimalNumber;
impl Visitor<'_> for DecimalNumber {
type Value = f64;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a number such as 1.5, with or without quotation marks")
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<f64, E> {
if v.is_finite() {
Ok(v)
} else {
Err(refuse(&v))
}
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<f64, E> {
Ok(v as f64)
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<f64, E> {
Ok(v as f64)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<f64, E> {
match v.trim().parse::<f64>() {
Ok(n) if n.is_finite() => Ok(n),
_ => Err(refuse(&v)),
}
}
}
d.deserialize_any(DecimalNumber)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct BudgetConfig {
#[serde(deserialize_with = "text_or_number")]
pub cpu: String,
#[serde(deserialize_with = "text_or_number")]
pub mem: String,
}
impl Default for BudgetConfig {
fn default() -> Self {
Self {
cpu: "75%".into(),
mem: "75%".into(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SystemConfig {
#[serde(deserialize_with = "text_or_number")]
pub reserve_mem: String,
#[serde(deserialize_with = "decimal_number")]
pub max_pressure: f64,
}
impl Default for SystemConfig {
fn default() -> Self {
Self {
reserve_mem: "2GB".into(),
max_pressure: 20.0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EnforceConfig {
pub mode: EnforceMode,
#[serde(deserialize_with = "decimal_number")]
pub mem_overcommit: f64,
pub use_systemd: bool,
}
impl Default for EnforceConfig {
fn default() -> Self {
Self {
mode: EnforceMode::Off,
mem_overcommit: 1.5,
use_systemd: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PeersConfig {
pub enabled: bool,
pub dir: String,
#[serde(deserialize_with = "text_or_number")]
pub stale_after: String,
}
impl Default for PeersConfig {
fn default() -> Self {
Self {
enabled: true,
dir: "/tmp/qex".into(),
stale_after: "30s".into(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct QueueConfig {
pub oversized: OversizedPolicy,
#[serde(deserialize_with = "text_or_number")]
pub settle: String,
}
impl Default for QueueConfig {
fn default() -> Self {
Self {
oversized: OversizedPolicy::RunWhenIdle,
settle: "3s".into(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SubmitConfig {
pub env_capture: EnvCapture,
pub minimal_env: Vec<String>,
}
impl Default for SubmitConfig {
fn default() -> Self {
Self {
env_capture: EnvCapture::All,
minimal_env: ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "TZ"]
.iter()
.map(|s| s.to_string())
.collect(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DefaultsConfig {
#[serde(default, deserialize_with = "whole_number_opt")]
pub cpu: Option<u64>,
#[serde(default, deserialize_with = "text_or_number_opt")]
pub mem: Option<String>,
#[serde(default, deserialize_with = "text_or_number_opt")]
pub timeout: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct GcConfig {
#[serde(deserialize_with = "text_or_number")]
pub keep: String,
}
impl Default for GcConfig {
fn default() -> Self {
Self { keep: "1d".into() }
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct HistoryConfig {
#[serde(deserialize_with = "text_or_number")]
pub keep: String,
}
impl Default for HistoryConfig {
fn default() -> Self {
Self { keep: "1d".into() }
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct LearnConfig {
pub enabled: bool,
#[serde(deserialize_with = "decimal_number")]
pub margin: f64,
}
impl Default for LearnConfig {
fn default() -> Self {
Self {
enabled: true,
margin: 1.5,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub budget: BudgetConfig,
pub system: SystemConfig,
pub enforce: EnforceConfig,
pub peers: PeersConfig,
pub queue: QueueConfig,
pub submit: SubmitConfig,
pub defaults: DefaultsConfig,
pub learn: LearnConfig,
pub history: HistoryConfig,
pub gc: GcConfig,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Detail {
Full,
Short,
}
fn config_error(path: &std::path::Path, error: toml::de::Error, detail: Detail) -> anyhow::Error {
let short = anyhow::anyhow!("parsing config file {}: {error}", path.display());
if detail == Detail::Short || !error.message().contains("unknown field") {
return short;
}
anyhow::anyhow!(
"{short}\n\n\
qex refuses a field that it does not know, because a name with a spelling \
fault must not be ignored in silence.\n\n\
This qex is version {}. If that name is an option of a NEWER qex, then the \
file and the program do not agree. Each command that needs this file stops \
until they agree, and qex cannot start a coordinator, so a queue whose \
coordinator retires stays where it is. Your jobs continue, and `qex wait` \
and `qex top` continue. `qex info`, `qex list` and `qex status` continue \
while a coordinator operates. Read `qex help config`.\n\n\
Put a new option in the config file only AFTER the coordinator is the new \
build. The program on the disk is not sufficient: a coordinator operates for \
hours, it holds the code that started it, and it reads this file once, when \
it starts. A new option that you write before that moment has no effect, and \
qex ignores it in silence.\n\n\
To correct it now:\n\
\x20 1. Install the new qex. Do this FIRST: while the old qex is the \
program on the disk, no coordinator can start from this file.\n\
\x20 2. Run `qex info` for the version and the pid of the coordinator. A \
coordinator stops when no job operates; `kill <pid>` changes it at once, and \
the jobs that operate continue.\n\
\x20 3. Run `qex info` again. The version must be the new one.\n\n\
To go back instead, remove that section from {}.",
crate::version::VERSION,
path.display()
)
}
impl Config {
pub fn load() -> Result<Self> {
Self::read(Detail::Full)
}
pub fn load_for_job_record() -> Result<Self> {
Self::read(Detail::Short)
}
fn read(detail: Detail) -> Result<Self> {
let path = paths::config_file()?;
match std::fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text).map_err(|e| config_error(&path, e, detail)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("reading config file {}", path.display())),
}
}
pub fn budget_cpu(&self) -> Result<u64> {
let total = sys::cpu_count();
let n = units::parse_budget(&self.budget.cpu, total, false)
.map_err(|e| anyhow::anyhow!("config [budget] cpu: {e}"))?;
Ok(n.max(1))
}
pub fn budget_mem(&self) -> Result<u64> {
let total = sys::total_memory();
let n = units::parse_budget(&self.budget.mem, total, true)
.map_err(|e| anyhow::anyhow!("config [budget] mem: {e}"))?;
Ok(n.max(64 << 20))
}
pub fn reserve_mem(&self) -> Result<u64> {
units::parse_size(&self.system.reserve_mem)
.map_err(|e| anyhow::anyhow!("config [system] reserve_mem: {e}"))
}
pub fn settle(&self) -> Result<std::time::Duration> {
units::parse_duration(&self.queue.settle)
.map_err(|e| anyhow::anyhow!("config [queue] settle: {e}"))
.map(|d| d.unwrap_or(std::time::Duration::ZERO))
}
pub fn gc_keep(&self) -> Result<std::time::Duration> {
units::parse_duration(&self.gc.keep)
.map_err(|e| anyhow::anyhow!("config [gc] keep: {e}"))
.map(|d| d.unwrap_or(std::time::Duration::from_secs(86400)))
}
pub fn history_keep(&self) -> Result<std::time::Duration> {
units::parse_duration(&self.history.keep)
.map_err(|e| anyhow::anyhow!("config [history] keep: {e}"))
.map(|d| d.unwrap_or(std::time::Duration::from_secs(86400)))
}
pub fn peer_stale_after(&self) -> Result<std::time::Duration> {
units::parse_duration(&self.peers.stale_after)
.map_err(|e| anyhow::anyhow!("config [peers] stale_after: {e}"))
.map(|d| d.unwrap_or(std::time::Duration::from_secs(30)))
}
pub fn default_cpu(&self) -> u64 {
self.defaults.cpu.unwrap_or(1).max(1)
}
pub fn default_mem(&self) -> Result<u64> {
match &self.defaults.mem {
Some(s) => {
units::parse_size(s).map_err(|e| anyhow::anyhow!("config [defaults] mem: {e}"))
}
None => {
let cores = sys::cpu_count().max(1);
let total = sys::total_memory();
Ok((total / cores).max(1 << 28))
}
}
}
pub fn default_timeout(&self) -> Result<Option<std::time::Duration>> {
match &self.defaults.timeout {
Some(s) => units::parse_duration(s)
.map_err(|e| anyhow::anyhow!("config [defaults] timeout: {e}")),
None => Ok(None),
}
}
pub fn validate(&self) -> Result<()> {
self.budget_cpu()?;
self.budget_mem()?;
self.reserve_mem()?;
self.settle()?;
self.peer_stale_after()?;
self.history_keep()?;
self.gc_keep()?;
self.default_mem()?;
self.default_timeout()?;
if self.learn.margin < 1.0 {
anyhow::bail!(
"config [learn] margin is {}. Use a value of 1.0 or more. A smaller value \
gives a claim below the measurement, and the job would then stop.",
self.learn.margin
);
}
if self.enforce.mem_overcommit < 1.0 {
anyhow::bail!(
"config [enforce] mem_overcommit is {}. Use a value of 1.0 or more. \
A smaller value sets memory.max below memory.high. The kernel then \
stops every job that reaches its claim.",
self.enforce.mem_overcommit
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_budget_accepts_a_number_and_text_alike() {
let c: Config = toml::from_str("[budget]\ncpu = 2\nmem = 2147483648\n").unwrap();
c.validate().unwrap();
assert_eq!(c.budget.cpu, "2");
assert_eq!(c.budget_cpu().unwrap(), 2);
assert_eq!(c.budget_mem().unwrap(), 2 << 30);
let c: Config = toml::from_str("[budget]\ncpu = \"75%\"\nmem = \"8GB\"\n").unwrap();
c.validate().unwrap();
assert_eq!(c.budget_mem().unwrap(), 8 << 30);
let c: Config = toml::from_str("[system]\nreserve_mem = 0\n").unwrap();
c.validate().unwrap();
assert_eq!(c.reserve_mem().unwrap(), 0);
}
#[test]
fn each_duration_and_size_field_accepts_a_number() {
let c: Config = toml::from_str(
"[queue]\nsettle = 5\n\
[peers]\nstale_after = 45\n\
[gc]\nkeep = 3600.0\n\
[history]\nkeep = 7200\n\
[defaults]\nmem = 536870912\ntimeout = 90\n",
)
.unwrap();
c.validate().unwrap();
assert_eq!(c.settle().unwrap(), std::time::Duration::from_secs(5));
assert_eq!(
c.peer_stale_after().unwrap(),
std::time::Duration::from_secs(45)
);
assert_eq!(c.gc_keep().unwrap(), std::time::Duration::from_secs(3600));
assert_eq!(
c.history_keep().unwrap(),
std::time::Duration::from_secs(7200)
);
assert_eq!(c.default_mem().unwrap(), 512 << 20);
assert_eq!(
c.default_timeout().unwrap(),
Some(std::time::Duration::from_secs(90))
);
}
#[test]
fn a_value_that_means_nothing_gives_an_error_that_names_the_field() {
let c: Config = toml::from_str("[budget]\ncpu = \"two\"\n").unwrap();
let e = c.validate().unwrap_err().to_string();
assert!(
e.contains("[budget] cpu"),
"the error must name the field: {e}"
);
assert!(
e.contains("integer or a percentage"),
"the error must say what to write: {e}"
);
let e = toml::from_str::<Config>("[budget]\ncpu = true\n")
.unwrap_err()
.to_string();
assert!(
e.contains("a number such as 2") && e.contains("75%"),
"the error must say which forms the field takes: {e}"
);
}
#[test]
fn an_integer_above_the_signed_limit_is_read_as_text() {
let c: Config = toml::from_str("[budget]\ncpu = 9223372036854775808\n").unwrap();
assert_eq!(c.budget.cpu, "9223372036854775808");
let c: Config = toml::from_str("[system]\nmax_pressure = 9223372036854775808\n").unwrap();
assert_eq!(c.system.max_pressure, 9223372036854775808f64);
let c: Config = toml::from_str("[defaults]\ncpu = 18446744073709551615\n").unwrap();
assert_eq!(c.default_cpu(), u64::MAX);
}
#[test]
fn a_number_inside_quotation_marks_gives_the_same_value() {
let quoted: Config = toml::from_str(
"[defaults]\ncpu = \"3\"\n\
[system]\nmax_pressure = \"30\"\n\
[learn]\nmargin = \"2.5\"\n\
[enforce]\nmem_overcommit = \"2.0\"\n",
)
.unwrap();
quoted.validate().unwrap();
let bare: Config = toml::from_str(
"[defaults]\ncpu = 3\n\
[system]\nmax_pressure = 30\n\
[learn]\nmargin = 2.5\n\
[enforce]\nmem_overcommit = 2.0\n",
)
.unwrap();
bare.validate().unwrap();
assert_eq!(quoted.default_cpu(), bare.default_cpu());
assert_eq!(quoted.default_cpu(), 3);
assert_eq!(quoted.system.max_pressure, bare.system.max_pressure);
assert_eq!(quoted.system.max_pressure, 30.0);
assert_eq!(quoted.learn.margin, bare.learn.margin);
assert_eq!(quoted.learn.margin, 2.5);
assert_eq!(quoted.enforce.mem_overcommit, bare.enforce.mem_overcommit);
assert_eq!(quoted.enforce.mem_overcommit, 2.0);
let c: Config = toml::from_str("[learn]\nmargin = 2\n").unwrap();
c.validate().unwrap();
assert_eq!(c.learn.margin, 2.0);
}
#[test]
fn a_percentage_is_refused_where_it_has_no_meaning() {
let e = toml::from_str::<Config>("[defaults]\ncpu = \"50%\"\n")
.unwrap_err()
.to_string();
assert!(
e.contains("does not take a percentage"),
"the error must say what happened: {e}"
);
assert!(
e.contains("cores for ONE job"),
"the error must say why it matters: {e}"
);
assert!(
e.contains("Write a whole number") && e.contains("[budget] cpu"),
"the error must say what to do: {e}"
);
for (text, want) in [
("[defaults]\ncpu = \"many\"\n", "Write a whole number"),
("[defaults]\ncpu = -1\n", "cannot be below zero"),
("[learn]\nmargin = \"one\"\n", "Write a number"),
] {
let e = toml::from_str::<Config>(text).unwrap_err().to_string();
assert!(e.contains(want), "{text} gave: {e}");
}
let c: Config = toml::from_str("[defaults]\ncpu = \" 2 \"\n").unwrap();
assert_eq!(c.default_cpu(), 2);
let c: Config = toml::from_str("[learn]\nmargin = \" 2.5 \"\n").unwrap();
assert_eq!(c.learn.margin, 2.5);
}
#[test]
fn a_limit_that_is_not_a_number_stops_the_file() {
for text in [
"[system]\nmax_pressure = nan\n",
"[system]\nmax_pressure = \"nan\"\n",
"[system]\nmax_pressure = inf\n",
"[system]\nmax_pressure = -inf\n",
"[learn]\nmargin = nan\n",
"[learn]\nmargin = inf\n",
"[learn]\nmargin = \"inf\"\n",
"[enforce]\nmem_overcommit = nan\n",
"[enforce]\nmem_overcommit = inf\n",
] {
let e = toml::from_str::<Config>(text)
.err()
.unwrap_or_else(|| panic!("{text} was accepted, and it must not be"))
.to_string();
assert!(
e.contains("false against every measurement"),
"{text} must say why it matters, and it gave: {e}"
);
assert!(
e.contains("Write a number"),
"{text} must say what to write, and it gave: {e}"
);
}
let c: Config = toml::from_str("[system]\nmax_pressure = 20.5\n").unwrap();
c.validate().unwrap();
assert_eq!(c.system.max_pressure, 20.5);
}
#[test]
fn a_field_that_qex_does_not_know_gives_the_order_of_the_steps() {
let path = std::path::Path::new("/home/me/.config/qex.toml");
let error = toml::from_str::<Config>("[hooks]\non_stop = [\"true\"]\n").unwrap_err();
let text = config_error(path, error, Detail::Full).to_string();
assert!(
text.contains("unknown field"),
"the message must keep the answer of the parser: {text}"
);
assert!(
text.contains("remove that section"),
"the message must give the way back: {text}"
);
assert!(
text.contains("qex info") && text.contains("continue"),
"the message must say which commands continue: {text}"
);
assert!(
text.contains("while a coordinator operates"),
"the promise about `qex info` must carry its qualifier: {text}"
);
assert!(
text.contains("Do this FIRST"),
"the message must say that the install comes before the kill: {text}"
);
assert!(
text.contains("AFTER the coordinator is the new build"),
"the message must give the order of the steps: {text}"
);
assert!(
text.contains("not sufficient"),
"the message must say that the program on the disk is not sufficient: {text}"
);
assert!(
text.contains(crate::version::VERSION),
"the message must name the version that reads the file: {text}"
);
let other = toml::from_str::<Config>("[budget\n").unwrap_err();
let text = config_error(path, other, Detail::Full).to_string();
assert!(
!text.contains("coordinator"),
"a fault of the form of the file must not give the version lesson: {text}"
);
}
#[test]
fn the_record_of_a_job_takes_the_short_message() {
let path = std::path::Path::new("/home/me/.config/qex.toml");
let error = toml::from_str::<Config>("[hooks]\non_stop = [\"true\"]\n").unwrap_err();
let text = config_error(path, error, Detail::Short).to_string();
assert!(
text.contains("unknown field"),
"the short message must still give the answer of the parser: {text}"
);
assert!(
!text.contains("coordinator"),
"the record of a job must not hold the lesson about the coordinator: {text}"
);
assert!(
text.lines().count() <= 6,
"the short message must fit in a record: {text}"
);
}
#[test]
fn empty_config_is_valid_and_gives_working_defaults() {
let c: Config = toml::from_str("").unwrap();
c.validate().unwrap();
assert_eq!(c.submit.env_capture, EnvCapture::All);
assert_eq!(c.queue.oversized, OversizedPolicy::RunWhenIdle);
assert_eq!(c.enforce.mode, EnforceMode::Off);
assert!(c.budget_cpu().unwrap() >= 1);
assert_eq!(c.default_cpu(), 1);
assert_eq!(c.default_timeout().unwrap(), None);
}
#[test]
fn default_job_size_scales_with_the_machine() {
let c = Config::default();
let cores = sys::cpu_count().max(1);
let expected = (sys::total_memory() / cores).max(1 << 28);
assert_eq!(c.default_mem().unwrap(), expected);
let budget_mem = c.budget_mem().unwrap();
assert!(
c.default_mem().unwrap() <= budget_mem,
"a job of the default size ({}) does not fit the default budget ({})",
units::format_size(c.default_mem().unwrap()),
units::format_size(budget_mem)
);
}
#[test]
fn config_defaults_replace_the_calculated_values() {
let c: Config =
toml::from_str("[defaults]\ncpu = 4\nmem = \"6GB\"\ntimeout = \"30m\"\n").unwrap();
c.validate().unwrap();
assert_eq!(c.default_cpu(), 4);
assert_eq!(c.default_mem().unwrap(), 6 << 30);
assert_eq!(
c.default_timeout().unwrap(),
Some(std::time::Duration::from_secs(1800))
);
}
#[test]
fn documented_config_parses() {
let text = r#"
[budget]
cpu = "75%"
mem = "20GB"
[system]
reserve_mem = "2GB"
max_pressure = 20
[enforce]
mode = "soft"
mem_overcommit = 1.5
use_systemd = true
[peers]
enabled = true
dir = "/tmp/qex"
stale_after = "30s"
[queue]
oversized = "run-when-idle"
settle = "3s"
[submit]
env_capture = "minimal"
minimal_env = ["PATH", "HOME"]
[defaults]
cpu = 1
mem = "1GB"
timeout = "0"
"#;
let c: Config = toml::from_str(text).unwrap();
c.validate().unwrap();
assert_eq!(c.enforce.mode, EnforceMode::Soft);
assert_eq!(c.submit.env_capture, EnvCapture::Minimal);
assert_eq!(c.budget_mem().unwrap(), 20 << 30);
assert_eq!(c.default_timeout().unwrap(), None);
}
#[test]
fn typos_in_config_keys_are_rejected_not_ignored() {
let err = toml::from_str::<Config>("[budget]\ncpuu = 4\n").unwrap_err();
assert!(err.to_string().contains("cpuu"), "got: {err}");
}
#[test]
fn bad_values_are_reported_with_the_offending_section() {
let c: Config = toml::from_str("[budget]\nmem = \"lots\"\n").unwrap();
let err = c.validate().unwrap_err().to_string();
assert!(err.contains("[budget] mem"), "got: {err}");
}
#[test]
fn overcommit_below_one_is_rejected() {
let c: Config = toml::from_str("[enforce]\nmem_overcommit = 0.5\n").unwrap();
assert!(c.validate().is_err());
}
#[test]
fn env_capture_parses_from_cli_strings() {
use std::str::FromStr;
assert_eq!(EnvCapture::from_str("all").unwrap(), EnvCapture::All);
assert_eq!(
EnvCapture::from_str("MINIMAL").unwrap(),
EnvCapture::Minimal
);
assert_eq!(EnvCapture::from_str(" none ").unwrap(), EnvCapture::None);
assert!(EnvCapture::from_str("some").is_err());
}
}