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    /// How much the records of failed jobs may keep.
32    #[serde(default)]
33    pub records: Records,
34    /// How sizing advice reads the job history.
35    #[serde(default)]
36    pub sizing: Sizing,
37}
38
39/// `[host]`: where runner containers run and how they are isolated.
40#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct Host {
43    /// The systemd slice every runner container is placed in; its `MemoryMax` is the budget.
44    pub slice: String,
45    /// How runner containers are kept from the host.
46    pub isolation: Isolation,
47    /// Must be set for `isolation = "unsafe-dind"`, so a copied config cannot turn it on silently.
48    #[serde(default)]
49    pub allow_unsafe: bool,
50    /// How often GitHub is asked for queued jobs.
51    #[serde(default = "default_poll_interval", deserialize_with = "duration")]
52    pub poll_interval: Duration,
53    /// What a runner must fit in before it starts. Default `caps`.
54    #[serde(default)]
55    pub admission: Admission,
56}
57
58/// `admission` under `[host]`: what a new runner must fit in the slice's `MemoryMax` beside the running ones.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum Admission {
62    /// Every runner's memory cap: `slots × memory` over all profiles, with the mirror, must fit `MemoryMax`.
63    #[default]
64    Caps,
65    /// Every runner's predicted peak, from the job history; `slots` only bounds how many run at once, and one
66    /// runner of each profile with the mirror must fit `MemoryMax`. Nothing starts while the slice is at 90% of it.
67    Measured,
68}
69
70fn default_poll_interval() -> Duration {
71    Duration::from_secs(15)
72}
73
74/// How runner containers are kept from the host: `isolation` under `[host]`.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
76#[serde(rename_all = "kebab-case")]
77pub enum Isolation {
78    /// Sysbox: Docker inside an unprivileged container.
79    Sysbox,
80    /// Privileged Docker-in-Docker: a job can take over the host.
81    UnsafeDind,
82}
83
84/// `[caches]`: what runners share and keep between jobs.
85#[derive(Debug, Clone, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Caches {
88    /// The Docker Hub mirror.
89    pub mirror: Mirror,
90    /// The shared pnpm store.
91    pub pnpm: CacheLimit,
92    /// The shared Maven repository.
93    pub maven: CacheLimit,
94    /// When a profile has no job running and its slot 1 changed its layers by at least this much, slot 1's
95    /// layers become the profile's new snapshot.
96    #[serde(default = "default_reseed_at")]
97    pub reseed_at: ByteSize,
98}
99
100fn default_reseed_at() -> ByteSize {
101    ByteSize(2 << 30)
102}
103
104/// The Docker Hub pull-through mirror shared by all runners.
105#[derive(Debug, Clone, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct Mirror {
108    /// Whether runners pull through the mirror.
109    pub enabled: bool,
110    /// The mirror container's memory limit.
111    pub memory: ByteSize,
112    /// The mirror container's CPUs.
113    pub cpus: u32,
114    /// How long content not pulled again is kept.
115    #[serde(deserialize_with = "duration")]
116    pub ttl: Duration,
117}
118
119/// A size limit on a shared cache.
120#[derive(Debug, Clone, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct CacheLimit {
123    /// The cache is emptied above this size when no job runs.
124    pub max_size: ByteSize,
125}
126
127/// A runner image with its limits; every job of the profile gets one container with these limits.
128#[derive(Debug, Clone, Deserialize)]
129#[serde(try_from = "ProfileToml")]
130pub struct Profile {
131    /// The image its runners run: `image_stage` or `image`, exactly one of them.
132    pub image: Image,
133    /// CPUs per slot.
134    pub cpus: u32,
135    /// Memory per slot; the container's hard limit.
136    pub memory: ByteSize,
137    /// The most jobs of this profile running at once.
138    pub slots: u32,
139    /// Whether a failed job's runner is kept as a record, to open a shell in (`runnerctl shell`).
140    pub record_failures: bool,
141}
142
143/// The image a profile's runners run.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Image {
146    /// `image_stage`: a stage of `image/Dockerfile` built on this host as `runnerd-runner:<stage>`.
147    Stage(String),
148    /// `image`: a reference runnerd pulls when Docker lacks it, such as
149    /// `ghcr.io/ayagmar/runnerd-runner:jvm-0.0.7`.
150    Pulled(String),
151}
152
153impl Image {
154    /// The reference Docker runs.
155    pub fn reference(&self) -> String {
156        match self {
157            Image::Stage(stage) => format!("runnerd-runner:{stage}"),
158            Image::Pulled(reference) => reference.clone(),
159        }
160    }
161}
162
163/// `[profiles.<name>]` as written, before `Profile` checks it names one image.
164#[derive(Deserialize)]
165#[serde(deny_unknown_fields)]
166struct ProfileToml {
167    image_stage: Option<String>,
168    image: Option<String>,
169    cpus: u32,
170    memory: ByteSize,
171    slots: u32,
172    #[serde(default)]
173    record_failures: bool,
174}
175
176impl TryFrom<ProfileToml> for Profile {
177    type Error = String;
178
179    fn try_from(profile: ProfileToml) -> Result<Profile, String> {
180        let image = match (profile.image_stage, profile.image) {
181            (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
182            (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
183            (Some(_), Some(_)) => {
184                return Err(
185                    "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
186                     not both"
187                        .to_owned(),
188                );
189            }
190            _ => {
191                return Err(
192                    "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
193                        .to_owned(),
194                );
195            }
196        };
197        Ok(Profile {
198            image,
199            cpus: profile.cpus,
200            memory: profile.memory,
201            slots: profile.slots,
202            record_failures: profile.record_failures,
203        })
204    }
205}
206
207/// A repository to serve: `[[repositories]]`.
208#[derive(Debug, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct Repository {
211    /// `owner/name` on GitHub.
212    pub name: String,
213    /// The profile its jobs run in.
214    pub profile: String,
215    /// The labels its runners register with; a job runs on them when every label it asks for is one of these.
216    pub labels: Vec<String>,
217}
218
219/// `[history]`: the job history database and how long it keeps what. Every field has a default.
220#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
221#[serde(deny_unknown_fields, default)]
222pub struct History {
223    /// The SQLite database; default `$XDG_STATE_HOME/runnerd/runnerd.db`.
224    pub database: Option<PathBuf>,
225    /// How long CPU and memory samples are kept; a job's totals and peaks stay. Default 7 days.
226    #[serde(deserialize_with = "duration")]
227    pub samples: Duration,
228    /// How long a finished job's log is kept. Default 30 days.
229    #[serde(deserialize_with = "duration")]
230    pub logs: Duration,
231    /// How long events are kept for clients resuming with `Last-Event-ID`. Default 30 days.
232    #[serde(deserialize_with = "duration")]
233    pub events: Duration,
234    /// How long a finished job's record is kept, with its steps, totals and runner. Default 90 days.
235    #[serde(deserialize_with = "duration")]
236    pub jobs: Duration,
237    /// The most log stored per job; later lines are dropped. Default 50M.
238    pub log_max: ByteSize,
239}
240
241impl Default for History {
242    fn default() -> History {
243        History {
244            database: None,
245            samples: Duration::from_hours(7 * 24),
246            logs: Duration::from_hours(30 * 24),
247            events: Duration::from_hours(30 * 24),
248            jobs: Duration::from_hours(90 * 24),
249            log_max: ByteSize(50 << 20),
250        }
251    }
252}
253
254/// `[records]`: how much the records of failed jobs may keep (`record_failures` under a profile turns them on).
255/// When a new record would pass a limit, the oldest records go first. Every field has a default.
256#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
257#[serde(deny_unknown_fields, default)]
258pub struct Records {
259    /// The most disk all records may take together. Default 20G.
260    pub max_total: ByteSize,
261    /// The most records kept. Default 10.
262    pub max_count: u32,
263    /// How long a record is kept. Default 7 days.
264    #[serde(deserialize_with = "duration")]
265    pub max_age: Duration,
266    /// The disk Docker must keep free: no record is made that would leave less. Default 10G.
267    pub min_free: ByteSize,
268}
269
270impl Default for Records {
271    fn default() -> Records {
272        Records {
273            max_total: ByteSize(20 << 30),
274            max_count: 10,
275            max_age: Duration::from_hours(7 * 24),
276            min_free: ByteSize(10 << 30),
277        }
278    }
279}
280
281/// `[sizing]`: how `runnerctl sizing` turns the job history's peaks into advice. Every field has a default.
282#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
283#[serde(deny_unknown_fields, default)]
284pub struct Sizing {
285    /// The percent added to the largest sampled memory peak: a container is sampled every 2 s, so a spike between
286    /// two samples goes unseen. Default 15.
287    pub margin: u32,
288    /// The fewest jobs a profile, or a job of a repository's workflow, needs before its peaks become advice.
289    /// Default 10.
290    pub min_jobs: u32,
291}
292
293impl Default for Sizing {
294    fn default() -> Sizing {
295        Sizing {
296            margin: 15,
297            min_jobs: 10,
298        }
299    }
300}
301
302/// Why a config cannot be used.
303#[derive(Debug, thiserror::Error)]
304pub enum ConfigError {
305    /// The file cannot be read.
306    #[error("cannot read {}: {source}", path.display())]
307    Read {
308        /// The config file.
309        path: PathBuf,
310        /// Why reading it failed.
311        source: std::io::Error,
312    },
313    /// The file is not valid TOML or does not match the config's shape.
314    #[error(transparent)]
315    Parse(#[from] toml::de::Error),
316    /// A repository names a profile `[profiles]` does not define.
317    #[error(
318        "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
319        defined_profiles(.defined)
320    )]
321    UnknownProfile {
322        /// The repository's `owner/name`.
323        repository: String,
324        /// The profile it names.
325        profile: String,
326        /// The profiles `[profiles]` defines.
327        defined: Vec<String>,
328    },
329    /// A repository has no labels, or an empty one.
330    #[error("repository {repository} needs at least one non-empty label")]
331    MissingLabels {
332        /// The repository's `owner/name`.
333        repository: String,
334    },
335    /// `unsafe-dind` without `allow_unsafe = true`.
336    #[error(
337        "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
338         set allow_unsafe = true under [host] to use it anyway"
339    )]
340    UnsafeNotAllowed,
341    /// A limit is 0.
342    #[error("{field} is 0; set it above 0")]
343    ZeroLimit {
344        /// The setting, such as `profiles.app.cpus`.
345        field: String,
346    },
347    /// The caps add up past `u64::MAX` bytes.
348    #[error("slots × memory over all profiles plus the mirror is too large to add up")]
349    BudgetOverflow,
350    /// `admission = "measured"` with a slice that has no `MemoryMax` to admit against.
351    #[error(
352        "admission = \"measured\" admits runners against the slice {slice}'s MemoryMax, and it has none; install \
353         a unit file for it (`runnerd doctor` prints one)"
354    )]
355    MeasuredWithoutMemoryMax {
356        /// The slice runners run in.
357        slice: String,
358    },
359    /// Under `admission = "measured"`, one runner of a profile and the mirror do not fit `MemoryMax`.
360    #[error(
361        "one runner of {profile} and the mirror take {needs}, the slice {slice}'s MemoryMax is {memory_max}; lower \
362         its `memory` or raise the slice's MemoryMax"
363    )]
364    ProfileOverBudget {
365        /// The profile.
366        profile: String,
367        /// Its `memory` and the mirror's.
368        needs: ByteSize,
369        /// The slice runners run in.
370        slice: String,
371        /// The slice's `MemoryMax`.
372        memory_max: ByteSize,
373    },
374    /// The caps add up to more than the slice's `MemoryMax`.
375    #[error(
376        "slots × memory over all profiles plus the mirror is {total}, \
377         the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
378         MemoryMax (`runnerd doctor` prints its unit)"
379    )]
380    OverBudget {
381        /// What every runner and the mirror may use at once.
382        total: ByteSize,
383        /// The slice runners run in.
384        slice: String,
385        /// The slice's `MemoryMax`.
386        memory_max: ByteSize,
387    },
388}
389
390fn defined_profiles(defined: &[String]) -> String {
391    if defined.is_empty() {
392        "it defines none".to_owned()
393    } else {
394        format!("defined: {}", defined.join(", "))
395    }
396}
397
398/// What a valid config should still fix.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub enum Warning {
401    /// The slice has no unit file, so systemd creates it without limits.
402    UnlimitedSlice {
403        /// The slice runners run in.
404        slice: String,
405        /// What every runner and the mirror may use at once.
406        total: ByteSize,
407    },
408}
409
410impl fmt::Display for Warning {
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        match self {
413            Warning::UnlimitedSlice { slice, total } => write!(
414                f,
415                "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
416                 install a unit file for it"
417            ),
418        }
419    }
420}
421
422impl Config {
423    /// Reads and parses a config file.
424    pub fn load(path: &Path) -> Result<Config, ConfigError> {
425        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
426            path: path.to_owned(),
427            source,
428        })?;
429        Config::parse(&text)
430    }
431
432    /// Parses a config from TOML text.
433    pub fn parse(text: &str) -> Result<Config, ConfigError> {
434        Ok(toml::from_str(text)?)
435    }
436
437    /// Parses `text` and validates it against the `MemoryMax` that `memory_max` reads for the slice it names:
438    /// what `runnerd check-config` prints and `POST /v1/config/check` answers. Returns the config if it is valid.
439    pub fn check(
440        text: &str,
441        memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
442    ) -> (Option<Config>, api::ConfigCheck) {
443        let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
444            api::ConfigCheck {
445                valid: false,
446                message,
447                warnings: Vec::new(),
448                memory_caps: memory_caps.map(|caps| caps.0),
449                memory_max: max.map(|max| max.0),
450            }
451        };
452        let config = match Config::parse(text) {
453            Ok(config) => config,
454            Err(error) => return (None, invalid(error.to_string(), None, None)),
455        };
456        let caps = config.memory_caps().ok();
457        let max = match memory_max(&config.host.slice) {
458            Ok(max) => max,
459            Err(error) => return (None, invalid(error.to_string(), caps, None)),
460        };
461        match config
462            .validate(max)
463            .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
464        {
465            Ok((warnings, total)) => {
466                let message = match (config.host.admission, max) {
467                    (Admission::Measured, Some(max)) if total > max => format!(
468                        "valid, memory caps sum to {total}, over the {max} MemoryMax: admission = \"measured\" \
469                         starts what the predicted peaks fit"
470                    ),
471                    _ => format!("valid, memory caps sum to {total}"),
472                };
473                let check = api::ConfigCheck {
474                    valid: true,
475                    message,
476                    warnings: warnings.iter().map(ToString::to_string).collect(),
477                    memory_caps: Some(total.0),
478                    memory_max: max.map(|max| max.0),
479                };
480                (Some(config), check)
481            }
482            Err(error) => (None, invalid(error.to_string(), caps, max)),
483        }
484    }
485
486    /// The memory every runner and the mirror may use at once.
487    pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
488        let mirror = if self.caches.mirror.enabled {
489            self.caches.mirror.memory.0
490        } else {
491            0
492        };
493        self.profiles
494            .values()
495            .try_fold(mirror, |total, p| {
496                p.memory
497                    .0
498                    .checked_mul(u64::from(p.slots))?
499                    .checked_add(total)
500            })
501            .map(ByteSize)
502            .ok_or(ConfigError::BudgetOverflow)
503    }
504
505    /// Checks the config against the slice's `MemoryMax` (`None` when the slice is unlimited).
506    pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
507        if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
508            return Err(ConfigError::UnsafeNotAllowed);
509        }
510        let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
511            [
512                (p.memory.0 == 0, format!("profiles.{name}.memory")),
513                (p.cpus == 0, format!("profiles.{name}.cpus")),
514                (p.slots == 0, format!("profiles.{name}.slots")),
515            ]
516        });
517        let mirror = &self.caches.mirror;
518        let mirror_zero_limits = [
519            (
520                mirror.enabled && mirror.memory.0 == 0,
521                "caches.mirror.memory".to_owned(),
522            ),
523            (
524                mirror.enabled && mirror.cpus == 0,
525                "caches.mirror.cpus".to_owned(),
526            ),
527            (
528                self.host.poll_interval.is_zero(),
529                "host.poll_interval".to_owned(),
530            ),
531            (self.records.max_count == 0, "records.max_count".to_owned()),
532            (self.sizing.min_jobs == 0, "sizing.min_jobs".to_owned()),
533            (
534                self.records.max_total.0 == 0,
535                "records.max_total".to_owned(),
536            ),
537        ];
538        if let Some((_, field)) = zero_limits
539            .chain(mirror_zero_limits)
540            .find(|(zero, _)| *zero)
541        {
542            return Err(ConfigError::ZeroLimit { field });
543        }
544        for repository in &self.repositories {
545            if !self.profiles.contains_key(&repository.profile) {
546                return Err(ConfigError::UnknownProfile {
547                    repository: repository.name.clone(),
548                    profile: repository.profile.clone(),
549                    defined: self.profiles.keys().cloned().collect(),
550                });
551            }
552            if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
553            {
554                return Err(ConfigError::MissingLabels {
555                    repository: repository.name.clone(),
556                });
557            }
558        }
559        let total = self.memory_caps()?;
560        if self.host.admission == Admission::Measured {
561            let Some(memory_max) = memory_max else {
562                return Err(ConfigError::MeasuredWithoutMemoryMax {
563                    slice: self.host.slice.clone(),
564                });
565            };
566            let mirror = if self.caches.mirror.enabled {
567                self.caches.mirror.memory.0
568            } else {
569                0
570            };
571            if let Some((profile, needs)) = self
572                .profiles
573                .iter()
574                .map(|(name, profile)| (name, profile.memory.0.saturating_add(mirror)))
575                .find(|(_, needs)| *needs > memory_max.0)
576            {
577                return Err(ConfigError::ProfileOverBudget {
578                    profile: profile.clone(),
579                    needs: ByteSize(needs),
580                    slice: self.host.slice.clone(),
581                    memory_max,
582                });
583            }
584            return Ok(Vec::new());
585        }
586        match memory_max {
587            Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
588                total,
589                slice: self.host.slice.clone(),
590                memory_max,
591            }),
592            Some(_) => Ok(Vec::new()),
593            None => Ok(vec![Warning::UnlimitedSlice {
594                slice: self.host.slice.clone(),
595                total,
596            }]),
597        }
598    }
599}
600
601/// Why `resize_profile` cannot change a config.
602#[derive(Debug, thiserror::Error)]
603pub enum ResizeError {
604    /// The text is not valid TOML.
605    #[error(transparent)]
606    Parse(#[from] toml_edit::TomlError),
607    /// `[profiles]` does not define the profile.
608    #[error("the config has no profile {0:?} under [profiles]")]
609    NoProfile(String),
610}
611
612/// `text` with the profile's `memory` and `slots` replaced, everything else as written, comments included: what
613/// applying a sizing suggestion sends to `PUT /v1/config`, which validates it.
614pub fn resize_profile(
615    text: &str,
616    profile: &str,
617    resize: api::Resize,
618) -> Result<String, ResizeError> {
619    let mut document: toml_edit::DocumentMut = text.parse()?;
620    let table = document
621        .get_mut("profiles")
622        .and_then(|profiles| profiles.get_mut(profile))
623        .and_then(toml_edit::Item::as_table_like_mut)
624        .ok_or_else(|| ResizeError::NoProfile(profile.to_owned()))?;
625    let values = [
626        (
627            "memory",
628            toml_edit::Value::from(ByteSize(resize.memory).to_string()),
629        ),
630        ("slots", toml_edit::Value::from(i64::from(resize.slots))),
631    ];
632    for (key, mut value) in values {
633        // The comment after the old value stays after the new one.
634        if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) {
635            *value.decor_mut() = old.decor().clone();
636        }
637        table.insert(key, toml_edit::Item::Value(value));
638    }
639    Ok(document.to_string())
640}
641
642/// Reads a slice's `MemoryMax` from systemd; `None` when it is unlimited.
643pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
644    let output = Command::new("systemctl")
645        .args(["show", slice, "--property=MemoryMax", "--value"])
646        .output()?;
647    if !output.status.success() {
648        return Err(std::io::Error::other(format!(
649            "systemctl show {slice} failed: {}",
650            String::from_utf8_lossy(&output.stderr).trim()
651        )));
652    }
653    let value = String::from_utf8_lossy(&output.stdout);
654    parse_memory_max(value.trim()).ok_or_else(|| {
655        std::io::Error::other(format!(
656            "systemctl show {slice}: unexpected MemoryMax {value:?}"
657        ))
658    })
659}
660
661/// Parses a `MemoryMax` value as `systemctl show` prints it: bytes, or `infinity`.
662pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
663    if value == "infinity" {
664        Some(None)
665    } else {
666        value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
667    }
668}
669
670/// A size in bytes, written with a binary unit as Docker and systemd do: `512M`, `9G`, `32.5G`.
671#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
672pub struct ByteSize(pub u64);
673
674const UNITS: [(char, u64); 4] = [
675    ('T', 1 << 40),
676    ('G', 1 << 30),
677    ('M', 1 << 20),
678    ('K', 1 << 10),
679];
680
681impl FromStr for ByteSize {
682    type Err = String;
683
684    #[allow(
685        clippy::cast_precision_loss,
686        clippy::cast_possible_truncation,
687        clippy::cast_sign_loss,
688        reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
689    )]
690    fn from_str(s: &str) -> Result<Self, Self::Err> {
691        let invalid = || {
692            format!(
693                "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
694            )
695        };
696        let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
697        let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
698        let number = &s[..s.len() - 1];
699        if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
700            return Err(invalid());
701        }
702        let number: f64 = number.parse().map_err(|_| invalid())?;
703        let bytes = (number * *factor as f64).round();
704        // `u64::MAX as f64` is 2^64, the first value out of range.
705        if bytes >= u64::MAX as f64 {
706            return Err(format!("size {s:?} is too large"));
707        }
708        Ok(ByteSize(bytes as u64))
709    }
710}
711
712impl fmt::Display for ByteSize {
713    #[allow(
714        clippy::cast_precision_loss,
715        reason = "shown with two decimals, far coarser than f64's precision"
716    )]
717    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
718        let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
719            return write!(f, "{}B", self.0);
720        };
721        let number = format!("{:.2}", self.0 as f64 / *factor as f64);
722        let number = number.trim_end_matches('0').trim_end_matches('.');
723        write!(f, "{number}{unit}")
724    }
725}
726
727impl<'de> Deserialize<'de> for ByteSize {
728    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
729        String::deserialize(deserializer)?
730            .parse()
731            .map_err(de::Error::custom)
732    }
733}
734
735/// Parses a duration as Go and the registry write it, `30s`, `90m`, `168h`, or in days: `7d`.
736pub fn parse_duration(s: &str) -> Result<Duration, String> {
737    let invalid = || {
738        format!(
739            "invalid duration {s:?}, expected a number and s, m, h or d, such as \"168h\" or \"7d\""
740        )
741    };
742    let (number, factor) = match s.chars().last() {
743        Some('s') => (&s[..s.len() - 1], 1),
744        Some('m') => (&s[..s.len() - 1], 60),
745        Some('h') => (&s[..s.len() - 1], 3600),
746        Some('d') => (&s[..s.len() - 1], 86400),
747        _ => return Err(invalid()),
748    };
749    let number: u64 = number.parse().map_err(|_| invalid())?;
750    number
751        .checked_mul(factor)
752        .map(Duration::from_secs)
753        .ok_or_else(invalid)
754}
755
756/// Parses an age as the clients' `--since` and `--older-than` take it: `30m`, `12h`, `7d`, `2w`.
757pub fn parse_age(text: &str) -> Result<Duration, String> {
758    let error = || "expected a number and s, m, h, d or w, such as 12h".to_owned();
759    let split = text.find(|c: char| !c.is_ascii_digit()).ok_or_else(error)?;
760    let (number, unit) = text.split_at(split);
761    let number: u64 = number.parse().map_err(|_| error())?;
762    let unit = match unit {
763        "s" => 1,
764        "m" => 60,
765        "h" => 3600,
766        "d" => 86_400,
767        "w" => 7 * 86_400,
768        _ => return Err(error()),
769    };
770    Ok(Duration::from_secs(number.saturating_mul(unit)))
771}
772
773fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
774    parse_duration(&String::deserialize(deserializer)?).map_err(de::Error::custom)
775}
776
777#[cfg(test)]
778mod tests;