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.5`.
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    /// The most log stored per job; later lines are dropped. Default 50M.
209    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/// Why a config cannot be used.
225#[derive(Debug, thiserror::Error)]
226pub enum ConfigError {
227    /// The file cannot be read.
228    #[error("cannot read {}: {source}", path.display())]
229    Read {
230        /// The config file.
231        path: PathBuf,
232        /// Why reading it failed.
233        source: std::io::Error,
234    },
235    /// The file is not valid TOML or does not match the config's shape.
236    #[error(transparent)]
237    Parse(#[from] toml::de::Error),
238    /// A repository names a profile `[profiles]` does not define.
239    #[error(
240        "repository {repository} uses profile {profile:?}, which is not defined under [profiles]"
241    )]
242    UnknownProfile {
243        /// The repository's `owner/name`.
244        repository: String,
245        /// The profile it names.
246        profile: String,
247    },
248    /// A repository has no labels, or an empty one.
249    #[error("repository {repository} needs at least one non-empty label")]
250    MissingLabels {
251        /// The repository's `owner/name`.
252        repository: String,
253    },
254    /// `unsafe-dind` without `allow_unsafe = true`.
255    #[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    /// A limit is 0.
261    #[error("{field} is 0; set it above 0")]
262    ZeroLimit {
263        /// The setting, such as `profiles.app.cpus`.
264        field: String,
265    },
266    /// The caps add up past `u64::MAX` bytes.
267    #[error("slots × memory over all profiles plus the mirror is too large to add up")]
268    BudgetOverflow,
269    /// The caps add up to more than the slice's `MemoryMax`.
270    #[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        /// What every runner and the mirror may use at once.
276        total: ByteSize,
277        /// The slice runners run in.
278        slice: String,
279        /// The slice's `MemoryMax`.
280        memory_max: ByteSize,
281    },
282}
283
284/// What a valid config should still fix.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub enum Warning {
287    /// The slice has no unit file, so systemd creates it without limits.
288    UnlimitedSlice {
289        /// The slice runners run in.
290        slice: String,
291        /// What every runner and the mirror may use at once.
292        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    /// Reads and parses a config file.
310    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    /// Parses a config from TOML text.
319    pub fn parse(text: &str) -> Result<Config, ConfigError> {
320        Ok(toml::from_str(text)?)
321    }
322
323    /// Parses `text` and validates it against the `MemoryMax` that `memory_max` reads for the slice it names:
324    /// what `runnerd check-config` prints and `POST /v1/config/check` answers. Returns the config if it is valid.
325    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    /// The memory every runner and the mirror may use at once.
366    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    /// Checks the config against the slice's `MemoryMax` (`None` when the slice is unlimited).
385    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
447/// Reads a slice's `MemoryMax` from systemd; `None` when it is unlimited.
448pub 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
466/// Parses a `MemoryMax` value as `systemctl show` prints it: bytes, or `infinity`.
467pub 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/// A size in bytes, written with a binary unit as Docker and systemd do: `512M`, `9G`, `32.5G`.
476#[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        // `u64::MAX as f64` is 2^64, the first value out of range.
510        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
540/// A duration as Go and the registry write it: `30s`, `90m`, `168h`.
541fn 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;