1use std::collections::BTreeMap;
6use std::fmt;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::str::FromStr;
10use std::time::Duration;
11
12use serde::{Deserialize, Deserializer, de};
13
14use crate::api;
15
16#[derive(Debug, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct Config {
20 pub host: Host,
22 pub caches: Caches,
24 pub profiles: BTreeMap<String, Profile>,
26 pub repositories: Vec<Repository>,
28 #[serde(default)]
30 pub history: History,
31}
32
33#[derive(Debug, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Host {
37 pub slice: String,
39 pub isolation: Isolation,
41 #[serde(default)]
43 pub allow_unsafe: bool,
44 #[serde(default = "default_poll_interval", deserialize_with = "duration")]
46 pub poll_interval: Duration,
47}
48
49fn default_poll_interval() -> Duration {
50 Duration::from_secs(15)
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum Isolation {
57 Sysbox,
59 UnsafeDind,
61}
62
63#[derive(Debug, Clone, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct Caches {
67 pub mirror: Mirror,
69 pub pnpm: CacheLimit,
71 pub maven: CacheLimit,
73 #[serde(default = "default_reseed_at")]
76 pub reseed_at: ByteSize,
77}
78
79fn default_reseed_at() -> ByteSize {
80 ByteSize(2 << 30)
81}
82
83#[derive(Debug, Clone, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Mirror {
87 pub enabled: bool,
89 pub memory: ByteSize,
91 pub cpus: u32,
93 #[serde(deserialize_with = "duration")]
95 pub ttl: Duration,
96}
97
98#[derive(Debug, Clone, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct CacheLimit {
102 pub max_size: ByteSize,
104}
105
106#[derive(Debug, Clone, Deserialize)]
108#[serde(try_from = "ProfileToml")]
109pub struct Profile {
110 pub image: Image,
112 pub cpus: u32,
114 pub memory: ByteSize,
116 pub slots: u32,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Image {
123 Stage(String),
125 Pulled(String),
128}
129
130impl Image {
131 pub fn reference(&self) -> String {
133 match self {
134 Image::Stage(stage) => format!("runnerd-runner:{stage}"),
135 Image::Pulled(reference) => reference.clone(),
136 }
137 }
138}
139
140#[derive(Deserialize)]
142#[serde(deny_unknown_fields)]
143struct ProfileToml {
144 image_stage: Option<String>,
145 image: Option<String>,
146 cpus: u32,
147 memory: ByteSize,
148 slots: u32,
149}
150
151impl TryFrom<ProfileToml> for Profile {
152 type Error = String;
153
154 fn try_from(profile: ProfileToml) -> Result<Profile, String> {
155 let image = match (profile.image_stage, profile.image) {
156 (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
157 (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
158 (Some(_), Some(_)) => {
159 return Err(
160 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
161 not both"
162 .to_owned(),
163 );
164 }
165 _ => {
166 return Err(
167 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
168 .to_owned(),
169 );
170 }
171 };
172 Ok(Profile {
173 image,
174 cpus: profile.cpus,
175 memory: profile.memory,
176 slots: profile.slots,
177 })
178 }
179}
180
181#[derive(Debug, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct Repository {
185 pub name: String,
187 pub profile: String,
189 pub labels: Vec<String>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
195#[serde(deny_unknown_fields, default)]
196pub struct History {
197 pub database: Option<PathBuf>,
199 #[serde(deserialize_with = "duration")]
201 pub samples: Duration,
202 #[serde(deserialize_with = "duration")]
204 pub logs: Duration,
205 #[serde(deserialize_with = "duration")]
207 pub events: Duration,
208 pub log_max: ByteSize,
210}
211
212impl Default for History {
213 fn default() -> History {
214 History {
215 database: None,
216 samples: Duration::from_hours(7 * 24),
217 logs: Duration::from_hours(30 * 24),
218 events: Duration::from_hours(30 * 24),
219 log_max: ByteSize(50 << 20),
220 }
221 }
222}
223
224#[derive(Debug, thiserror::Error)]
226pub enum ConfigError {
227 #[error("cannot read {}: {source}", path.display())]
229 Read {
230 path: PathBuf,
232 source: std::io::Error,
234 },
235 #[error(transparent)]
237 Parse(#[from] toml::de::Error),
238 #[error(
240 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]"
241 )]
242 UnknownProfile {
243 repository: String,
245 profile: String,
247 },
248 #[error("repository {repository} needs at least one non-empty label")]
250 MissingLabels {
251 repository: String,
253 },
254 #[error(
256 "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
257 set allow_unsafe = true under [host] to use it anyway"
258 )]
259 UnsafeNotAllowed,
260 #[error("{field} is 0; set it above 0")]
262 ZeroLimit {
263 field: String,
265 },
266 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
268 BudgetOverflow,
269 #[error(
271 "slots × memory over all profiles plus the mirror is {total}, \
272 the slice {slice}'s MemoryMax is {memory_max}"
273 )]
274 OverBudget {
275 total: ByteSize,
277 slice: String,
279 memory_max: ByteSize,
281 },
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub enum Warning {
287 UnlimitedSlice {
289 slice: String,
291 total: ByteSize,
293 },
294}
295
296impl fmt::Display for Warning {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 match self {
299 Warning::UnlimitedSlice { slice, total } => write!(
300 f,
301 "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
302 install a unit file for it"
303 ),
304 }
305 }
306}
307
308impl Config {
309 pub fn load(path: &Path) -> Result<Config, ConfigError> {
311 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
312 path: path.to_owned(),
313 source,
314 })?;
315 Config::parse(&text)
316 }
317
318 pub fn parse(text: &str) -> Result<Config, ConfigError> {
320 Ok(toml::from_str(text)?)
321 }
322
323 pub fn check(
326 text: &str,
327 memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
328 ) -> (Option<Config>, api::ConfigCheck) {
329 let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
330 api::ConfigCheck {
331 valid: false,
332 message,
333 warnings: Vec::new(),
334 memory_caps: memory_caps.map(|caps| caps.0),
335 memory_max: max.map(|max| max.0),
336 }
337 };
338 let config = match Config::parse(text) {
339 Ok(config) => config,
340 Err(error) => return (None, invalid(error.to_string(), None, None)),
341 };
342 let caps = config.memory_caps().ok();
343 let max = match memory_max(&config.host.slice) {
344 Ok(max) => max,
345 Err(error) => return (None, invalid(error.to_string(), caps, None)),
346 };
347 match config
348 .validate(max)
349 .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
350 {
351 Ok((warnings, total)) => {
352 let check = api::ConfigCheck {
353 valid: true,
354 message: format!("valid, memory caps sum to {total}"),
355 warnings: warnings.iter().map(ToString::to_string).collect(),
356 memory_caps: Some(total.0),
357 memory_max: max.map(|max| max.0),
358 };
359 (Some(config), check)
360 }
361 Err(error) => (None, invalid(error.to_string(), caps, max)),
362 }
363 }
364
365 pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
367 let mirror = if self.caches.mirror.enabled {
368 self.caches.mirror.memory.0
369 } else {
370 0
371 };
372 self.profiles
373 .values()
374 .try_fold(mirror, |total, p| {
375 p.memory
376 .0
377 .checked_mul(u64::from(p.slots))?
378 .checked_add(total)
379 })
380 .map(ByteSize)
381 .ok_or(ConfigError::BudgetOverflow)
382 }
383
384 pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
386 if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
387 return Err(ConfigError::UnsafeNotAllowed);
388 }
389 let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
390 [
391 (p.memory.0 == 0, format!("profiles.{name}.memory")),
392 (p.cpus == 0, format!("profiles.{name}.cpus")),
393 (p.slots == 0, format!("profiles.{name}.slots")),
394 ]
395 });
396 let mirror = &self.caches.mirror;
397 let mirror_zero_limits = [
398 (
399 mirror.enabled && mirror.memory.0 == 0,
400 "caches.mirror.memory".to_owned(),
401 ),
402 (
403 mirror.enabled && mirror.cpus == 0,
404 "caches.mirror.cpus".to_owned(),
405 ),
406 (
407 self.host.poll_interval.is_zero(),
408 "host.poll_interval".to_owned(),
409 ),
410 ];
411 if let Some((_, field)) = zero_limits
412 .chain(mirror_zero_limits)
413 .find(|(zero, _)| *zero)
414 {
415 return Err(ConfigError::ZeroLimit { field });
416 }
417 for repository in &self.repositories {
418 if !self.profiles.contains_key(&repository.profile) {
419 return Err(ConfigError::UnknownProfile {
420 repository: repository.name.clone(),
421 profile: repository.profile.clone(),
422 });
423 }
424 if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
425 {
426 return Err(ConfigError::MissingLabels {
427 repository: repository.name.clone(),
428 });
429 }
430 }
431 let total = self.memory_caps()?;
432 match memory_max {
433 Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
434 total,
435 slice: self.host.slice.clone(),
436 memory_max,
437 }),
438 Some(_) => Ok(Vec::new()),
439 None => Ok(vec![Warning::UnlimitedSlice {
440 slice: self.host.slice.clone(),
441 total,
442 }]),
443 }
444 }
445}
446
447pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
449 let output = Command::new("systemctl")
450 .args(["show", slice, "--property=MemoryMax", "--value"])
451 .output()?;
452 if !output.status.success() {
453 return Err(std::io::Error::other(format!(
454 "systemctl show {slice} failed: {}",
455 String::from_utf8_lossy(&output.stderr).trim()
456 )));
457 }
458 let value = String::from_utf8_lossy(&output.stdout);
459 parse_memory_max(value.trim()).ok_or_else(|| {
460 std::io::Error::other(format!(
461 "systemctl show {slice}: unexpected MemoryMax {value:?}"
462 ))
463 })
464}
465
466pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
468 if value == "infinity" {
469 Some(None)
470 } else {
471 value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
472 }
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
477pub struct ByteSize(pub u64);
478
479const UNITS: [(char, u64); 4] = [
480 ('T', 1 << 40),
481 ('G', 1 << 30),
482 ('M', 1 << 20),
483 ('K', 1 << 10),
484];
485
486impl FromStr for ByteSize {
487 type Err = String;
488
489 #[allow(
490 clippy::cast_precision_loss,
491 clippy::cast_possible_truncation,
492 clippy::cast_sign_loss,
493 reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
494 )]
495 fn from_str(s: &str) -> Result<Self, Self::Err> {
496 let invalid = || {
497 format!(
498 "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
499 )
500 };
501 let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
502 let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
503 let number = &s[..s.len() - 1];
504 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
505 return Err(invalid());
506 }
507 let number: f64 = number.parse().map_err(|_| invalid())?;
508 let bytes = (number * *factor as f64).round();
509 if bytes >= u64::MAX as f64 {
511 return Err(format!("size {s:?} is too large"));
512 }
513 Ok(ByteSize(bytes as u64))
514 }
515}
516
517impl fmt::Display for ByteSize {
518 #[allow(
519 clippy::cast_precision_loss,
520 reason = "shown with two decimals, far coarser than f64's precision"
521 )]
522 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
523 let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
524 return write!(f, "{}B", self.0);
525 };
526 let number = format!("{:.2}", self.0 as f64 / *factor as f64);
527 let number = number.trim_end_matches('0').trim_end_matches('.');
528 write!(f, "{number}{unit}")
529 }
530}
531
532impl<'de> Deserialize<'de> for ByteSize {
533 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
534 String::deserialize(deserializer)?
535 .parse()
536 .map_err(de::Error::custom)
537 }
538}
539
540fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
542 let s = String::deserialize(deserializer)?;
543 let invalid = || {
544 de::Error::custom(format!(
545 "invalid duration {s:?}, expected a number and s, m or h, such as \"168h\""
546 ))
547 };
548 let (number, factor) = match s.chars().last() {
549 Some('s') => (&s[..s.len() - 1], 1),
550 Some('m') => (&s[..s.len() - 1], 60),
551 Some('h') => (&s[..s.len() - 1], 3600),
552 _ => return Err(invalid()),
553 };
554 let number: u64 = number.parse().map_err(|_| invalid())?;
555 number
556 .checked_mul(factor)
557 .map(Duration::from_secs)
558 .ok_or_else(invalid)
559}
560
561#[cfg(test)]
562mod tests;