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 #[serde(default)]
33 pub records: Records,
34 #[serde(default)]
36 pub sizing: Sizing,
37}
38
39#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct Host {
43 pub slice: String,
45 pub isolation: Isolation,
47 #[serde(default)]
49 pub allow_unsafe: bool,
50 #[serde(default = "default_poll_interval", deserialize_with = "duration")]
52 pub poll_interval: Duration,
53 #[serde(default)]
55 pub admission: Admission,
56}
57
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum Admission {
62 #[default]
64 Caps,
65 Measured,
68}
69
70fn default_poll_interval() -> Duration {
71 Duration::from_secs(15)
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
76#[serde(rename_all = "kebab-case")]
77pub enum Isolation {
78 Sysbox,
80 UnsafeDind,
82}
83
84#[derive(Debug, Clone, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Caches {
88 pub mirror: Mirror,
90 pub pnpm: CacheLimit,
92 pub maven: CacheLimit,
94 #[serde(default = "default_reseed_at")]
97 pub reseed_at: ByteSize,
98}
99
100fn default_reseed_at() -> ByteSize {
101 ByteSize(2 << 30)
102}
103
104#[derive(Debug, Clone, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct Mirror {
108 pub enabled: bool,
110 pub memory: ByteSize,
112 pub cpus: u32,
114 #[serde(deserialize_with = "duration")]
116 pub ttl: Duration,
117}
118
119#[derive(Debug, Clone, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct CacheLimit {
123 pub max_size: ByteSize,
125}
126
127#[derive(Debug, Clone, Deserialize)]
129#[serde(try_from = "ProfileToml")]
130pub struct Profile {
131 pub image: Image,
133 pub cpus: u32,
135 pub memory: ByteSize,
137 pub slots: u32,
139 pub record_failures: bool,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Image {
146 Stage(String),
148 Pulled(String),
151}
152
153impl Image {
154 pub fn reference(&self) -> String {
156 match self {
157 Image::Stage(stage) => format!("runnerd-runner:{stage}"),
158 Image::Pulled(reference) => reference.clone(),
159 }
160 }
161}
162
163#[derive(Deserialize)]
165#[serde(deny_unknown_fields)]
166struct ProfileToml {
167 image_stage: Option<String>,
168 image: Option<String>,
169 cpus: u32,
170 memory: ByteSize,
171 slots: u32,
172 #[serde(default)]
173 record_failures: bool,
174}
175
176impl TryFrom<ProfileToml> for Profile {
177 type Error = String;
178
179 fn try_from(profile: ProfileToml) -> Result<Profile, String> {
180 let image = match (profile.image_stage, profile.image) {
181 (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
182 (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
183 (Some(_), Some(_)) => {
184 return Err(
185 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
186 not both"
187 .to_owned(),
188 );
189 }
190 _ => {
191 return Err(
192 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
193 .to_owned(),
194 );
195 }
196 };
197 Ok(Profile {
198 image,
199 cpus: profile.cpus,
200 memory: profile.memory,
201 slots: profile.slots,
202 record_failures: profile.record_failures,
203 })
204 }
205}
206
207#[derive(Debug, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct Repository {
211 pub name: String,
213 pub profile: String,
215 pub labels: Vec<String>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
221#[serde(deny_unknown_fields, default)]
222pub struct History {
223 pub database: Option<PathBuf>,
225 #[serde(deserialize_with = "duration")]
227 pub samples: Duration,
228 #[serde(deserialize_with = "duration")]
230 pub logs: Duration,
231 #[serde(deserialize_with = "duration")]
233 pub events: Duration,
234 #[serde(deserialize_with = "duration")]
236 pub jobs: Duration,
237 pub log_max: ByteSize,
239}
240
241impl Default for History {
242 fn default() -> History {
243 History {
244 database: None,
245 samples: Duration::from_hours(7 * 24),
246 logs: Duration::from_hours(30 * 24),
247 events: Duration::from_hours(30 * 24),
248 jobs: Duration::from_hours(90 * 24),
249 log_max: ByteSize(50 << 20),
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
257#[serde(deny_unknown_fields, default)]
258pub struct Records {
259 pub max_total: ByteSize,
261 pub max_count: u32,
263 #[serde(deserialize_with = "duration")]
265 pub max_age: Duration,
266 pub min_free: ByteSize,
268}
269
270impl Default for Records {
271 fn default() -> Records {
272 Records {
273 max_total: ByteSize(20 << 30),
274 max_count: 10,
275 max_age: Duration::from_hours(7 * 24),
276 min_free: ByteSize(10 << 30),
277 }
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
283#[serde(deny_unknown_fields, default)]
284pub struct Sizing {
285 pub margin: u32,
288 pub min_jobs: u32,
291}
292
293impl Default for Sizing {
294 fn default() -> Sizing {
295 Sizing {
296 margin: 15,
297 min_jobs: 10,
298 }
299 }
300}
301
302#[derive(Debug, thiserror::Error)]
304pub enum ConfigError {
305 #[error("cannot read {}: {source}", path.display())]
307 Read {
308 path: PathBuf,
310 source: std::io::Error,
312 },
313 #[error(transparent)]
315 Parse(#[from] toml::de::Error),
316 #[error(
318 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
319 defined_profiles(.defined)
320 )]
321 UnknownProfile {
322 repository: String,
324 profile: String,
326 defined: Vec<String>,
328 },
329 #[error("repository {repository} needs at least one non-empty label")]
331 MissingLabels {
332 repository: String,
334 },
335 #[error(
337 "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
338 set allow_unsafe = true under [host] to use it anyway"
339 )]
340 UnsafeNotAllowed,
341 #[error("{field} is 0; set it above 0")]
343 ZeroLimit {
344 field: String,
346 },
347 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
349 BudgetOverflow,
350 #[error(
352 "admission = \"measured\" admits runners against the slice {slice}'s MemoryMax, and it has none; install \
353 a unit file for it (`runnerd doctor` prints one)"
354 )]
355 MeasuredWithoutMemoryMax {
356 slice: String,
358 },
359 #[error(
361 "one runner of {profile} and the mirror take {needs}, the slice {slice}'s MemoryMax is {memory_max}; lower \
362 its `memory` or raise the slice's MemoryMax"
363 )]
364 ProfileOverBudget {
365 profile: String,
367 needs: ByteSize,
369 slice: String,
371 memory_max: ByteSize,
373 },
374 #[error(
376 "slots × memory over all profiles plus the mirror is {total}, \
377 the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
378 MemoryMax (`runnerd doctor` prints its unit)"
379 )]
380 OverBudget {
381 total: ByteSize,
383 slice: String,
385 memory_max: ByteSize,
387 },
388}
389
390fn defined_profiles(defined: &[String]) -> String {
391 if defined.is_empty() {
392 "it defines none".to_owned()
393 } else {
394 format!("defined: {}", defined.join(", "))
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub enum Warning {
401 UnlimitedSlice {
403 slice: String,
405 total: ByteSize,
407 },
408}
409
410impl fmt::Display for Warning {
411 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412 match self {
413 Warning::UnlimitedSlice { slice, total } => write!(
414 f,
415 "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
416 install a unit file for it"
417 ),
418 }
419 }
420}
421
422impl Config {
423 pub fn load(path: &Path) -> Result<Config, ConfigError> {
425 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
426 path: path.to_owned(),
427 source,
428 })?;
429 Config::parse(&text)
430 }
431
432 pub fn parse(text: &str) -> Result<Config, ConfigError> {
434 Ok(toml::from_str(text)?)
435 }
436
437 pub fn check(
440 text: &str,
441 memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
442 ) -> (Option<Config>, api::ConfigCheck) {
443 let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
444 api::ConfigCheck {
445 valid: false,
446 message,
447 warnings: Vec::new(),
448 memory_caps: memory_caps.map(|caps| caps.0),
449 memory_max: max.map(|max| max.0),
450 }
451 };
452 let config = match Config::parse(text) {
453 Ok(config) => config,
454 Err(error) => return (None, invalid(error.to_string(), None, None)),
455 };
456 let caps = config.memory_caps().ok();
457 let max = match memory_max(&config.host.slice) {
458 Ok(max) => max,
459 Err(error) => return (None, invalid(error.to_string(), caps, None)),
460 };
461 match config
462 .validate(max)
463 .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
464 {
465 Ok((warnings, total)) => {
466 let message = match (config.host.admission, max) {
467 (Admission::Measured, Some(max)) if total > max => format!(
468 "valid, memory caps sum to {total}, over the {max} MemoryMax: admission = \"measured\" \
469 starts what the predicted peaks fit"
470 ),
471 _ => format!("valid, memory caps sum to {total}"),
472 };
473 let check = api::ConfigCheck {
474 valid: true,
475 message,
476 warnings: warnings.iter().map(ToString::to_string).collect(),
477 memory_caps: Some(total.0),
478 memory_max: max.map(|max| max.0),
479 };
480 (Some(config), check)
481 }
482 Err(error) => (None, invalid(error.to_string(), caps, max)),
483 }
484 }
485
486 pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
488 let mirror = if self.caches.mirror.enabled {
489 self.caches.mirror.memory.0
490 } else {
491 0
492 };
493 self.profiles
494 .values()
495 .try_fold(mirror, |total, p| {
496 p.memory
497 .0
498 .checked_mul(u64::from(p.slots))?
499 .checked_add(total)
500 })
501 .map(ByteSize)
502 .ok_or(ConfigError::BudgetOverflow)
503 }
504
505 pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
507 if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
508 return Err(ConfigError::UnsafeNotAllowed);
509 }
510 let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
511 [
512 (p.memory.0 == 0, format!("profiles.{name}.memory")),
513 (p.cpus == 0, format!("profiles.{name}.cpus")),
514 (p.slots == 0, format!("profiles.{name}.slots")),
515 ]
516 });
517 let mirror = &self.caches.mirror;
518 let mirror_zero_limits = [
519 (
520 mirror.enabled && mirror.memory.0 == 0,
521 "caches.mirror.memory".to_owned(),
522 ),
523 (
524 mirror.enabled && mirror.cpus == 0,
525 "caches.mirror.cpus".to_owned(),
526 ),
527 (
528 self.host.poll_interval.is_zero(),
529 "host.poll_interval".to_owned(),
530 ),
531 (self.records.max_count == 0, "records.max_count".to_owned()),
532 (self.sizing.min_jobs == 0, "sizing.min_jobs".to_owned()),
533 (
534 self.records.max_total.0 == 0,
535 "records.max_total".to_owned(),
536 ),
537 ];
538 if let Some((_, field)) = zero_limits
539 .chain(mirror_zero_limits)
540 .find(|(zero, _)| *zero)
541 {
542 return Err(ConfigError::ZeroLimit { field });
543 }
544 for repository in &self.repositories {
545 if !self.profiles.contains_key(&repository.profile) {
546 return Err(ConfigError::UnknownProfile {
547 repository: repository.name.clone(),
548 profile: repository.profile.clone(),
549 defined: self.profiles.keys().cloned().collect(),
550 });
551 }
552 if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
553 {
554 return Err(ConfigError::MissingLabels {
555 repository: repository.name.clone(),
556 });
557 }
558 }
559 let total = self.memory_caps()?;
560 if self.host.admission == Admission::Measured {
561 let Some(memory_max) = memory_max else {
562 return Err(ConfigError::MeasuredWithoutMemoryMax {
563 slice: self.host.slice.clone(),
564 });
565 };
566 let mirror = if self.caches.mirror.enabled {
567 self.caches.mirror.memory.0
568 } else {
569 0
570 };
571 if let Some((profile, needs)) = self
572 .profiles
573 .iter()
574 .map(|(name, profile)| (name, profile.memory.0.saturating_add(mirror)))
575 .find(|(_, needs)| *needs > memory_max.0)
576 {
577 return Err(ConfigError::ProfileOverBudget {
578 profile: profile.clone(),
579 needs: ByteSize(needs),
580 slice: self.host.slice.clone(),
581 memory_max,
582 });
583 }
584 return Ok(Vec::new());
585 }
586 match memory_max {
587 Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
588 total,
589 slice: self.host.slice.clone(),
590 memory_max,
591 }),
592 Some(_) => Ok(Vec::new()),
593 None => Ok(vec![Warning::UnlimitedSlice {
594 slice: self.host.slice.clone(),
595 total,
596 }]),
597 }
598 }
599}
600
601#[derive(Debug, thiserror::Error)]
603pub enum ResizeError {
604 #[error(transparent)]
606 Parse(#[from] toml_edit::TomlError),
607 #[error("the config has no profile {0:?} under [profiles]")]
609 NoProfile(String),
610}
611
612pub fn resize_profile(
615 text: &str,
616 profile: &str,
617 resize: api::Resize,
618) -> Result<String, ResizeError> {
619 let mut document: toml_edit::DocumentMut = text.parse()?;
620 let table = document
621 .get_mut("profiles")
622 .and_then(|profiles| profiles.get_mut(profile))
623 .and_then(toml_edit::Item::as_table_like_mut)
624 .ok_or_else(|| ResizeError::NoProfile(profile.to_owned()))?;
625 let values = [
626 (
627 "memory",
628 toml_edit::Value::from(ByteSize(resize.memory).to_string()),
629 ),
630 ("slots", toml_edit::Value::from(i64::from(resize.slots))),
631 ];
632 for (key, mut value) in values {
633 if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) {
635 *value.decor_mut() = old.decor().clone();
636 }
637 table.insert(key, toml_edit::Item::Value(value));
638 }
639 Ok(document.to_string())
640}
641
642pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
644 let output = Command::new("systemctl")
645 .args(["show", slice, "--property=MemoryMax", "--value"])
646 .output()?;
647 if !output.status.success() {
648 return Err(std::io::Error::other(format!(
649 "systemctl show {slice} failed: {}",
650 String::from_utf8_lossy(&output.stderr).trim()
651 )));
652 }
653 let value = String::from_utf8_lossy(&output.stdout);
654 parse_memory_max(value.trim()).ok_or_else(|| {
655 std::io::Error::other(format!(
656 "systemctl show {slice}: unexpected MemoryMax {value:?}"
657 ))
658 })
659}
660
661pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
663 if value == "infinity" {
664 Some(None)
665 } else {
666 value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
667 }
668}
669
670#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
672pub struct ByteSize(pub u64);
673
674const UNITS: [(char, u64); 4] = [
675 ('T', 1 << 40),
676 ('G', 1 << 30),
677 ('M', 1 << 20),
678 ('K', 1 << 10),
679];
680
681impl FromStr for ByteSize {
682 type Err = String;
683
684 #[allow(
685 clippy::cast_precision_loss,
686 clippy::cast_possible_truncation,
687 clippy::cast_sign_loss,
688 reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
689 )]
690 fn from_str(s: &str) -> Result<Self, Self::Err> {
691 let invalid = || {
692 format!(
693 "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
694 )
695 };
696 let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
697 let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
698 let number = &s[..s.len() - 1];
699 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
700 return Err(invalid());
701 }
702 let number: f64 = number.parse().map_err(|_| invalid())?;
703 let bytes = (number * *factor as f64).round();
704 if bytes >= u64::MAX as f64 {
706 return Err(format!("size {s:?} is too large"));
707 }
708 Ok(ByteSize(bytes as u64))
709 }
710}
711
712impl fmt::Display for ByteSize {
713 #[allow(
714 clippy::cast_precision_loss,
715 reason = "shown with two decimals, far coarser than f64's precision"
716 )]
717 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
718 let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
719 return write!(f, "{}B", self.0);
720 };
721 let number = format!("{:.2}", self.0 as f64 / *factor as f64);
722 let number = number.trim_end_matches('0').trim_end_matches('.');
723 write!(f, "{number}{unit}")
724 }
725}
726
727impl<'de> Deserialize<'de> for ByteSize {
728 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
729 String::deserialize(deserializer)?
730 .parse()
731 .map_err(de::Error::custom)
732 }
733}
734
735pub fn parse_duration(s: &str) -> Result<Duration, String> {
737 let invalid = || {
738 format!(
739 "invalid duration {s:?}, expected a number and s, m, h or d, such as \"168h\" or \"7d\""
740 )
741 };
742 let (number, factor) = match s.chars().last() {
743 Some('s') => (&s[..s.len() - 1], 1),
744 Some('m') => (&s[..s.len() - 1], 60),
745 Some('h') => (&s[..s.len() - 1], 3600),
746 Some('d') => (&s[..s.len() - 1], 86400),
747 _ => return Err(invalid()),
748 };
749 let number: u64 = number.parse().map_err(|_| invalid())?;
750 number
751 .checked_mul(factor)
752 .map(Duration::from_secs)
753 .ok_or_else(invalid)
754}
755
756pub fn parse_age(text: &str) -> Result<Duration, String> {
758 let error = || "expected a number and s, m, h, d or w, such as 12h".to_owned();
759 let split = text.find(|c: char| !c.is_ascii_digit()).ok_or_else(error)?;
760 let (number, unit) = text.split_at(split);
761 let number: u64 = number.parse().map_err(|_| error())?;
762 let unit = match unit {
763 "s" => 1,
764 "m" => 60,
765 "h" => 3600,
766 "d" => 86_400,
767 "w" => 7 * 86_400,
768 _ => return Err(error()),
769 };
770 Ok(Duration::from_secs(number.saturating_mul(unit)))
771}
772
773fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
774 parse_duration(&String::deserialize(deserializer)?).map_err(de::Error::custom)
775}
776
777#[cfg(test)]
778mod tests;