use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::str::FromStr;
use std::time::Duration;
use serde::{Deserialize, Deserializer, de};
use crate::api;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub host: Host,
pub caches: Caches,
pub profiles: BTreeMap<String, Profile>,
pub repositories: Vec<Repository>,
#[serde(default)]
pub history: History,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Host {
pub slice: String,
pub isolation: Isolation,
#[serde(default)]
pub allow_unsafe: bool,
#[serde(default = "default_poll_interval", deserialize_with = "duration")]
pub poll_interval: Duration,
}
fn default_poll_interval() -> Duration {
Duration::from_secs(15)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Isolation {
Sysbox,
UnsafeDind,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Caches {
pub mirror: Mirror,
pub pnpm: CacheLimit,
pub maven: CacheLimit,
#[serde(default = "default_reseed_at")]
pub reseed_at: ByteSize,
}
fn default_reseed_at() -> ByteSize {
ByteSize(2 << 30)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Mirror {
pub enabled: bool,
pub memory: ByteSize,
pub cpus: u32,
#[serde(deserialize_with = "duration")]
pub ttl: Duration,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CacheLimit {
pub max_size: ByteSize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(try_from = "ProfileToml")]
pub struct Profile {
pub image: Image,
pub cpus: u32,
pub memory: ByteSize,
pub slots: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Image {
Stage(String),
Pulled(String),
}
impl Image {
pub fn reference(&self) -> String {
match self {
Image::Stage(stage) => format!("runnerd-runner:{stage}"),
Image::Pulled(reference) => reference.clone(),
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileToml {
image_stage: Option<String>,
image: Option<String>,
cpus: u32,
memory: ByteSize,
slots: u32,
}
impl TryFrom<ProfileToml> for Profile {
type Error = String;
fn try_from(profile: ProfileToml) -> Result<Profile, String> {
let image = match (profile.image_stage, profile.image) {
(Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
(None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
(Some(_), Some(_)) => {
return Err(
"set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
not both"
.to_owned(),
);
}
_ => {
return Err(
"set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
.to_owned(),
);
}
};
Ok(Profile {
image,
cpus: profile.cpus,
memory: profile.memory,
slots: profile.slots,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Repository {
pub name: String,
pub profile: String,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct History {
pub database: Option<PathBuf>,
#[serde(deserialize_with = "duration")]
pub samples: Duration,
#[serde(deserialize_with = "duration")]
pub logs: Duration,
#[serde(deserialize_with = "duration")]
pub events: Duration,
#[serde(deserialize_with = "duration")]
pub jobs: Duration,
pub log_max: ByteSize,
}
impl Default for History {
fn default() -> History {
History {
database: None,
samples: Duration::from_hours(7 * 24),
logs: Duration::from_hours(30 * 24),
events: Duration::from_hours(30 * 24),
jobs: Duration::from_hours(90 * 24),
log_max: ByteSize(50 << 20),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("cannot read {}: {source}", path.display())]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error(transparent)]
Parse(#[from] toml::de::Error),
#[error(
"repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
defined_profiles(.defined)
)]
UnknownProfile {
repository: String,
profile: String,
defined: Vec<String>,
},
#[error("repository {repository} needs at least one non-empty label")]
MissingLabels {
repository: String,
},
#[error(
"isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
set allow_unsafe = true under [host] to use it anyway"
)]
UnsafeNotAllowed,
#[error("{field} is 0; set it above 0")]
ZeroLimit {
field: String,
},
#[error("slots × memory over all profiles plus the mirror is too large to add up")]
BudgetOverflow,
#[error(
"slots × memory over all profiles plus the mirror is {total}, \
the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
MemoryMax (`runnerd doctor` prints its unit)"
)]
OverBudget {
total: ByteSize,
slice: String,
memory_max: ByteSize,
},
}
fn defined_profiles(defined: &[String]) -> String {
if defined.is_empty() {
"it defines none".to_owned()
} else {
format!("defined: {}", defined.join(", "))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Warning {
UnlimitedSlice {
slice: String,
total: ByteSize,
},
}
impl fmt::Display for Warning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Warning::UnlimitedSlice { slice, total } => write!(
f,
"the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
install a unit file for it"
),
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Config, ConfigError> {
let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_owned(),
source,
})?;
Config::parse(&text)
}
pub fn parse(text: &str) -> Result<Config, ConfigError> {
Ok(toml::from_str(text)?)
}
pub fn check(
text: &str,
memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
) -> (Option<Config>, api::ConfigCheck) {
let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
api::ConfigCheck {
valid: false,
message,
warnings: Vec::new(),
memory_caps: memory_caps.map(|caps| caps.0),
memory_max: max.map(|max| max.0),
}
};
let config = match Config::parse(text) {
Ok(config) => config,
Err(error) => return (None, invalid(error.to_string(), None, None)),
};
let caps = config.memory_caps().ok();
let max = match memory_max(&config.host.slice) {
Ok(max) => max,
Err(error) => return (None, invalid(error.to_string(), caps, None)),
};
match config
.validate(max)
.and_then(|warnings| Ok((warnings, config.memory_caps()?)))
{
Ok((warnings, total)) => {
let check = api::ConfigCheck {
valid: true,
message: format!("valid, memory caps sum to {total}"),
warnings: warnings.iter().map(ToString::to_string).collect(),
memory_caps: Some(total.0),
memory_max: max.map(|max| max.0),
};
(Some(config), check)
}
Err(error) => (None, invalid(error.to_string(), caps, max)),
}
}
pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
let mirror = if self.caches.mirror.enabled {
self.caches.mirror.memory.0
} else {
0
};
self.profiles
.values()
.try_fold(mirror, |total, p| {
p.memory
.0
.checked_mul(u64::from(p.slots))?
.checked_add(total)
})
.map(ByteSize)
.ok_or(ConfigError::BudgetOverflow)
}
pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
return Err(ConfigError::UnsafeNotAllowed);
}
let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
[
(p.memory.0 == 0, format!("profiles.{name}.memory")),
(p.cpus == 0, format!("profiles.{name}.cpus")),
(p.slots == 0, format!("profiles.{name}.slots")),
]
});
let mirror = &self.caches.mirror;
let mirror_zero_limits = [
(
mirror.enabled && mirror.memory.0 == 0,
"caches.mirror.memory".to_owned(),
),
(
mirror.enabled && mirror.cpus == 0,
"caches.mirror.cpus".to_owned(),
),
(
self.host.poll_interval.is_zero(),
"host.poll_interval".to_owned(),
),
];
if let Some((_, field)) = zero_limits
.chain(mirror_zero_limits)
.find(|(zero, _)| *zero)
{
return Err(ConfigError::ZeroLimit { field });
}
for repository in &self.repositories {
if !self.profiles.contains_key(&repository.profile) {
return Err(ConfigError::UnknownProfile {
repository: repository.name.clone(),
profile: repository.profile.clone(),
defined: self.profiles.keys().cloned().collect(),
});
}
if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
{
return Err(ConfigError::MissingLabels {
repository: repository.name.clone(),
});
}
}
let total = self.memory_caps()?;
match memory_max {
Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
total,
slice: self.host.slice.clone(),
memory_max,
}),
Some(_) => Ok(Vec::new()),
None => Ok(vec![Warning::UnlimitedSlice {
slice: self.host.slice.clone(),
total,
}]),
}
}
}
pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
let output = Command::new("systemctl")
.args(["show", slice, "--property=MemoryMax", "--value"])
.output()?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"systemctl show {slice} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let value = String::from_utf8_lossy(&output.stdout);
parse_memory_max(value.trim()).ok_or_else(|| {
std::io::Error::other(format!(
"systemctl show {slice}: unexpected MemoryMax {value:?}"
))
})
}
pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
if value == "infinity" {
Some(None)
} else {
value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ByteSize(pub u64);
const UNITS: [(char, u64); 4] = [
('T', 1 << 40),
('G', 1 << 30),
('M', 1 << 20),
('K', 1 << 10),
];
impl FromStr for ByteSize {
type Err = String;
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
)]
fn from_str(s: &str) -> Result<Self, Self::Err> {
let invalid = || {
format!(
"invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
)
};
let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
let number = &s[..s.len() - 1];
if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
return Err(invalid());
}
let number: f64 = number.parse().map_err(|_| invalid())?;
let bytes = (number * *factor as f64).round();
if bytes >= u64::MAX as f64 {
return Err(format!("size {s:?} is too large"));
}
Ok(ByteSize(bytes as u64))
}
}
impl fmt::Display for ByteSize {
#[allow(
clippy::cast_precision_loss,
reason = "shown with two decimals, far coarser than f64's precision"
)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
return write!(f, "{}B", self.0);
};
let number = format!("{:.2}", self.0 as f64 / *factor as f64);
let number = number.trim_end_matches('0').trim_end_matches('.');
write!(f, "{number}{unit}")
}
}
impl<'de> Deserialize<'de> for ByteSize {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(de::Error::custom)
}
}
fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
let s = String::deserialize(deserializer)?;
let invalid = || {
de::Error::custom(format!(
"invalid duration {s:?}, expected a number and s, m or h, such as \"168h\""
))
};
let (number, factor) = match s.chars().last() {
Some('s') => (&s[..s.len() - 1], 1),
Some('m') => (&s[..s.len() - 1], 60),
Some('h') => (&s[..s.len() - 1], 3600),
_ => return Err(invalid()),
};
let number: u64 = number.parse().map_err(|_| invalid())?;
number
.checked_mul(factor)
.map(Duration::from_secs)
.ok_or_else(invalid)
}
#[cfg(test)]
mod tests;