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 #[serde(deserialize_with = "duration")]
210 pub jobs: Duration,
211 pub log_max: ByteSize,
213}
214
215impl Default for History {
216 fn default() -> History {
217 History {
218 database: None,
219 samples: Duration::from_hours(7 * 24),
220 logs: Duration::from_hours(30 * 24),
221 events: Duration::from_hours(30 * 24),
222 jobs: Duration::from_hours(90 * 24),
223 log_max: ByteSize(50 << 20),
224 }
225 }
226}
227
228#[derive(Debug, thiserror::Error)]
230pub enum ConfigError {
231 #[error("cannot read {}: {source}", path.display())]
233 Read {
234 path: PathBuf,
236 source: std::io::Error,
238 },
239 #[error(transparent)]
241 Parse(#[from] toml::de::Error),
242 #[error(
244 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
245 defined_profiles(.defined)
246 )]
247 UnknownProfile {
248 repository: String,
250 profile: String,
252 defined: Vec<String>,
254 },
255 #[error("repository {repository} needs at least one non-empty label")]
257 MissingLabels {
258 repository: String,
260 },
261 #[error(
263 "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
264 set allow_unsafe = true under [host] to use it anyway"
265 )]
266 UnsafeNotAllowed,
267 #[error("{field} is 0; set it above 0")]
269 ZeroLimit {
270 field: String,
272 },
273 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
275 BudgetOverflow,
276 #[error(
278 "slots × memory over all profiles plus the mirror is {total}, \
279 the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
280 MemoryMax (`runnerd doctor` prints its unit)"
281 )]
282 OverBudget {
283 total: ByteSize,
285 slice: String,
287 memory_max: ByteSize,
289 },
290}
291
292fn defined_profiles(defined: &[String]) -> String {
293 if defined.is_empty() {
294 "it defines none".to_owned()
295 } else {
296 format!("defined: {}", defined.join(", "))
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
302pub enum Warning {
303 UnlimitedSlice {
305 slice: String,
307 total: ByteSize,
309 },
310}
311
312impl fmt::Display for Warning {
313 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314 match self {
315 Warning::UnlimitedSlice { slice, total } => write!(
316 f,
317 "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
318 install a unit file for it"
319 ),
320 }
321 }
322}
323
324impl Config {
325 pub fn load(path: &Path) -> Result<Config, ConfigError> {
327 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
328 path: path.to_owned(),
329 source,
330 })?;
331 Config::parse(&text)
332 }
333
334 pub fn parse(text: &str) -> Result<Config, ConfigError> {
336 Ok(toml::from_str(text)?)
337 }
338
339 pub fn check(
342 text: &str,
343 memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
344 ) -> (Option<Config>, api::ConfigCheck) {
345 let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
346 api::ConfigCheck {
347 valid: false,
348 message,
349 warnings: Vec::new(),
350 memory_caps: memory_caps.map(|caps| caps.0),
351 memory_max: max.map(|max| max.0),
352 }
353 };
354 let config = match Config::parse(text) {
355 Ok(config) => config,
356 Err(error) => return (None, invalid(error.to_string(), None, None)),
357 };
358 let caps = config.memory_caps().ok();
359 let max = match memory_max(&config.host.slice) {
360 Ok(max) => max,
361 Err(error) => return (None, invalid(error.to_string(), caps, None)),
362 };
363 match config
364 .validate(max)
365 .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
366 {
367 Ok((warnings, total)) => {
368 let check = api::ConfigCheck {
369 valid: true,
370 message: format!("valid, memory caps sum to {total}"),
371 warnings: warnings.iter().map(ToString::to_string).collect(),
372 memory_caps: Some(total.0),
373 memory_max: max.map(|max| max.0),
374 };
375 (Some(config), check)
376 }
377 Err(error) => (None, invalid(error.to_string(), caps, max)),
378 }
379 }
380
381 pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
383 let mirror = if self.caches.mirror.enabled {
384 self.caches.mirror.memory.0
385 } else {
386 0
387 };
388 self.profiles
389 .values()
390 .try_fold(mirror, |total, p| {
391 p.memory
392 .0
393 .checked_mul(u64::from(p.slots))?
394 .checked_add(total)
395 })
396 .map(ByteSize)
397 .ok_or(ConfigError::BudgetOverflow)
398 }
399
400 pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
402 if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
403 return Err(ConfigError::UnsafeNotAllowed);
404 }
405 let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
406 [
407 (p.memory.0 == 0, format!("profiles.{name}.memory")),
408 (p.cpus == 0, format!("profiles.{name}.cpus")),
409 (p.slots == 0, format!("profiles.{name}.slots")),
410 ]
411 });
412 let mirror = &self.caches.mirror;
413 let mirror_zero_limits = [
414 (
415 mirror.enabled && mirror.memory.0 == 0,
416 "caches.mirror.memory".to_owned(),
417 ),
418 (
419 mirror.enabled && mirror.cpus == 0,
420 "caches.mirror.cpus".to_owned(),
421 ),
422 (
423 self.host.poll_interval.is_zero(),
424 "host.poll_interval".to_owned(),
425 ),
426 ];
427 if let Some((_, field)) = zero_limits
428 .chain(mirror_zero_limits)
429 .find(|(zero, _)| *zero)
430 {
431 return Err(ConfigError::ZeroLimit { field });
432 }
433 for repository in &self.repositories {
434 if !self.profiles.contains_key(&repository.profile) {
435 return Err(ConfigError::UnknownProfile {
436 repository: repository.name.clone(),
437 profile: repository.profile.clone(),
438 defined: self.profiles.keys().cloned().collect(),
439 });
440 }
441 if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
442 {
443 return Err(ConfigError::MissingLabels {
444 repository: repository.name.clone(),
445 });
446 }
447 }
448 let total = self.memory_caps()?;
449 match memory_max {
450 Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
451 total,
452 slice: self.host.slice.clone(),
453 memory_max,
454 }),
455 Some(_) => Ok(Vec::new()),
456 None => Ok(vec![Warning::UnlimitedSlice {
457 slice: self.host.slice.clone(),
458 total,
459 }]),
460 }
461 }
462}
463
464pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
466 let output = Command::new("systemctl")
467 .args(["show", slice, "--property=MemoryMax", "--value"])
468 .output()?;
469 if !output.status.success() {
470 return Err(std::io::Error::other(format!(
471 "systemctl show {slice} failed: {}",
472 String::from_utf8_lossy(&output.stderr).trim()
473 )));
474 }
475 let value = String::from_utf8_lossy(&output.stdout);
476 parse_memory_max(value.trim()).ok_or_else(|| {
477 std::io::Error::other(format!(
478 "systemctl show {slice}: unexpected MemoryMax {value:?}"
479 ))
480 })
481}
482
483pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
485 if value == "infinity" {
486 Some(None)
487 } else {
488 value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
489 }
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
494pub struct ByteSize(pub u64);
495
496const UNITS: [(char, u64); 4] = [
497 ('T', 1 << 40),
498 ('G', 1 << 30),
499 ('M', 1 << 20),
500 ('K', 1 << 10),
501];
502
503impl FromStr for ByteSize {
504 type Err = String;
505
506 #[allow(
507 clippy::cast_precision_loss,
508 clippy::cast_possible_truncation,
509 clippy::cast_sign_loss,
510 reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
511 )]
512 fn from_str(s: &str) -> Result<Self, Self::Err> {
513 let invalid = || {
514 format!(
515 "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
516 )
517 };
518 let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
519 let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
520 let number = &s[..s.len() - 1];
521 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
522 return Err(invalid());
523 }
524 let number: f64 = number.parse().map_err(|_| invalid())?;
525 let bytes = (number * *factor as f64).round();
526 if bytes >= u64::MAX as f64 {
528 return Err(format!("size {s:?} is too large"));
529 }
530 Ok(ByteSize(bytes as u64))
531 }
532}
533
534impl fmt::Display for ByteSize {
535 #[allow(
536 clippy::cast_precision_loss,
537 reason = "shown with two decimals, far coarser than f64's precision"
538 )]
539 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
540 let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
541 return write!(f, "{}B", self.0);
542 };
543 let number = format!("{:.2}", self.0 as f64 / *factor as f64);
544 let number = number.trim_end_matches('0').trim_end_matches('.');
545 write!(f, "{number}{unit}")
546 }
547}
548
549impl<'de> Deserialize<'de> for ByteSize {
550 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
551 String::deserialize(deserializer)?
552 .parse()
553 .map_err(de::Error::custom)
554 }
555}
556
557fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
559 let s = String::deserialize(deserializer)?;
560 let invalid = || {
561 de::Error::custom(format!(
562 "invalid duration {s:?}, expected a number and s, m or h, such as \"168h\""
563 ))
564 };
565 let (number, factor) = match s.chars().last() {
566 Some('s') => (&s[..s.len() - 1], 1),
567 Some('m') => (&s[..s.len() - 1], 60),
568 Some('h') => (&s[..s.len() - 1], 3600),
569 _ => return Err(invalid()),
570 };
571 let number: u64 = number.parse().map_err(|_| invalid())?;
572 number
573 .checked_mul(factor)
574 .map(Duration::from_secs)
575 .ok_or_else(invalid)
576}
577
578#[cfg(test)]
579mod tests;