Skip to main content

runnerd_core/
config.rs

1//! `runnerd.toml`: the repositories to serve, the runner profiles they use, the shared caches and the host
2//! budget they must fit in. Builds without the `host` feature, so clients can read a config; its only I/O is
3//! reading the file and asking `systemctl` for a slice's `MemoryMax`. It never talks to Docker or GitHub.
4
5use 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/// A parsed `runnerd.toml`. `validate` checks it against the host's budget.
17#[derive(Debug, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct Config {
20    /// The host's budget and isolation.
21    pub host: Host,
22    /// The caches runners share.
23    pub caches: Caches,
24    /// Runner profiles by name.
25    pub profiles: BTreeMap<String, Profile>,
26    /// The repositories to serve.
27    pub repositories: Vec<Repository>,
28    /// What is kept of past jobs.
29    #[serde(default)]
30    pub history: History,
31}
32
33/// `[host]`: where runner containers run and how they are isolated.
34#[derive(Debug, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Host {
37    /// The systemd slice every runner container is placed in; its `MemoryMax` is the budget.
38    pub slice: String,
39    /// How runner containers are kept from the host.
40    pub isolation: Isolation,
41    /// Must be set for `isolation = "unsafe-dind"`, so a copied config cannot turn it on silently.
42    #[serde(default)]
43    pub allow_unsafe: bool,
44    /// How often GitHub is asked for queued jobs.
45    #[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/// How runner containers are kept from the host: `isolation` under `[host]`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum Isolation {
57    /// Sysbox: Docker inside an unprivileged container.
58    Sysbox,
59    /// Privileged Docker-in-Docker: a job can take over the host.
60    UnsafeDind,
61}
62
63/// `[caches]`: what runners share and keep between jobs.
64#[derive(Debug, Clone, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct Caches {
67    /// The Docker Hub mirror.
68    pub mirror: Mirror,
69    /// The shared pnpm store.
70    pub pnpm: CacheLimit,
71    /// The shared Maven repository.
72    pub maven: CacheLimit,
73    /// When a profile has no job running and its slot 1 changed its layers by at least this much, slot 1's
74    /// layers become the profile's new snapshot.
75    #[serde(default = "default_reseed_at")]
76    pub reseed_at: ByteSize,
77}
78
79fn default_reseed_at() -> ByteSize {
80    ByteSize(2 << 30)
81}
82
83/// The Docker Hub pull-through mirror shared by all runners.
84#[derive(Debug, Clone, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Mirror {
87    /// Whether runners pull through the mirror.
88    pub enabled: bool,
89    /// The mirror container's memory limit.
90    pub memory: ByteSize,
91    /// The mirror container's CPUs.
92    pub cpus: u32,
93    /// How long content not pulled again is kept.
94    #[serde(deserialize_with = "duration")]
95    pub ttl: Duration,
96}
97
98/// A size limit on a shared cache.
99#[derive(Debug, Clone, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct CacheLimit {
102    /// The cache is emptied above this size when no job runs.
103    pub max_size: ByteSize,
104}
105
106/// A runner image with its limits; every job of the profile gets one container with these limits.
107#[derive(Debug, Clone, Deserialize)]
108#[serde(try_from = "ProfileToml")]
109pub struct Profile {
110    /// The image its runners run: `image_stage` or `image`, exactly one of them.
111    pub image: Image,
112    /// CPUs per slot.
113    pub cpus: u32,
114    /// Memory per slot; the container's hard limit.
115    pub memory: ByteSize,
116    /// The most jobs of this profile running at once.
117    pub slots: u32,
118}
119
120/// The image a profile's runners run.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Image {
123    /// `image_stage`: a stage of `image/Dockerfile` built on this host as `runnerd-runner:<stage>`.
124    Stage(String),
125    /// `image`: a reference runnerd pulls when Docker lacks it, such as
126    /// `ghcr.io/ayagmar/runnerd-runner:jvm-0.0.6`.
127    Pulled(String),
128}
129
130impl Image {
131    /// The reference Docker runs.
132    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/// `[profiles.<name>]` as written, before `Profile` checks it names one image.
141#[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/// A repository to serve: `[[repositories]]`.
182#[derive(Debug, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct Repository {
185    /// `owner/name` on GitHub.
186    pub name: String,
187    /// The profile its jobs run in.
188    pub profile: String,
189    /// The labels its runners register with; a job runs on them when every label it asks for is one of these.
190    pub labels: Vec<String>,
191}
192
193/// `[history]`: the job history database and how long it keeps what. Every field has a default.
194#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
195#[serde(deny_unknown_fields, default)]
196pub struct History {
197    /// The SQLite database; default `$XDG_STATE_HOME/runnerd/runnerd.db`.
198    pub database: Option<PathBuf>,
199    /// How long CPU and memory samples are kept; a job's totals and peaks stay. Default 7 days.
200    #[serde(deserialize_with = "duration")]
201    pub samples: Duration,
202    /// How long a finished job's log is kept. Default 30 days.
203    #[serde(deserialize_with = "duration")]
204    pub logs: Duration,
205    /// How long events are kept for clients resuming with `Last-Event-ID`. Default 30 days.
206    #[serde(deserialize_with = "duration")]
207    pub events: Duration,
208    /// How long a finished job's record is kept, with its steps, totals and runner. Default 90 days.
209    #[serde(deserialize_with = "duration")]
210    pub jobs: Duration,
211    /// The most log stored per job; later lines are dropped. Default 50M.
212    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/// Why a config cannot be used.
229#[derive(Debug, thiserror::Error)]
230pub enum ConfigError {
231    /// The file cannot be read.
232    #[error("cannot read {}: {source}", path.display())]
233    Read {
234        /// The config file.
235        path: PathBuf,
236        /// Why reading it failed.
237        source: std::io::Error,
238    },
239    /// The file is not valid TOML or does not match the config's shape.
240    #[error(transparent)]
241    Parse(#[from] toml::de::Error),
242    /// A repository names a profile `[profiles]` does not define.
243    #[error(
244        "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
245        defined_profiles(.defined)
246    )]
247    UnknownProfile {
248        /// The repository's `owner/name`.
249        repository: String,
250        /// The profile it names.
251        profile: String,
252        /// The profiles `[profiles]` defines.
253        defined: Vec<String>,
254    },
255    /// A repository has no labels, or an empty one.
256    #[error("repository {repository} needs at least one non-empty label")]
257    MissingLabels {
258        /// The repository's `owner/name`.
259        repository: String,
260    },
261    /// `unsafe-dind` without `allow_unsafe = true`.
262    #[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    /// A limit is 0.
268    #[error("{field} is 0; set it above 0")]
269    ZeroLimit {
270        /// The setting, such as `profiles.app.cpus`.
271        field: String,
272    },
273    /// The caps add up past `u64::MAX` bytes.
274    #[error("slots × memory over all profiles plus the mirror is too large to add up")]
275    BudgetOverflow,
276    /// The caps add up to more than the slice's `MemoryMax`.
277    #[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        /// What every runner and the mirror may use at once.
284        total: ByteSize,
285        /// The slice runners run in.
286        slice: String,
287        /// The slice's `MemoryMax`.
288        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/// What a valid config should still fix.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub enum Warning {
303    /// The slice has no unit file, so systemd creates it without limits.
304    UnlimitedSlice {
305        /// The slice runners run in.
306        slice: String,
307        /// What every runner and the mirror may use at once.
308        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    /// Reads and parses a config file.
326    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    /// Parses a config from TOML text.
335    pub fn parse(text: &str) -> Result<Config, ConfigError> {
336        Ok(toml::from_str(text)?)
337    }
338
339    /// Parses `text` and validates it against the `MemoryMax` that `memory_max` reads for the slice it names:
340    /// what `runnerd check-config` prints and `POST /v1/config/check` answers. Returns the config if it is valid.
341    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    /// The memory every runner and the mirror may use at once.
382    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    /// Checks the config against the slice's `MemoryMax` (`None` when the slice is unlimited).
401    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
464/// Reads a slice's `MemoryMax` from systemd; `None` when it is unlimited.
465pub 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
483/// Parses a `MemoryMax` value as `systemctl show` prints it: bytes, or `infinity`.
484pub 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/// A size in bytes, written with a binary unit as Docker and systemd do: `512M`, `9G`, `32.5G`.
493#[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        // `u64::MAX as f64` is 2^64, the first value out of range.
527        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
557/// A duration as Go and the registry write it: `30s`, `90m`, `168h`.
558fn 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;