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::net::{Ipv4Addr, Ipv6Addr};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::str::FromStr;
11use std::time::Duration;
12
13use serde::{Deserialize, Deserializer, de};
14
15use crate::api;
16
17/// A parsed `runnerd.toml`. `validate` checks it against the host's budget.
18#[derive(Debug, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Config {
21    /// The host's budget and isolation.
22    pub host: Host,
23    /// The caches runners share.
24    pub caches: Caches,
25    /// Runner profiles by name.
26    pub profiles: BTreeMap<String, Profile>,
27    /// The repositories to serve.
28    pub repositories: Vec<Repository>,
29    /// What is kept of past jobs.
30    #[serde(default)]
31    pub history: History,
32    /// How much the records of failed jobs may keep.
33    #[serde(default)]
34    pub records: Records,
35    /// How sizing advice reads the job history.
36    #[serde(default)]
37    pub sizing: Sizing,
38    /// Who may use the dashboard through another address.
39    #[serde(default)]
40    pub web: Web,
41}
42
43/// `[host]`: where runner containers run and how they are isolated.
44#[derive(Debug, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct Host {
47    /// The systemd slice every runner container is placed in; its `MemoryMax` is the budget.
48    pub slice: String,
49    /// How runner containers are kept from the host.
50    pub isolation: Isolation,
51    /// Must be set for `isolation = "unsafe-dind"`, so a copied config cannot turn it on silently.
52    #[serde(default)]
53    pub allow_unsafe: bool,
54    /// How often GitHub is asked for queued jobs.
55    #[serde(default = "default_poll_interval", deserialize_with = "duration")]
56    pub poll_interval: Duration,
57    /// What a runner must fit in before it starts. Default `caps`.
58    #[serde(default)]
59    pub admission: Admission,
60    /// Set, a runner also starts only when its predicted CPU fits the slice's CPUs times this; absent, CPU is not
61    /// admitted.
62    #[serde(default)]
63    pub cpu_overcommit: Option<f64>,
64}
65
66/// The largest `cpu_overcommit` the config check takes.
67pub const MAX_CPU_OVERCOMMIT: f64 = 16.0;
68
69/// `admission` under `[host]`: what a new runner must fit in the slice's `MemoryMax` beside the running ones.
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Admission {
73    /// Every runner's memory cap: `slots × memory` over all profiles, with the mirror, must fit `MemoryMax`.
74    #[default]
75    Caps,
76    /// Every runner's predicted peak, from the job history; `slots` only bounds how many run at once, and one
77    /// runner of each profile with the mirror must fit `MemoryMax`. Nothing starts while the slice is at 90% of it.
78    Measured,
79}
80
81fn default_poll_interval() -> Duration {
82    Duration::from_secs(15)
83}
84
85/// How runner containers are kept from the host: `isolation` under `[host]`.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum Isolation {
89    /// Sysbox: Docker inside an unprivileged container.
90    Sysbox,
91    /// Privileged Docker-in-Docker: a job can take over the host.
92    UnsafeDind,
93}
94
95/// `[caches]`: what runners share and keep between jobs.
96#[derive(Debug, Clone, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct Caches {
99    /// The Docker Hub mirror.
100    pub mirror: Mirror,
101    /// The shared pnpm store.
102    pub pnpm: CacheLimit,
103    /// The shared Maven repository.
104    pub maven: CacheLimit,
105    /// When a profile has no job running and its slot 1 changed its layers by at least this much, slot 1's
106    /// layers become the profile's new snapshot.
107    #[serde(default = "default_reseed_at")]
108    pub reseed_at: ByteSize,
109    /// A reseed that would make a profile's snapshot larger than this starts it over from slot 1's changes alone,
110    /// or from the image when those are larger too.
111    #[serde(default = "default_snapshot_max")]
112    pub snapshot_max: ByteSize,
113}
114
115fn default_reseed_at() -> ByteSize {
116    ByteSize(2 << 30)
117}
118
119/// Above the 8.7G a profile running Flutter and Android builds needs: its Flutter SDK, NDK and Gradle caches.
120fn default_snapshot_max() -> ByteSize {
121    ByteSize(10 << 30)
122}
123
124fn default_mirror_max_size() -> ByteSize {
125    ByteSize(10 << 30)
126}
127
128/// The Docker Hub pull-through mirror shared by all runners.
129#[derive(Debug, Clone, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct Mirror {
132    /// Whether runners pull through the mirror.
133    pub enabled: bool,
134    /// The mirror container's memory limit.
135    pub memory: ByteSize,
136    /// The mirror container's CPUs.
137    pub cpus: u32,
138    /// How long content not pulled again is kept.
139    #[serde(deserialize_with = "duration")]
140    pub ttl: Duration,
141    /// Its storage is emptied above this size when no job runs.
142    #[serde(default = "default_mirror_max_size")]
143    pub max_size: ByteSize,
144}
145
146/// A size limit on a shared cache.
147#[derive(Debug, Clone, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct CacheLimit {
150    /// The cache is emptied above this size when no job runs.
151    pub max_size: ByteSize,
152}
153
154/// Where runnerd's release workflow publishes the runner images, one tag per stage and version, such as
155/// `ghcr.io/ayagmar/runnerd-runner:jvm-0.0.8`.
156pub const PUBLISHED_RUNNER: &str = "ghcr.io/ayagmar/runnerd-runner";
157
158/// A runner image with its limits; every job of the profile gets one container with these limits.
159#[derive(Debug, Clone, Deserialize)]
160#[serde(try_from = "ProfileToml")]
161pub struct Profile {
162    /// The image its runners run: `image_stage` or `image`, exactly one of them.
163    pub image: Image,
164    /// CPUs per slot.
165    pub cpus: u32,
166    /// Memory per slot; the container's hard limit.
167    pub memory: ByteSize,
168    /// The most jobs of this profile running at once.
169    pub slots: u32,
170    /// Whether a failed job's runner is kept as a record, to open a shell in (`runnerctl shell`).
171    pub record_failures: bool,
172    /// The least `memory` auto sizing lowers it to; `None` for no bound.
173    pub min_memory: Option<ByteSize>,
174    /// The most `memory` auto sizing raises it to; `None` for what the slice's `MemoryMax` holds.
175    pub max_memory: Option<ByteSize>,
176    /// The most `slots` auto sizing raises them to; `None` leaves raising them to the owner.
177    pub max_slots: Option<u32>,
178}
179
180/// The image a profile's runners run.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum Image {
183    /// `image_stage`: a stage of `image/Dockerfile` built on this host as `runnerd-runner:<stage>`.
184    Stage(String),
185    /// `image`: a reference runnerd pulls when Docker lacks it, such as
186    /// `ghcr.io/ayagmar/runnerd-runner:jvm-0.0.8`.
187    Pulled(String),
188}
189
190impl Image {
191    /// The reference Docker runs.
192    pub fn reference(&self) -> String {
193        match self {
194            Image::Stage(stage) => format!("runnerd-runner:{stage}"),
195            Image::Pulled(reference) => reference.clone(),
196        }
197    }
198}
199
200/// `[profiles.<name>]` as written, before `Profile` checks it names one image.
201#[derive(Deserialize)]
202#[serde(deny_unknown_fields)]
203struct ProfileToml {
204    image_stage: Option<String>,
205    image: Option<String>,
206    cpus: u32,
207    memory: ByteSize,
208    slots: u32,
209    #[serde(default)]
210    record_failures: bool,
211    min_memory: Option<ByteSize>,
212    max_memory: Option<ByteSize>,
213    max_slots: Option<u32>,
214}
215
216impl TryFrom<ProfileToml> for Profile {
217    type Error = String;
218
219    fn try_from(profile: ProfileToml) -> Result<Profile, String> {
220        let image = match (profile.image_stage, profile.image) {
221            (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
222            (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
223            (Some(_), Some(_)) => {
224                return Err(
225                    "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
226                     not both"
227                        .to_owned(),
228                );
229            }
230            _ => {
231                return Err(
232                    "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
233                        .to_owned(),
234                );
235            }
236        };
237        Ok(Profile {
238            image,
239            cpus: profile.cpus,
240            memory: profile.memory,
241            slots: profile.slots,
242            record_failures: profile.record_failures,
243            min_memory: profile.min_memory,
244            max_memory: profile.max_memory,
245            max_slots: profile.max_slots,
246        })
247    }
248}
249
250/// A repository to serve: `[[repositories]]`.
251#[derive(Debug, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Repository {
254    /// `owner/name` on GitHub.
255    pub name: String,
256    /// The profile its jobs run in.
257    pub profile: String,
258    /// The labels its runners register with; a job runs on them when every label it asks for is one of these.
259    pub labels: Vec<String>,
260}
261
262/// `[history]`: the job history database and how long it keeps what. Every field has a default.
263#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
264#[serde(deny_unknown_fields, default)]
265pub struct History {
266    /// The SQLite database; default `$XDG_STATE_HOME/runnerd/runnerd.db`.
267    pub database: Option<PathBuf>,
268    /// How long CPU and memory samples are kept; a job's totals and peaks stay. Default 7 days.
269    #[serde(deserialize_with = "duration")]
270    pub samples: Duration,
271    /// How long a finished job's log is kept. Default 30 days.
272    #[serde(deserialize_with = "duration")]
273    pub logs: Duration,
274    /// How long events are kept for clients resuming with `Last-Event-ID`. Default 30 days.
275    #[serde(deserialize_with = "duration")]
276    pub events: Duration,
277    /// How long a finished job's record is kept, with its steps, totals and runner. Default 90 days.
278    #[serde(deserialize_with = "duration")]
279    pub jobs: Duration,
280    /// The most log stored per job; later lines are dropped. Default 50M.
281    pub log_max: ByteSize,
282}
283
284impl Default for History {
285    fn default() -> History {
286        History {
287            database: None,
288            samples: Duration::from_hours(7 * 24),
289            logs: Duration::from_hours(30 * 24),
290            events: Duration::from_hours(30 * 24),
291            jobs: Duration::from_hours(90 * 24),
292            log_max: ByteSize(50 << 20),
293        }
294    }
295}
296
297/// `[records]`: how much the records of failed jobs may keep (`record_failures` under a profile turns them on).
298/// When a new record would pass a limit, the oldest records go first. Every field has a default.
299#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
300#[serde(deny_unknown_fields, default)]
301pub struct Records {
302    /// The most disk all records may take together. Default 20G.
303    pub max_total: ByteSize,
304    /// The most records kept. Default 10.
305    pub max_count: u32,
306    /// How long a record is kept. Default 7 days.
307    #[serde(deserialize_with = "duration")]
308    pub max_age: Duration,
309    /// The disk Docker must keep free: no record is made that would leave less. Default 10G.
310    pub min_free: ByteSize,
311}
312
313impl Default for Records {
314    fn default() -> Records {
315        Records {
316            max_total: ByteSize(20 << 30),
317            max_count: 10,
318            max_age: Duration::from_hours(7 * 24),
319            min_free: ByteSize(10 << 30),
320        }
321    }
322}
323
324/// `[sizing]`: how `runnerctl sizing` turns the job history's peaks into advice, and whether the daemon applies it
325/// itself. Every field has a default.
326#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
327#[serde(deny_unknown_fields, default)]
328pub struct Sizing {
329    /// The percent added to the largest sampled memory peak: a container is sampled every 2 s, so a spike between
330    /// two samples goes unseen. Default 15.
331    pub margin: u32,
332    /// The fewest jobs a profile, or a job of a repository's workflow, needs before its peaks become advice.
333    /// Default 10.
334    pub min_jobs: u32,
335    /// Whether the daemon writes each profile's advice into the config itself, within each profile's
336    /// `min_memory`, `max_memory` and `max_slots`. Default off.
337    pub auto: bool,
338    /// The least time between two changes auto sizing makes to one profile. Default 24 hours.
339    #[serde(deserialize_with = "duration")]
340    pub interval: Duration,
341    /// The most a change moves a profile's `memory`, in percent of it. Default 25.
342    pub max_step: u32,
343}
344
345impl Default for Sizing {
346    fn default() -> Sizing {
347        Sizing {
348            margin: 15,
349            min_jobs: 10,
350            auto: false,
351            interval: Duration::from_hours(24),
352            max_step: 25,
353        }
354    }
355}
356
357/// `[web]`: the dashboard behind a reverse proxy, such as `tailscale serve`. Read when the daemon starts.
358#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
359#[serde(deny_unknown_fields, default)]
360pub struct Web {
361    /// The origins, besides the dashboard's own loopback address, whose pages may write: pause, reseed, save the
362    /// config. Default none.
363    pub origins: Vec<Origin>,
364}
365
366/// An origin as a browser sends it in `Origin`: `https://host[:port]`, or `http://` for a loopback host, in lower
367/// case, without the scheme's default port, and an address written as the URL standard serializes it. Nothing after
368/// the port: no path, query or user.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct Origin(String);
371
372impl Origin {
373    /// The origin as a browser sends it, such as `https://host.example.ts.net:8443`.
374    pub fn as_str(&self) -> &str {
375        &self.0
376    }
377}
378
379impl FromStr for Origin {
380    type Err = String;
381
382    fn from_str(s: &str) -> Result<Origin, String> {
383        let invalid = |why: &str| {
384            format!(
385                "invalid origin {s:?}: {why}; write the scheme, host and port only, such as \
386                 \"https://host.example.ts.net:8443\""
387            )
388        };
389        let (scheme, rest) = s
390            .split_once("://")
391            .ok_or_else(|| invalid("it has no scheme"))?;
392        let scheme = scheme.to_ascii_lowercase();
393        if rest.contains(['/', '?', '#']) {
394            return Err(invalid("it has a path"));
395        }
396        if rest.contains('@') {
397            return Err(invalid("it names a user"));
398        }
399        let (host, port) = match rest.strip_prefix('[') {
400            Some(v6) => {
401                let (address, after) = v6
402                    .split_once(']')
403                    .ok_or_else(|| invalid("its IPv6 address has no closing ]"))?;
404                let port = match after {
405                    "" => None,
406                    _ => Some(after.strip_prefix(':').ok_or_else(|| invalid("its port"))?),
407                };
408                (format!("[{address}]"), port)
409            }
410            None => match rest.split_once(':') {
411                Some((host, port)) => (host.to_owned(), Some(port)),
412                None => (rest.to_owned(), None),
413            },
414        };
415        let host = host.to_ascii_lowercase();
416        let not_a_host = || invalid("its host is not a name or an address");
417        // An address is kept as a browser serializes it, or it would never match the `Origin` the browser sends.
418        let (host, loopback) =
419            if let Some(v6) = host.strip_prefix('[').and_then(|v6| v6.strip_suffix(']')) {
420                let address: Ipv6Addr = v6.parse().map_err(|_| not_a_host())?;
421                (format!("[{}]", ipv6_origin(address)), address.is_loopback())
422            } else {
423                if host.is_empty()
424                    || !host
425                        .chars()
426                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
427                {
428                    return Err(not_a_host());
429                }
430                // As the URL standard reads a host: one whose last label is a number is an IPv4 address.
431                let name = host.strip_suffix('.').unwrap_or(&host);
432                let last = name.rsplit('.').next().unwrap_or_default();
433                let numeric = !last.is_empty()
434                    && (last.bytes().all(|byte| byte.is_ascii_digit())
435                        || last
436                            .strip_prefix("0x")
437                            .is_some_and(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit())));
438                if numeric {
439                    let address: Ipv4Addr = name.parse().map_err(|_| {
440                        invalid("its IPv4 address is not four decimal numbers, such as 192.0.2.1")
441                    })?;
442                    (address.to_string(), address.is_loopback())
443                } else {
444                    let loopback = host == "localhost";
445                    (host, loopback)
446                }
447            };
448        let port = port
449            .map(|port| {
450                Some(port)
451                    .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit()))
452                    .and_then(|port| port.parse::<u16>().ok())
453                    .filter(|port| *port != 0)
454                    .ok_or_else(|| invalid("its port is not a number from 1 to 65535"))
455            })
456            .transpose()?;
457        let default_port = match scheme.as_str() {
458            "https" => 443,
459            "http" if loopback => 80,
460            "http" => {
461                return Err(invalid(
462                    "http is for a loopback host only, since the session cookie would cross the network in \
463                     the clear; use https",
464                ));
465            }
466            _ => return Err(invalid("its scheme is not https")),
467        };
468        Ok(Origin(match port {
469            Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
470            _ => format!("{scheme}://{host}"),
471        }))
472    }
473}
474
475/// An IPv6 address as the URL standard serializes it: lower-case hex pieces, the first longest run of two or more
476/// zero pieces as `::`, and no dotted IPv4 tail.
477fn ipv6_origin(address: Ipv6Addr) -> String {
478    let pieces = address.segments();
479    let (mut zeros_at, mut zeros) = (0, 0);
480    let mut at = 0;
481    while at < pieces.len() {
482        let run = pieces[at..].iter().take_while(|piece| **piece == 0).count();
483        if run > zeros {
484            (zeros_at, zeros) = (at, run);
485        }
486        at += run.max(1);
487    }
488    let hex = |pieces: &[u16]| {
489        pieces
490            .iter()
491            .map(|piece| format!("{piece:x}"))
492            .collect::<Vec<_>>()
493            .join(":")
494    };
495    if zeros < 2 {
496        return hex(&pieces);
497    }
498    format!(
499        "{}::{}",
500        hex(&pieces[..zeros_at]),
501        hex(&pieces[zeros_at + zeros..])
502    )
503}
504
505impl fmt::Display for Origin {
506    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507        f.write_str(&self.0)
508    }
509}
510
511impl<'de> Deserialize<'de> for Origin {
512    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
513        String::deserialize(deserializer)?
514            .parse()
515            .map_err(de::Error::custom)
516    }
517}
518
519/// Why a config cannot be used.
520#[derive(Debug, thiserror::Error)]
521pub enum ConfigError {
522    /// The file cannot be read.
523    #[error("cannot read {}: {source}", path.display())]
524    Read {
525        /// The config file.
526        path: PathBuf,
527        /// Why reading it failed.
528        source: std::io::Error,
529    },
530    /// The file is not valid TOML or does not match the config's shape.
531    #[error(transparent)]
532    Parse(#[from] toml::de::Error),
533    /// A repository names a profile `[profiles]` does not define.
534    #[error(
535        "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
536        defined_profiles(.defined)
537    )]
538    UnknownProfile {
539        /// The repository's `owner/name`.
540        repository: String,
541        /// The profile it names.
542        profile: String,
543        /// The profiles `[profiles]` defines.
544        defined: Vec<String>,
545    },
546    /// A repository has no labels, or an empty one.
547    #[error("repository {repository} needs at least one non-empty label")]
548    MissingLabels {
549        /// The repository's `owner/name`.
550        repository: String,
551    },
552    /// `unsafe-dind` without `allow_unsafe = true`.
553    #[error(
554        "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
555         set allow_unsafe = true under [host] to use it anyway"
556    )]
557    UnsafeNotAllowed,
558    /// A limit is 0.
559    #[error("{field} is 0; set it above 0")]
560    ZeroLimit {
561        /// The setting, such as `profiles.app.cpus`.
562        field: String,
563    },
564    /// `cpu_overcommit` is not a number above 0 and at most `MAX_CPU_OVERCOMMIT`.
565    #[error(
566        "host.cpu_overcommit is {value}; set it above 0 and at most {MAX_CPU_OVERCOMMIT}, such as 1.0"
567    )]
568    CpuOvercommit {
569        /// The value set.
570        value: f64,
571    },
572    /// A profile's `memory` or `slots` are outside its `min_memory`, `max_memory` or `max_slots`, or its
573    /// `min_memory` is above its `max_memory`.
574    #[error("profiles.{profile}: {why}")]
575    SizingBounds {
576        /// The profile.
577        profile: String,
578        /// Which bound, such as `memory is above its max_memory`.
579        why: &'static str,
580    },
581    /// The caps add up past `u64::MAX` bytes.
582    #[error("slots × memory over all profiles plus the mirror is too large to add up")]
583    BudgetOverflow,
584    /// `admission = "measured"` with a slice that has no `MemoryMax` to admit against.
585    #[error(
586        "admission = \"measured\" admits runners against the slice {slice}'s MemoryMax, and it has none; install \
587         a unit file for it (`runnerd doctor` prints one)"
588    )]
589    MeasuredWithoutMemoryMax {
590        /// The slice runners run in.
591        slice: String,
592    },
593    /// Under `admission = "measured"`, one runner of a profile and the mirror do not fit `MemoryMax`.
594    #[error(
595        "one runner of {profile} and the mirror take {needs}, the slice {slice}'s MemoryMax is {memory_max}; lower \
596         its `memory` or raise the slice's MemoryMax"
597    )]
598    ProfileOverBudget {
599        /// The profile.
600        profile: String,
601        /// Its `memory` and the mirror's.
602        needs: ByteSize,
603        /// The slice runners run in.
604        slice: String,
605        /// The slice's `MemoryMax`.
606        memory_max: ByteSize,
607    },
608    /// The caps add up to more than the slice's `MemoryMax`.
609    #[error(
610        "slots × memory over all profiles plus the mirror is {total}, \
611         the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
612         MemoryMax (`runnerd doctor` prints its unit)"
613    )]
614    OverBudget {
615        /// What every runner and the mirror may use at once.
616        total: ByteSize,
617        /// The slice runners run in.
618        slice: String,
619        /// The slice's `MemoryMax`.
620        memory_max: ByteSize,
621    },
622}
623
624fn defined_profiles(defined: &[String]) -> String {
625    if defined.is_empty() {
626        "it defines none".to_owned()
627    } else {
628        format!("defined: {}", defined.join(", "))
629    }
630}
631
632/// What a valid config should still fix.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub enum Warning {
635    /// The slice has no unit file, so systemd creates it without limits.
636    UnlimitedSlice {
637        /// The slice runners run in.
638        slice: String,
639        /// What every runner and the mirror may use at once.
640        total: ByteSize,
641    },
642}
643
644impl fmt::Display for Warning {
645    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
646        match self {
647            Warning::UnlimitedSlice { slice, total } => write!(
648                f,
649                "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
650                 install a unit file for it"
651            ),
652        }
653    }
654}
655
656impl Config {
657    /// Reads and parses a config file.
658    pub fn load(path: &Path) -> Result<Config, ConfigError> {
659        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
660            path: path.to_owned(),
661            source,
662        })?;
663        Config::parse(&text)
664    }
665
666    /// Parses a config from TOML text.
667    pub fn parse(text: &str) -> Result<Config, ConfigError> {
668        Ok(toml::from_str(text)?)
669    }
670
671    /// Parses `text` and validates it against the `MemoryMax` that `memory_max` reads for the slice it names:
672    /// what `runnerd check-config` prints and `POST /v1/config/check` answers. Returns the config if it is valid.
673    pub fn check(
674        text: &str,
675        memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
676    ) -> (Option<Config>, api::ConfigCheck) {
677        let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
678            api::ConfigCheck {
679                valid: false,
680                message,
681                warnings: Vec::new(),
682                memory_caps: memory_caps.map(|caps| caps.0),
683                memory_max: max.map(|max| max.0),
684                revision: None,
685            }
686        };
687        let config = match Config::parse(text) {
688            Ok(config) => config,
689            Err(error) => return (None, invalid(error.to_string(), None, None)),
690        };
691        let caps = config.memory_caps().ok();
692        let max = match memory_max(&config.host.slice) {
693            Ok(max) => max,
694            Err(error) => return (None, invalid(error.to_string(), caps, None)),
695        };
696        match config
697            .validate(max)
698            .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
699        {
700            Ok((warnings, total)) => {
701                let message = match (config.host.admission, max) {
702                    (Admission::Measured, Some(max)) if total > max => format!(
703                        "valid, memory caps sum to {total}, over the {max} MemoryMax: admission = \"measured\" \
704                         starts what the predicted peaks fit"
705                    ),
706                    _ => format!("valid, memory caps sum to {total}"),
707                };
708                let check = api::ConfigCheck {
709                    valid: true,
710                    message,
711                    warnings: warnings.iter().map(ToString::to_string).collect(),
712                    memory_caps: Some(total.0),
713                    memory_max: max.map(|max| max.0),
714                    revision: None,
715                };
716                (Some(config), check)
717            }
718            Err(error) => (None, invalid(error.to_string(), caps, max)),
719        }
720    }
721
722    /// The mirror's memory cap, 0 when it is disabled.
723    pub fn mirror_memory(&self) -> u64 {
724        if self.caches.mirror.enabled {
725            self.caches.mirror.memory.0
726        } else {
727            0
728        }
729    }
730
731    /// The memory every runner and the mirror may use at once.
732    pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
733        let mirror = self.mirror_memory();
734        self.profiles
735            .values()
736            .try_fold(mirror, |total, p| {
737                p.memory
738                    .0
739                    .checked_mul(u64::from(p.slots))?
740                    .checked_add(total)
741            })
742            .map(ByteSize)
743            .ok_or(ConfigError::BudgetOverflow)
744    }
745
746    /// Refuses a profile outside the bounds auto sizing keeps it in, or whose bounds cross.
747    fn check_sizing_bounds(&self) -> Result<(), ConfigError> {
748        for (name, p) in &self.profiles {
749            let memory = p.memory.0;
750            let field = if p
751                .min_memory
752                .zip(p.max_memory)
753                .is_some_and(|(min, max)| min.0 > max.0)
754            {
755                "min_memory is above its max_memory"
756            } else if p.min_memory.is_some_and(|min| memory < min.0) {
757                "memory is below its min_memory"
758            } else if p.max_memory.is_some_and(|max| memory > max.0) {
759                "memory is above its max_memory"
760            } else if p.max_slots.is_some_and(|max| p.slots > max) {
761                "slots are above its max_slots"
762            } else {
763                continue;
764            };
765            return Err(ConfigError::SizingBounds {
766                profile: name.clone(),
767                why: field,
768            });
769        }
770        Ok(())
771    }
772
773    /// Checks the config against the slice's `MemoryMax` (`None` when the slice is unlimited).
774    pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
775        if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
776            return Err(ConfigError::UnsafeNotAllowed);
777        }
778        let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
779            [
780                (p.memory.0 == 0, format!("profiles.{name}.memory")),
781                (p.cpus == 0, format!("profiles.{name}.cpus")),
782                (p.slots == 0, format!("profiles.{name}.slots")),
783                (p.max_slots == Some(0), format!("profiles.{name}.max_slots")),
784            ]
785        });
786        let mirror = &self.caches.mirror;
787        let mirror_zero_limits = [
788            (
789                mirror.enabled && mirror.memory.0 == 0,
790                "caches.mirror.memory".to_owned(),
791            ),
792            (
793                mirror.enabled && mirror.cpus == 0,
794                "caches.mirror.cpus".to_owned(),
795            ),
796            (
797                self.host.poll_interval.is_zero(),
798                "host.poll_interval".to_owned(),
799            ),
800            (self.records.max_count == 0, "records.max_count".to_owned()),
801            (self.sizing.min_jobs == 0, "sizing.min_jobs".to_owned()),
802            (self.sizing.interval.is_zero(), "sizing.interval".to_owned()),
803            (self.sizing.max_step == 0, "sizing.max_step".to_owned()),
804            (
805                self.records.max_total.0 == 0,
806                "records.max_total".to_owned(),
807            ),
808        ];
809        if let Some((_, field)) = zero_limits
810            .chain(mirror_zero_limits)
811            .find(|(zero, _)| *zero)
812        {
813            return Err(ConfigError::ZeroLimit { field });
814        }
815        self.check_sizing_bounds()?;
816        if let Some(value) = self
817            .host
818            .cpu_overcommit
819            .filter(|value| !(*value > 0.0 && *value <= MAX_CPU_OVERCOMMIT))
820        {
821            return Err(ConfigError::CpuOvercommit { value });
822        }
823        for repository in &self.repositories {
824            if !self.profiles.contains_key(&repository.profile) {
825                return Err(ConfigError::UnknownProfile {
826                    repository: repository.name.clone(),
827                    profile: repository.profile.clone(),
828                    defined: self.profiles.keys().cloned().collect(),
829                });
830            }
831            if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
832            {
833                return Err(ConfigError::MissingLabels {
834                    repository: repository.name.clone(),
835                });
836            }
837        }
838        let total = self.memory_caps()?;
839        if self.host.admission == Admission::Measured {
840            let Some(memory_max) = memory_max else {
841                return Err(ConfigError::MeasuredWithoutMemoryMax {
842                    slice: self.host.slice.clone(),
843                });
844            };
845            let mirror = self.mirror_memory();
846            if let Some((profile, needs)) = self
847                .profiles
848                .iter()
849                .map(|(name, profile)| (name, profile.memory.0.saturating_add(mirror)))
850                .find(|(_, needs)| *needs > memory_max.0)
851            {
852                return Err(ConfigError::ProfileOverBudget {
853                    profile: profile.clone(),
854                    needs: ByteSize(needs),
855                    slice: self.host.slice.clone(),
856                    memory_max,
857                });
858            }
859            return Ok(Vec::new());
860        }
861        match memory_max {
862            Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
863                total,
864                slice: self.host.slice.clone(),
865                memory_max,
866            }),
867            Some(_) => Ok(Vec::new()),
868            None => Ok(vec![Warning::UnlimitedSlice {
869                slice: self.host.slice.clone(),
870                total,
871            }]),
872        }
873    }
874}
875
876/// Why `resize_profile` cannot change a config.
877#[derive(Debug, thiserror::Error)]
878pub enum ResizeError {
879    /// The text is not valid TOML.
880    #[error(transparent)]
881    Parse(#[from] toml_edit::TomlError),
882    /// `[profiles]` does not define the profile.
883    #[error("the config has no profile {0:?} under [profiles]")]
884    NoProfile(String),
885}
886
887/// `text` with the profile's `memory` and `slots` replaced, everything else as written, comments included: what
888/// applying a sizing suggestion sends to `PUT /v1/config`, which validates it.
889pub fn resize_profile(
890    text: &str,
891    profile: &str,
892    resize: api::Resize,
893) -> Result<String, ResizeError> {
894    let mut document: toml_edit::DocumentMut = text.parse()?;
895    let table = document
896        .get_mut("profiles")
897        .and_then(|profiles| profiles.get_mut(profile))
898        .and_then(toml_edit::Item::as_table_like_mut)
899        .ok_or_else(|| ResizeError::NoProfile(profile.to_owned()))?;
900    let values = [
901        (
902            "memory",
903            toml_edit::Value::from(ByteSize(resize.memory).to_string()),
904        ),
905        ("slots", toml_edit::Value::from(i64::from(resize.slots))),
906    ];
907    for (key, mut value) in values {
908        // The comment after the old value stays after the new one.
909        if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) {
910            *value.decor_mut() = old.decor().clone();
911        }
912        table.insert(key, toml_edit::Item::Value(value));
913    }
914    Ok(document.to_string())
915}
916
917/// A config text's revision: a hash of its bytes (64-bit FNV-1a, in hex), the same in every runnerd. `PUT
918/// /v1/config?revision=` refuses a text sent against another revision than the daemon's.
919pub fn revision(text: &str) -> String {
920    let hash = text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
921        (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
922    });
923    format!("{hash:016x}")
924}
925
926/// Reads a slice's `MemoryMax` from systemd; `None` when it is unlimited.
927pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
928    let output = Command::new("systemctl")
929        .args(["show", slice, "--property=MemoryMax", "--value"])
930        .output()?;
931    if !output.status.success() {
932        return Err(std::io::Error::other(format!(
933            "systemctl show {slice} failed: {}",
934            String::from_utf8_lossy(&output.stderr).trim()
935        )));
936    }
937    let value = String::from_utf8_lossy(&output.stdout);
938    parse_memory_max(value.trim()).ok_or_else(|| {
939        std::io::Error::other(format!(
940            "systemctl show {slice}: unexpected MemoryMax {value:?}"
941        ))
942    })
943}
944
945/// The CPU budget, in millicores, of `millicores` of CPUs overcommitted `overcommit` times.
946pub fn cpu_budget(millicores: u64, overcommit: f64) -> u64 {
947    #[expect(
948        clippy::cast_precision_loss,
949        clippy::cast_possible_truncation,
950        clippy::cast_sign_loss,
951        reason = "a host's millicores are far below 2^52, and the config check holds the factor above 0 and at \
952                  most 16"
953    )]
954    let budget = (millicores as f64 * overcommit.max(0.0)).round() as u64;
955    budget
956}
957
958/// Reads the CPUs a slice's processes may use from systemd, in millicores: its `EffectiveCPUs` (or `AllowedCPUs`,
959/// else the host's CPUs), less when its `CPUQuota` is lower.
960pub fn slice_cpus(slice: &str) -> std::io::Result<u64> {
961    let output = Command::new("systemctl")
962        .args([
963            "show",
964            slice,
965            "--property=EffectiveCPUs",
966            "--property=AllowedCPUs",
967            "--property=CPUQuotaPerSecUSec",
968        ])
969        .output()?;
970    if !output.status.success() {
971        return Err(std::io::Error::other(format!(
972            "systemctl show {slice} failed: {}",
973            String::from_utf8_lossy(&output.stderr).trim()
974        )));
975    }
976    let host = std::thread::available_parallelism().map_or(1, std::num::NonZero::get) as u64;
977    parse_slice_cpus(&String::from_utf8_lossy(&output.stdout), host).ok_or_else(|| {
978        std::io::Error::other(format!(
979            "systemctl show {slice}: unexpected CPU properties {:?}",
980            String::from_utf8_lossy(&output.stdout).trim()
981        ))
982    })
983}
984
985/// The CPUs, in millicores, that `systemctl show`'s `EffectiveCPUs`, `AllowedCPUs` and `CPUQuotaPerSecUSec` lines
986/// leave a slice on a host with `host` CPUs; `None` when a value does not parse.
987pub fn parse_slice_cpus(properties: &str, host: u64) -> Option<u64> {
988    let value = |name: &str| {
989        properties.lines().find_map(|line| {
990            line.strip_prefix(name)
991                .and_then(|rest| rest.strip_prefix('='))
992                .map(str::trim)
993        })
994    };
995    let set = [value("EffectiveCPUs"), value("AllowedCPUs")]
996        .into_iter()
997        .flatten()
998        .find(|cpus| !cpus.is_empty());
999    let cpus = match set {
1000        Some(set) => cpu_count(set)?,
1001        None => host,
1002    };
1003    let quota = match value("CPUQuotaPerSecUSec") {
1004        None | Some("" | "infinity") => u64::MAX,
1005        Some(quota) => quota_millicores(quota)?,
1006    };
1007    Some(cpus.saturating_mul(1000).min(quota))
1008}
1009
1010/// How many CPUs a set names, as systemd prints it (`0-3 8-11`) or as it is written (`0-3,8,10-11`).
1011fn cpu_count(set: &str) -> Option<u64> {
1012    set.split([',', ' '])
1013        .filter(|range| !range.is_empty())
1014        .try_fold(0u64, |count, range| {
1015            let (first, last) = range.split_once('-').unwrap_or((range, range));
1016            let (first, last): (u64, u64) = (first.parse().ok()?, last.parse().ok()?);
1017            Some(count + last.checked_sub(first)? + 1)
1018        })
1019}
1020
1021/// A `CPUQuotaPerSecUSec` as systemd prints a timespan, such as `800ms`, `2.500000s` or `1min 4s`, as the
1022/// millicores it allows: a second of CPU time each second is 1000.
1023fn quota_millicores(quota: &str) -> Option<u64> {
1024    // Each unit's length in milliseconds, as systemd's `format_timespan` names them.
1025    const UNITS: [(&str, f64); 10] = [
1026        ("y", 31_557_600_000.0),
1027        ("M", 2_629_800_000.0),
1028        ("w", 604_800_000.0),
1029        ("d", 86_400_000.0),
1030        ("h", 3_600_000.0),
1031        ("min", 60_000.0),
1032        ("s", 1000.0),
1033        ("ms", 1.0),
1034        ("us", 0.001),
1035        ("µs", 0.001),
1036    ];
1037    let millicores = quota.split_whitespace().try_fold(0.0, |total, part| {
1038        let at = part.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
1039        let (number, unit) = part.split_at(at);
1040        let per_unit = UNITS
1041            .iter()
1042            .find_map(|(name, per_unit)| (*name == unit).then_some(*per_unit))?;
1043        Some(total + number.parse::<f64>().ok()? * per_unit)
1044    })?;
1045    #[allow(
1046        clippy::cast_possible_truncation,
1047        clippy::cast_sign_loss,
1048        reason = "a quota is a few thousand millicores per CPU, checked finite and not negative first"
1049    )]
1050    (!quota.trim().is_empty() && millicores.is_finite() && millicores >= 0.0)
1051        .then(|| millicores.round() as u64)
1052}
1053
1054/// Parses a `MemoryMax` value as `systemctl show` prints it: bytes, or `infinity`.
1055pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
1056    if value == "infinity" {
1057        Some(None)
1058    } else {
1059        value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
1060    }
1061}
1062
1063/// A size in bytes, written with a binary unit as Docker and systemd do: `512M`, `9G`, `32.5G`.
1064#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1065pub struct ByteSize(pub u64);
1066
1067const UNITS: [(char, u64); 4] = [
1068    ('T', 1 << 40),
1069    ('G', 1 << 30),
1070    ('M', 1 << 20),
1071    ('K', 1 << 10),
1072];
1073
1074impl FromStr for ByteSize {
1075    type Err = String;
1076
1077    #[allow(
1078        clippy::cast_precision_loss,
1079        clippy::cast_possible_truncation,
1080        clippy::cast_sign_loss,
1081        reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
1082    )]
1083    fn from_str(s: &str) -> Result<Self, Self::Err> {
1084        let invalid = || {
1085            format!(
1086                "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
1087            )
1088        };
1089        let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
1090        let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
1091        let number = &s[..s.len() - 1];
1092        if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
1093            return Err(invalid());
1094        }
1095        let number: f64 = number.parse().map_err(|_| invalid())?;
1096        let bytes = (number * *factor as f64).round();
1097        // `u64::MAX as f64` is 2^64, the first value out of range.
1098        if bytes >= u64::MAX as f64 {
1099            return Err(format!("size {s:?} is too large"));
1100        }
1101        Ok(ByteSize(bytes as u64))
1102    }
1103}
1104
1105impl fmt::Display for ByteSize {
1106    #[allow(
1107        clippy::cast_precision_loss,
1108        reason = "shown with two decimals, far coarser than f64's precision"
1109    )]
1110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1111        let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
1112            return write!(f, "{}B", self.0);
1113        };
1114        let number = format!("{:.2}", self.0 as f64 / *factor as f64);
1115        let number = number.trim_end_matches('0').trim_end_matches('.');
1116        write!(f, "{number}{unit}")
1117    }
1118}
1119
1120impl<'de> Deserialize<'de> for ByteSize {
1121    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1122        String::deserialize(deserializer)?
1123            .parse()
1124            .map_err(de::Error::custom)
1125    }
1126}
1127
1128/// Parses a duration as Go and the registry write it, `30s`, `90m`, `168h`, or in days: `7d`.
1129pub fn parse_duration(s: &str) -> Result<Duration, String> {
1130    let invalid = || {
1131        format!(
1132            "invalid duration {s:?}, expected a number and s, m, h or d, such as \"168h\" or \"7d\""
1133        )
1134    };
1135    let (number, factor) = match s.chars().last() {
1136        Some('s') => (&s[..s.len() - 1], 1),
1137        Some('m') => (&s[..s.len() - 1], 60),
1138        Some('h') => (&s[..s.len() - 1], 3600),
1139        Some('d') => (&s[..s.len() - 1], 86400),
1140        _ => return Err(invalid()),
1141    };
1142    let number: u64 = number.parse().map_err(|_| invalid())?;
1143    number
1144        .checked_mul(factor)
1145        .map(Duration::from_secs)
1146        .ok_or_else(invalid)
1147}
1148
1149/// Parses an age as the clients' `--since` and `--older-than` take it: `30m`, `12h`, `7d`, `2w`.
1150pub fn parse_age(text: &str) -> Result<Duration, String> {
1151    let error = || "expected a number and s, m, h, d or w, such as 12h".to_owned();
1152    let split = text.find(|c: char| !c.is_ascii_digit()).ok_or_else(error)?;
1153    let (number, unit) = text.split_at(split);
1154    let number: u64 = number.parse().map_err(|_| error())?;
1155    let unit = match unit {
1156        "s" => 1,
1157        "m" => 60,
1158        "h" => 3600,
1159        "d" => 86_400,
1160        "w" => 7 * 86_400,
1161        _ => return Err(error()),
1162    };
1163    Ok(Duration::from_secs(number.saturating_mul(unit)))
1164}
1165
1166fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
1167    parse_duration(&String::deserialize(deserializer)?).map_err(de::Error::custom)
1168}
1169
1170#[cfg(test)]
1171mod tests;