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    /// Where the daemon sends notifications; none by default.
42    #[serde(default)]
43    pub notify: Notify,
44}
45
46/// `[host]`: where runner containers run and how they are isolated.
47#[derive(Debug, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct Host {
50    /// The systemd slice every runner container is placed in; its `MemoryMax` is the budget.
51    pub slice: String,
52    /// How runner containers are kept from the host.
53    pub isolation: Isolation,
54    /// Must be set for `isolation = "unsafe-dind"`, so a copied config cannot turn it on silently.
55    #[serde(default)]
56    pub allow_unsafe: bool,
57    /// How often GitHub is asked for queued jobs.
58    #[serde(default = "default_poll_interval", deserialize_with = "duration")]
59    pub poll_interval: Duration,
60    /// What a runner must fit in before it starts. Default `caps`.
61    #[serde(default)]
62    pub admission: Admission,
63    /// Set, a runner also starts only when its predicted CPU fits the slice's CPUs times this; absent, CPU is not
64    /// admitted.
65    #[serde(default)]
66    pub cpu_overcommit: Option<f64>,
67}
68
69impl Host {
70    /// Refuses a `cpu_overcommit` that is not above 0 and at most `MAX_CPU_OVERCOMMIT`.
71    fn check_cpu_overcommit(&self) -> Result<(), ConfigError> {
72        match self.cpu_overcommit {
73            Some(value) if !(value > 0.0 && value <= MAX_CPU_OVERCOMMIT) => {
74                Err(ConfigError::CpuOvercommit { value })
75            }
76            _ => Ok(()),
77        }
78    }
79}
80
81/// The largest `cpu_overcommit` the config check takes.
82pub const MAX_CPU_OVERCOMMIT: f64 = 16.0;
83
84/// `admission` under `[host]`: what a new runner must fit in the slice's `MemoryMax` beside the running ones.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum Admission {
88    /// Every runner's memory cap: `slots × memory` over all profiles, with the mirror, must fit `MemoryMax`.
89    #[default]
90    Caps,
91    /// Every runner's predicted peak, from the job history; `slots` only bounds how many run at once, and one
92    /// runner of each profile with the mirror must fit `MemoryMax`. Nothing starts while the slice is at 90% of it.
93    Measured,
94}
95
96fn default_poll_interval() -> Duration {
97    Duration::from_secs(15)
98}
99
100/// How runner containers are kept from the host: `isolation` under `[host]`.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum Isolation {
104    /// Sysbox: Docker inside an unprivileged container.
105    Sysbox,
106    /// Privileged Docker-in-Docker: a job can take over the host.
107    UnsafeDind,
108}
109
110/// `[caches]`: what runners share and keep between jobs.
111#[derive(Debug, Clone, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct Caches {
114    /// The Docker Hub mirror.
115    pub mirror: Mirror,
116    /// The shared pnpm store.
117    pub pnpm: CacheLimit,
118    /// The shared Maven repository.
119    pub maven: CacheLimit,
120    /// When a profile has no job running and its slot 1 changed its layers by at least this much, slot 1's
121    /// layers become the profile's new snapshot.
122    #[serde(default = "default_reseed_at")]
123    pub reseed_at: ByteSize,
124    /// A reseed that would make a profile's snapshot larger than this starts it over from slot 1's changes alone,
125    /// or from the image when those are larger too.
126    #[serde(default = "default_snapshot_max")]
127    pub snapshot_max: ByteSize,
128    /// The build cache every runner shares.
129    #[serde(default)]
130    pub build: BuildCache,
131}
132
133/// `[caches.build]`: the build cache every runner shares (ADR 0052): Gradle's, and the other tools' the runner image
134/// points at it. Every field has a default.
135#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
136#[serde(deny_unknown_fields, default)]
137pub struct BuildCache {
138    /// Whether runners get it. Default true.
139    pub enabled: bool,
140    /// Above this size, the entries used least recently go when no job runs. Default 10G.
141    pub max_size: ByteSize,
142    /// The branches whose pushes, scheduled and manually started runs may write to it besides each repository's
143    /// default branch, which always may. Every other job only reads. Default empty.
144    pub branches: Vec<String>,
145    /// Where its entries are kept; default `$XDG_CACHE_HOME/runnerd/build-cache`.
146    pub directory: Option<PathBuf>,
147}
148
149impl BuildCache {
150    fn validate(&self) -> Result<(), ConfigError> {
151        if self.enabled && self.max_size.0 == 0 {
152            return Err(ConfigError::ZeroLimit {
153                field: "caches.build.max_size".to_owned(),
154            });
155        }
156        Ok(())
157    }
158}
159
160impl Default for BuildCache {
161    fn default() -> BuildCache {
162        BuildCache {
163            enabled: true,
164            max_size: ByteSize(10 << 30),
165            branches: Vec::new(),
166            directory: None,
167        }
168    }
169}
170
171fn default_reseed_at() -> ByteSize {
172    ByteSize(2 << 30)
173}
174
175/// Above the 8.7G a profile running Flutter and Android builds needs: its Flutter SDK, NDK and Gradle caches.
176fn default_snapshot_max() -> ByteSize {
177    ByteSize(10 << 30)
178}
179
180fn default_mirror_max_size() -> ByteSize {
181    ByteSize(10 << 30)
182}
183
184/// The Docker Hub pull-through mirror shared by all runners.
185#[derive(Debug, Clone, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct Mirror {
188    /// Whether runners pull through the mirror.
189    pub enabled: bool,
190    /// The mirror container's memory limit.
191    pub memory: ByteSize,
192    /// The mirror container's CPUs.
193    pub cpus: u32,
194    /// How long content not pulled again is kept.
195    #[serde(deserialize_with = "duration")]
196    pub ttl: Duration,
197    /// Its storage is emptied above this size when no job runs.
198    #[serde(default = "default_mirror_max_size")]
199    pub max_size: ByteSize,
200}
201
202/// A size limit on a shared cache.
203#[derive(Debug, Clone, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct CacheLimit {
206    /// The cache is emptied above this size when no job runs.
207    pub max_size: ByteSize,
208}
209
210/// Where runnerd's release workflow publishes the runner images, one tag per stage and version, such as
211/// `ghcr.io/ayagmar/runnerd-runner:jvm-0.0.13`.
212pub const PUBLISHED_RUNNER: &str = "ghcr.io/ayagmar/runnerd-runner";
213
214/// A runner image with its limits; every job of the profile gets one container with these limits.
215#[derive(Debug, Clone, Deserialize)]
216#[serde(try_from = "ProfileToml")]
217pub struct Profile {
218    /// The image its runners run: `image_stage` or `image`, exactly one of them.
219    pub image: Image,
220    /// CPUs per slot.
221    pub cpus: u32,
222    /// Memory per slot; the container's hard limit.
223    pub memory: ByteSize,
224    /// The most jobs of this profile running at once.
225    pub slots: u32,
226    /// Whether a failed job's runner is kept as a record, to open a shell in (`runnerctl shell`).
227    pub record_failures: bool,
228    /// The least `memory` auto sizing lowers it to; `None` for no bound.
229    pub min_memory: Option<ByteSize>,
230    /// The most `memory` auto sizing raises it to; `None` for what the slice's `MemoryMax` holds.
231    pub max_memory: Option<ByteSize>,
232    /// The most `slots` auto sizing raises them to; `None` for as many as the slice's `MemoryMax` holds.
233    pub max_slots: Option<u32>,
234}
235
236/// The image a profile's runners run.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum Image {
239    /// `image_stage`: a stage of `image/Dockerfile` built on this host as `runnerd-runner:<stage>`.
240    Stage(String),
241    /// `image`: a reference runnerd pulls when Docker lacks it, such as
242    /// `ghcr.io/ayagmar/runnerd-runner:jvm-0.0.13`.
243    Pulled(String),
244}
245
246impl Image {
247    /// The reference Docker runs.
248    pub fn reference(&self) -> String {
249        match self {
250            Image::Stage(stage) => format!("runnerd-runner:{stage}"),
251            Image::Pulled(reference) => reference.clone(),
252        }
253    }
254}
255
256/// `[profiles.<name>]` as written, before `Profile` checks it names one image.
257#[derive(Deserialize)]
258#[serde(deny_unknown_fields)]
259struct ProfileToml {
260    image_stage: Option<String>,
261    image: Option<String>,
262    cpus: u32,
263    memory: ByteSize,
264    slots: u32,
265    #[serde(default)]
266    record_failures: bool,
267    min_memory: Option<ByteSize>,
268    max_memory: Option<ByteSize>,
269    max_slots: Option<u32>,
270}
271
272impl TryFrom<ProfileToml> for Profile {
273    type Error = String;
274
275    fn try_from(profile: ProfileToml) -> Result<Profile, String> {
276        let image = match (profile.image_stage, profile.image) {
277            (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
278            (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
279            (Some(_), Some(_)) => {
280                return Err(
281                    "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
282                     not both"
283                        .to_owned(),
284                );
285            }
286            _ => {
287                return Err(
288                    "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
289                        .to_owned(),
290                );
291            }
292        };
293        Ok(Profile {
294            image,
295            cpus: profile.cpus,
296            memory: profile.memory,
297            slots: profile.slots,
298            record_failures: profile.record_failures,
299            min_memory: profile.min_memory,
300            max_memory: profile.max_memory,
301            max_slots: profile.max_slots,
302        })
303    }
304}
305
306/// A repository to serve: `[[repositories]]`.
307#[derive(Debug, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct Repository {
310    /// `owner/name` on GitHub.
311    pub name: String,
312    /// The profile its jobs run in.
313    pub profile: String,
314    /// The labels its runners register with; a job runs on them when every label it asks for is one of these.
315    pub labels: Vec<String>,
316    /// How many idle runners it keeps registered for jobs not queued yet while it is active (ADR 0050); 0 turns warm
317    /// runners off. Default 1.
318    #[serde(default = "default_warm")]
319    pub warm: u32,
320    /// Whether its runners get the shared build cache, when `[caches.build]` is enabled. Default true.
321    #[serde(default = "default_true")]
322    pub build_cache: bool,
323    /// Whose runs may write to the shared build cache besides the branches' (ADR 0053). Default
324    /// `"default-branch"`.
325    #[serde(default)]
326    pub build_cache_writers: BuildCacheWriters,
327}
328
329/// `build_cache_writers` of a `[[repositories]]`: whose jobs' uploads the shared build cache keeps (ADR 0053).
330#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
331#[serde(rename_all = "kebab-case")]
332pub enum BuildCacheWriters {
333    /// Pushes, scheduled and manually started runs of the default branch and of `[caches.build] branches`.
334    #[default]
335    DefaultBranch,
336    /// Those, and `pull_request` runs of the repository's own branches that people, not bots, opened and pushed.
337    PullRequests,
338}
339
340fn default_true() -> bool {
341    true
342}
343
344fn default_warm() -> u32 {
345    1
346}
347
348/// `[history]`: the job history database and how long it keeps what. Every field has a default.
349#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
350#[serde(deny_unknown_fields, default)]
351pub struct History {
352    /// The SQLite database; default `$XDG_STATE_HOME/runnerd/runnerd.db`.
353    pub database: Option<PathBuf>,
354    /// How long CPU and memory samples are kept; a job's totals and peaks stay. Default 7 days.
355    #[serde(deserialize_with = "duration")]
356    pub samples: Duration,
357    /// How long a finished job's log is kept. Default 30 days.
358    #[serde(deserialize_with = "duration")]
359    pub logs: Duration,
360    /// How long events are kept for clients resuming with `Last-Event-ID`. Default 30 days.
361    #[serde(deserialize_with = "duration")]
362    pub events: Duration,
363    /// How long a finished job's record is kept, with its steps, totals and runner. Default 90 days.
364    #[serde(deserialize_with = "duration")]
365    pub jobs: Duration,
366    /// The most log stored per job; later lines are dropped. Default 50M.
367    pub log_max: ByteSize,
368}
369
370impl Default for History {
371    fn default() -> History {
372        History {
373            database: None,
374            samples: Duration::from_hours(7 * 24),
375            logs: Duration::from_hours(30 * 24),
376            events: Duration::from_hours(30 * 24),
377            jobs: Duration::from_hours(90 * 24),
378            log_max: ByteSize(50 << 20),
379        }
380    }
381}
382
383/// `[records]`: how much the records of failed jobs may keep (`record_failures` under a profile turns them on).
384/// When a new record would pass a limit, the oldest records go first. Every field has a default.
385#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
386#[serde(deny_unknown_fields, default)]
387pub struct Records {
388    /// The most disk all records may take together. Default 20G.
389    pub max_total: ByteSize,
390    /// The most records kept. Default 10.
391    pub max_count: u32,
392    /// How long a record is kept. Default 7 days.
393    #[serde(deserialize_with = "duration")]
394    pub max_age: Duration,
395    /// The disk Docker must keep free: no record is made that would leave less. Default 10G.
396    pub min_free: ByteSize,
397}
398
399impl Default for Records {
400    fn default() -> Records {
401        Records {
402            max_total: ByteSize(20 << 30),
403            max_count: 10,
404            max_age: Duration::from_hours(7 * 24),
405            min_free: ByteSize(10 << 30),
406        }
407    }
408}
409
410/// `[sizing]`: how `runnerctl sizing` turns the job history's peaks into advice, and whether the daemon applies it
411/// itself. Every field has a default.
412#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
413#[serde(deny_unknown_fields, default)]
414pub struct Sizing {
415    /// The percent added to the largest sampled memory peak: a container is sampled every 2 s, so a spike between
416    /// two samples goes unseen. Default 15. Advice adds at least `api::PAGE_CACHE_ROOM`.
417    pub margin: u32,
418    /// The fewest jobs a profile, or a job of a repository's workflow, needs before its peaks become advice.
419    /// Default 10.
420    pub min_jobs: u32,
421    /// Whether the daemon writes each profile's advice into the config itself, within each profile's
422    /// `min_memory`, `max_memory` and `max_slots`. Default off.
423    pub auto: bool,
424    /// The least time between a change auto sizing makes to one profile and one that lowers anything in it. Default
425    /// 24 hours.
426    #[serde(deserialize_with = "duration")]
427    pub interval: Duration,
428    /// The least time between a change auto sizing makes to one profile and one that only raises its `memory`,
429    /// `slots` or `cpus`; at most `interval`, which a longer one counts as. Default 1 hour.
430    #[serde(deserialize_with = "duration")]
431    pub raise_interval: Duration,
432    /// The most a change moves a profile's `memory` or `cpus`, in percent of them. Default 25.
433    pub max_step: u32,
434}
435
436impl Sizing {
437    /// The least time between a change to one profile and one that only raises it: `raise_interval`, at most
438    /// `interval`.
439    pub fn raise_after(&self) -> Duration {
440        self.raise_interval.min(self.interval)
441    }
442}
443
444impl Default for Sizing {
445    fn default() -> Sizing {
446        Sizing {
447            margin: 15,
448            min_jobs: 10,
449            auto: false,
450            interval: Duration::from_hours(24),
451            raise_interval: Duration::from_hours(1),
452            max_step: 25,
453        }
454    }
455}
456
457/// `[web]`: the dashboard behind a reverse proxy, such as `tailscale serve`. Read when the daemon starts.
458#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
459#[serde(deny_unknown_fields, default)]
460pub struct Web {
461    /// The origins, besides the dashboard's own loopback address, whose pages may write: pause, reseed, save the
462    /// config. Default none.
463    pub origins: Vec<Origin>,
464    /// The one of `origins` that `runnerctl web` opens the dashboard on, such as a tailnet's address. Default none:
465    /// it opens the loopback address.
466    pub open: Option<Origin>,
467}
468
469impl Web {
470    /// Refuses an `open` that is not one of `origins`.
471    fn validate(&self) -> Result<(), ConfigError> {
472        match &self.open {
473            Some(open) if !self.origins.contains(open) => {
474                Err(ConfigError::OpenNotListed { open: open.clone() })
475            }
476            _ => Ok(()),
477        }
478    }
479}
480
481/// `[notify]`: where the daemon sends notifications (ADR 0051). Without targets nothing leaves the machine.
482#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
483#[serde(deny_unknown_fields, default)]
484pub struct Notify {
485    /// When the digest goes to the targets that want it, in the host's local time. Default `09:00`.
486    pub digest_at: DayTime,
487    /// The targets, each `[[notify.targets]]`.
488    pub targets: Vec<NotifyTarget>,
489}
490
491impl Default for Notify {
492    fn default() -> Notify {
493        Notify {
494            digest_at: DayTime { minutes: 9 * 60 },
495            targets: Vec::new(),
496        }
497    }
498}
499
500impl Notify {
501    /// Refuses a target without a name, with a name another has, with a URL that is not `http` or `https`, or with
502    /// a header that cannot be sent. Says which target and which field, never the URL or a header's value.
503    fn validate(&self) -> Result<(), ConfigError> {
504        let refuse = |target: &str, why: &'static str| {
505            Err(ConfigError::Notify {
506                target: target.to_owned(),
507                why,
508            })
509        };
510        for (index, target) in self.targets.iter().enumerate() {
511            let name = target.name.as_str();
512            if name.trim().is_empty() || name.chars().any(char::is_control) {
513                return refuse(name, "name is empty");
514            }
515            if self.targets[..index].iter().any(|other| other.name == name) {
516                return refuse(name, "another target has this name");
517            }
518            let url = target.url.expose();
519            if url == SECRET_SHOWN {
520                return refuse(
521                    name,
522                    "url is <secret>, which runnerd fills in only for a target of the same name that had one; write the url",
523                );
524            }
525            let Some(rest) = url
526                .strip_prefix("https://")
527                .or_else(|| url.strip_prefix("http://"))
528            else {
529                return refuse(name, "url does not start with https:// or http://");
530            };
531            let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
532            if host.is_empty() || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
533                return refuse(name, "url is not a URL");
534            }
535            if target.kind == NotifyKind::Ntfy
536                && (path.is_empty() || path.contains(['/', '?', '#']))
537            {
538                return refuse(
539                    name,
540                    "url is not an ntfy topic's, such as https://ntfy.sh/<topic>",
541                );
542            }
543            for (header, value) in &target.headers {
544                if value.expose() == SECRET_SHOWN {
545                    return refuse(
546                        name,
547                        "a header is <secret>, which runnerd fills in only for a target of the same name that had it; write its value",
548                    );
549                }
550                let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
551                if header.is_empty() || !header.chars().all(token) {
552                    return refuse(name, "a header's name is not a header name");
553                }
554                if value.expose().chars().any(char::is_control) {
555                    return refuse(name, "a header's value has a control character");
556                }
557            }
558            if target.events.contains(&api::NotifyEvent::Test) {
559                return refuse(
560                    name,
561                    "events lists test, which only runnerctl notify test sends",
562                );
563            }
564        }
565        Ok(())
566    }
567}
568
569/// `[[notify.targets]]`: one place notifications go.
570#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
571#[serde(deny_unknown_fields)]
572pub struct NotifyTarget {
573    /// What logs and `runnerctl notify test` call it, such as `phone`; the history keeps where it got to by it.
574    pub name: String,
575    /// What it takes.
576    pub kind: NotifyKind,
577    /// Where it is posted: the webhook's URL, or the ntfy topic's, such as `https://ntfy.sh/<topic>`. A secret.
578    pub url: Secret,
579    /// Headers sent with each post, such as an ntfy access token's `Authorization`. Their values are secrets.
580    #[serde(default)]
581    pub headers: BTreeMap<String, Secret>,
582    /// What it is sent. Default `failed`, `lost`, `runner` and `insights`.
583    #[serde(default = "default_notify_events")]
584    pub events: Vec<api::NotifyEvent>,
585}
586
587fn default_notify_events() -> Vec<api::NotifyEvent> {
588    vec![
589        api::NotifyEvent::Failed,
590        api::NotifyEvent::Lost,
591        api::NotifyEvent::Runner,
592        api::NotifyEvent::Insights,
593    ]
594}
595
596/// What a notification target takes.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
598#[serde(rename_all = "kebab-case")]
599pub enum NotifyKind {
600    /// `api::Notification` as JSON.
601    Webhook,
602    /// An ntfy topic: the title, message, priority and link, published as ntfy's JSON.
603    Ntfy,
604}
605
606/// A value no `Debug` output, log line or error shows: a notification target's URL or header.
607#[derive(Clone, PartialEq, Eq, Deserialize)]
608#[serde(transparent)]
609pub struct Secret(String);
610
611impl Secret {
612    /// The value, for the request that sends it.
613    pub fn expose(&self) -> &str {
614        &self.0
615    }
616}
617
618impl From<&str> for Secret {
619    fn from(value: &str) -> Secret {
620        Secret(value.to_owned())
621    }
622}
623
624impl fmt::Debug for Secret {
625    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
626        f.write_str("<secret>")
627    }
628}
629
630/// A time of day, `HH:MM`, 24-hour.
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub struct DayTime {
633    /// Minutes after midnight.
634    pub minutes: u32,
635}
636
637impl FromStr for DayTime {
638    type Err = String;
639
640    fn from_str(text: &str) -> Result<DayTime, String> {
641        let invalid = || format!("invalid time of day {text:?}, expected HH:MM such as \"09:00\"");
642        let (hour, minute) = text.split_once(':').ok_or_else(invalid)?;
643        let digits = |part: &str| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit());
644        if !digits(hour) || !digits(minute) {
645            return Err(invalid());
646        }
647        let (hour, minute): (u32, u32) = (
648            hour.parse().map_err(|_| invalid())?,
649            minute.parse().map_err(|_| invalid())?,
650        );
651        if hour > 23 || minute > 59 {
652            return Err(invalid());
653        }
654        Ok(DayTime {
655            minutes: hour * 60 + minute,
656        })
657    }
658}
659
660impl fmt::Display for DayTime {
661    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
662        write!(f, "{:02}:{:02}", self.minutes / 60, self.minutes % 60)
663    }
664}
665
666impl<'de> Deserialize<'de> for DayTime {
667    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
668        String::deserialize(deserializer)?
669            .parse()
670            .map_err(de::Error::custom)
671    }
672}
673
674/// An origin as a browser sends it in `Origin`: `https://host[:port]`, or `http://` for a loopback host, in lower
675/// case, without the scheme's default port, and an address written as the URL standard serializes it. Nothing after
676/// the port: no path, query or user.
677#[derive(Debug, Clone, PartialEq, Eq)]
678pub struct Origin(String);
679
680impl Origin {
681    /// The origin as a browser sends it, such as `https://host.example.ts.net:8443`.
682    pub fn as_str(&self) -> &str {
683        &self.0
684    }
685}
686
687impl FromStr for Origin {
688    type Err = String;
689
690    fn from_str(s: &str) -> Result<Origin, String> {
691        let invalid = |why: &str| {
692            format!(
693                "invalid origin {s:?}: {why}; write the scheme, host and port only, such as \
694                 \"https://host.example.ts.net:8443\""
695            )
696        };
697        let (scheme, rest) = s
698            .split_once("://")
699            .ok_or_else(|| invalid("it has no scheme"))?;
700        let scheme = scheme.to_ascii_lowercase();
701        if rest.contains(['/', '?', '#']) {
702            return Err(invalid("it has a path"));
703        }
704        if rest.contains('@') {
705            return Err(invalid("it names a user"));
706        }
707        let (host, port) = match rest.strip_prefix('[') {
708            Some(v6) => {
709                let (address, after) = v6
710                    .split_once(']')
711                    .ok_or_else(|| invalid("its IPv6 address has no closing ]"))?;
712                let port = match after {
713                    "" => None,
714                    _ => Some(after.strip_prefix(':').ok_or_else(|| invalid("its port"))?),
715                };
716                (format!("[{address}]"), port)
717            }
718            None => match rest.split_once(':') {
719                Some((host, port)) => (host.to_owned(), Some(port)),
720                None => (rest.to_owned(), None),
721            },
722        };
723        let host = host.to_ascii_lowercase();
724        let not_a_host = || invalid("its host is not a name or an address");
725        // An address is kept as a browser serializes it, or it would never match the `Origin` the browser sends.
726        let (host, loopback) =
727            if let Some(v6) = host.strip_prefix('[').and_then(|v6| v6.strip_suffix(']')) {
728                let address: Ipv6Addr = v6.parse().map_err(|_| not_a_host())?;
729                (format!("[{}]", ipv6_origin(address)), address.is_loopback())
730            } else {
731                if host.is_empty()
732                    || !host
733                        .chars()
734                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
735                {
736                    return Err(not_a_host());
737                }
738                // As the URL standard reads a host: one whose last label is a number is an IPv4 address.
739                let name = host.strip_suffix('.').unwrap_or(&host);
740                let last = name.rsplit('.').next().unwrap_or_default();
741                let numeric = !last.is_empty()
742                    && (last.bytes().all(|byte| byte.is_ascii_digit())
743                        || last
744                            .strip_prefix("0x")
745                            .is_some_and(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit())));
746                if numeric {
747                    let address: Ipv4Addr = name.parse().map_err(|_| {
748                        invalid("its IPv4 address is not four decimal numbers, such as 192.0.2.1")
749                    })?;
750                    (address.to_string(), address.is_loopback())
751                } else {
752                    let loopback = host == "localhost";
753                    (host, loopback)
754                }
755            };
756        let port = port
757            .map(|port| {
758                Some(port)
759                    .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit()))
760                    .and_then(|port| port.parse::<u16>().ok())
761                    .filter(|port| *port != 0)
762                    .ok_or_else(|| invalid("its port is not a number from 1 to 65535"))
763            })
764            .transpose()?;
765        let default_port = match scheme.as_str() {
766            "https" => 443,
767            "http" if loopback => 80,
768            "http" => {
769                return Err(invalid(
770                    "http is for a loopback host only, since the session cookie would cross the network in \
771                     the clear; use https",
772                ));
773            }
774            _ => return Err(invalid("its scheme is not https")),
775        };
776        Ok(Origin(match port {
777            Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
778            _ => format!("{scheme}://{host}"),
779        }))
780    }
781}
782
783/// An IPv6 address as the URL standard serializes it: lower-case hex pieces, the first longest run of two or more
784/// zero pieces as `::`, and no dotted IPv4 tail.
785fn ipv6_origin(address: Ipv6Addr) -> String {
786    let pieces = address.segments();
787    let (mut zeros_at, mut zeros) = (0, 0);
788    let mut at = 0;
789    while at < pieces.len() {
790        let run = pieces[at..].iter().take_while(|piece| **piece == 0).count();
791        if run > zeros {
792            (zeros_at, zeros) = (at, run);
793        }
794        at += run.max(1);
795    }
796    let hex = |pieces: &[u16]| {
797        pieces
798            .iter()
799            .map(|piece| format!("{piece:x}"))
800            .collect::<Vec<_>>()
801            .join(":")
802    };
803    if zeros < 2 {
804        return hex(&pieces);
805    }
806    format!(
807        "{}::{}",
808        hex(&pieces[..zeros_at]),
809        hex(&pieces[zeros_at + zeros..])
810    )
811}
812
813impl fmt::Display for Origin {
814    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
815        f.write_str(&self.0)
816    }
817}
818
819impl<'de> Deserialize<'de> for Origin {
820    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
821        String::deserialize(deserializer)?
822            .parse()
823            .map_err(de::Error::custom)
824    }
825}
826
827/// Why a config cannot be used.
828#[derive(Debug, thiserror::Error)]
829pub enum ConfigError {
830    /// The file cannot be read.
831    #[error("cannot read {}: {source}", path.display())]
832    Read {
833        /// The config file.
834        path: PathBuf,
835        /// Why reading it failed.
836        source: std::io::Error,
837    },
838    /// The file is not valid TOML or does not match the config's shape.
839    #[error(transparent)]
840    Parse(#[from] toml::de::Error),
841    /// A repository names a profile `[profiles]` does not define.
842    #[error(
843        "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
844        defined_profiles(.defined)
845    )]
846    UnknownProfile {
847        /// The repository's `owner/name`.
848        repository: String,
849        /// The profile it names.
850        profile: String,
851        /// The profiles `[profiles]` defines.
852        defined: Vec<String>,
853    },
854    /// A repository has no labels, or an empty one.
855    #[error("repository {repository} needs at least one non-empty label")]
856    MissingLabels {
857        /// The repository's `owner/name`.
858        repository: String,
859    },
860    /// `unsafe-dind` without `allow_unsafe = true`.
861    #[error(
862        "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
863         set allow_unsafe = true under [host] to use it anyway"
864    )]
865    UnsafeNotAllowed,
866    /// A limit is 0.
867    #[error("{field} is 0; set it above 0")]
868    ZeroLimit {
869        /// The setting, such as `profiles.app.cpus`.
870        field: String,
871    },
872    /// `cpu_overcommit` is not a number above 0 and at most `MAX_CPU_OVERCOMMIT`.
873    #[error(
874        "host.cpu_overcommit is {value}; set it above 0 and at most {MAX_CPU_OVERCOMMIT}, such as 1.0"
875    )]
876    CpuOvercommit {
877        /// The value set.
878        value: f64,
879    },
880    /// `[web] open` is not one of `[web] origins`.
881    #[error(
882        "web.open is {open}, which is not one of [web] origins; add it to origins, or remove open"
883    )]
884    OpenNotListed {
885        /// The origin `open` names.
886        open: Origin,
887    },
888    /// A profile's `memory` or `slots` are outside its `min_memory`, `max_memory` or `max_slots`, or its
889    /// `min_memory` is above its `max_memory`.
890    #[error("profiles.{profile}: {why}")]
891    SizingBounds {
892        /// The profile.
893        profile: String,
894        /// Which bound, such as `memory is above its max_memory`.
895        why: &'static str,
896    },
897    /// A `[[notify.targets]]` entry cannot be used. Never says its URL or a header's value.
898    #[error("notify target {target:?}: {why}")]
899    Notify {
900        /// The target's name.
901        target: String,
902        /// What is wrong, such as `url does not start with https:// or http://`.
903        why: &'static str,
904    },
905    /// The caps add up past `u64::MAX` bytes.
906    #[error("slots × memory over all profiles plus the mirror is too large to add up")]
907    BudgetOverflow,
908    /// `admission = "measured"` with a slice that has no `MemoryMax` to admit against.
909    #[error(
910        "admission = \"measured\" admits runners against the slice {slice}'s MemoryMax, and it has none; install \
911         a unit file for it (`runnerd doctor` prints one)"
912    )]
913    MeasuredWithoutMemoryMax {
914        /// The slice runners run in.
915        slice: String,
916    },
917    /// Under `admission = "measured"`, one runner of a profile and the mirror do not fit `MemoryMax`.
918    #[error(
919        "one runner of {profile} and the mirror take {needs}, the slice {slice}'s MemoryMax is {memory_max}; lower \
920         its `memory` or raise the slice's MemoryMax"
921    )]
922    ProfileOverBudget {
923        /// The profile.
924        profile: String,
925        /// Its `memory` and the mirror's.
926        needs: ByteSize,
927        /// The slice runners run in.
928        slice: String,
929        /// The slice's `MemoryMax`.
930        memory_max: ByteSize,
931    },
932    /// The caps add up to more than the slice's `MemoryMax`.
933    #[error(
934        "slots × memory over all profiles plus the mirror is {total}, \
935         the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
936         MemoryMax (`runnerd doctor` prints its unit)"
937    )]
938    OverBudget {
939        /// What every runner and the mirror may use at once.
940        total: ByteSize,
941        /// The slice runners run in.
942        slice: String,
943        /// The slice's `MemoryMax`.
944        memory_max: ByteSize,
945    },
946}
947
948fn defined_profiles(defined: &[String]) -> String {
949    if defined.is_empty() {
950        "it defines none".to_owned()
951    } else {
952        format!("defined: {}", defined.join(", "))
953    }
954}
955
956/// What a valid config should still fix.
957#[derive(Debug, Clone, PartialEq, Eq)]
958pub enum Warning {
959    /// The slice has no unit file, so systemd creates it without limits.
960    UnlimitedSlice {
961        /// The slice runners run in.
962        slice: String,
963        /// What every runner and the mirror may use at once.
964        total: ByteSize,
965    },
966}
967
968impl fmt::Display for Warning {
969    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
970        match self {
971            Warning::UnlimitedSlice { slice, total } => write!(
972                f,
973                "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
974                 install a unit file for it"
975            ),
976        }
977    }
978}
979
980impl Config {
981    /// Reads and parses a config file.
982    pub fn load(path: &Path) -> Result<Config, ConfigError> {
983        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
984            path: path.to_owned(),
985            source,
986        })?;
987        Config::parse(&text)
988    }
989
990    /// Parses a config from TOML text.
991    pub fn parse(text: &str) -> Result<Config, ConfigError> {
992        Ok(toml::from_str(text)?)
993    }
994
995    /// Parses `text` and validates it against the `MemoryMax` that `memory_max` reads for the slice it names:
996    /// what `runnerd check-config` prints and `POST /v1/config/check` answers. Returns the config if it is valid.
997    pub fn check(
998        text: &str,
999        memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
1000    ) -> (Option<Config>, api::ConfigCheck) {
1001        let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
1002            api::ConfigCheck {
1003                valid: false,
1004                message,
1005                warnings: Vec::new(),
1006                memory_caps: memory_caps.map(|caps| caps.0),
1007                memory_max: max.map(|max| max.0),
1008                revision: None,
1009            }
1010        };
1011        let config = match Config::parse(text) {
1012            Ok(config) => config,
1013            Err(error) => return (None, invalid(error.to_string(), None, None)),
1014        };
1015        let caps = config.memory_caps().ok();
1016        let max = match memory_max(&config.host.slice) {
1017            Ok(max) => max,
1018            Err(error) => return (None, invalid(error.to_string(), caps, None)),
1019        };
1020        match config
1021            .validate(max)
1022            .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
1023        {
1024            Ok((warnings, total)) => {
1025                let message = match (config.host.admission, max) {
1026                    (Admission::Measured, Some(max)) if total > max => format!(
1027                        "valid, memory caps sum to {total}, over the {max} MemoryMax: admission = \"measured\" \
1028                         starts what the predicted peaks fit"
1029                    ),
1030                    _ => format!("valid, memory caps sum to {total}"),
1031                };
1032                let check = api::ConfigCheck {
1033                    valid: true,
1034                    message,
1035                    warnings: warnings.iter().map(ToString::to_string).collect(),
1036                    memory_caps: Some(total.0),
1037                    memory_max: max.map(|max| max.0),
1038                    revision: None,
1039                };
1040                (Some(config), check)
1041            }
1042            Err(error) => (None, invalid(error.to_string(), caps, max)),
1043        }
1044    }
1045
1046    /// The mirror's memory cap, 0 when it is disabled.
1047    pub fn mirror_memory(&self) -> u64 {
1048        if self.caches.mirror.enabled {
1049            self.caches.mirror.memory.0
1050        } else {
1051            0
1052        }
1053    }
1054
1055    /// The memory every runner and the mirror may use at once.
1056    pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
1057        let mirror = self.mirror_memory();
1058        self.profiles
1059            .values()
1060            .try_fold(mirror, |total, p| {
1061                p.memory
1062                    .0
1063                    .checked_mul(u64::from(p.slots))?
1064                    .checked_add(total)
1065            })
1066            .map(ByteSize)
1067            .ok_or(ConfigError::BudgetOverflow)
1068    }
1069
1070    /// Refuses a profile outside the bounds auto sizing keeps it in, or whose bounds cross.
1071    fn check_sizing_bounds(&self) -> Result<(), ConfigError> {
1072        for (name, p) in &self.profiles {
1073            let memory = p.memory.0;
1074            let field = if p
1075                .min_memory
1076                .zip(p.max_memory)
1077                .is_some_and(|(min, max)| min.0 > max.0)
1078            {
1079                "min_memory is above its max_memory"
1080            } else if p.min_memory.is_some_and(|min| memory < min.0) {
1081                "memory is below its min_memory"
1082            } else if p.max_memory.is_some_and(|max| memory > max.0) {
1083                "memory is above its max_memory"
1084            } else if p.max_slots.is_some_and(|max| p.slots > max) {
1085                "slots are above its max_slots"
1086            } else {
1087                continue;
1088            };
1089            return Err(ConfigError::SizingBounds {
1090                profile: name.clone(),
1091                why: field,
1092            });
1093        }
1094        Ok(())
1095    }
1096
1097    /// Checks the config against the slice's `MemoryMax` (`None` when the slice is unlimited).
1098    pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
1099        if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
1100            return Err(ConfigError::UnsafeNotAllowed);
1101        }
1102        let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
1103            [
1104                (p.memory.0 == 0, format!("profiles.{name}.memory")),
1105                (p.cpus == 0, format!("profiles.{name}.cpus")),
1106                (p.slots == 0, format!("profiles.{name}.slots")),
1107                (p.max_slots == Some(0), format!("profiles.{name}.max_slots")),
1108            ]
1109        });
1110        let mirror = &self.caches.mirror;
1111        let mirror_zero_limits = [
1112            (
1113                mirror.enabled && mirror.memory.0 == 0,
1114                "caches.mirror.memory".to_owned(),
1115            ),
1116            (
1117                mirror.enabled && mirror.cpus == 0,
1118                "caches.mirror.cpus".to_owned(),
1119            ),
1120            (
1121                self.host.poll_interval.is_zero(),
1122                "host.poll_interval".to_owned(),
1123            ),
1124            (self.records.max_count == 0, "records.max_count".to_owned()),
1125            (self.sizing.min_jobs == 0, "sizing.min_jobs".to_owned()),
1126            (self.sizing.interval.is_zero(), "sizing.interval".to_owned()),
1127            (
1128                self.sizing.raise_interval.is_zero(),
1129                "sizing.raise_interval".to_owned(),
1130            ),
1131            (self.sizing.max_step == 0, "sizing.max_step".to_owned()),
1132            (
1133                self.records.max_total.0 == 0,
1134                "records.max_total".to_owned(),
1135            ),
1136        ];
1137        if let Some((_, field)) = zero_limits
1138            .chain(mirror_zero_limits)
1139            .find(|(zero, _)| *zero)
1140        {
1141            return Err(ConfigError::ZeroLimit { field });
1142        }
1143        self.check_sizing_bounds()?;
1144        self.caches.build.validate()?;
1145        self.host.check_cpu_overcommit()?;
1146        self.web.validate()?;
1147        self.notify.validate()?;
1148        for repository in &self.repositories {
1149            if !self.profiles.contains_key(&repository.profile) {
1150                return Err(ConfigError::UnknownProfile {
1151                    repository: repository.name.clone(),
1152                    profile: repository.profile.clone(),
1153                    defined: self.profiles.keys().cloned().collect(),
1154                });
1155            }
1156            if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
1157            {
1158                return Err(ConfigError::MissingLabels {
1159                    repository: repository.name.clone(),
1160                });
1161            }
1162        }
1163        let total = self.memory_caps()?;
1164        if self.host.admission == Admission::Measured {
1165            let Some(memory_max) = memory_max else {
1166                return Err(ConfigError::MeasuredWithoutMemoryMax {
1167                    slice: self.host.slice.clone(),
1168                });
1169            };
1170            let mirror = self.mirror_memory();
1171            if let Some((profile, needs)) = self
1172                .profiles
1173                .iter()
1174                .map(|(name, profile)| (name, profile.memory.0.saturating_add(mirror)))
1175                .find(|(_, needs)| *needs > memory_max.0)
1176            {
1177                return Err(ConfigError::ProfileOverBudget {
1178                    profile: profile.clone(),
1179                    needs: ByteSize(needs),
1180                    slice: self.host.slice.clone(),
1181                    memory_max,
1182                });
1183            }
1184            return Ok(Vec::new());
1185        }
1186        match memory_max {
1187            Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
1188                total,
1189                slice: self.host.slice.clone(),
1190                memory_max,
1191            }),
1192            Some(_) => Ok(Vec::new()),
1193            None => Ok(vec![Warning::UnlimitedSlice {
1194                slice: self.host.slice.clone(),
1195                total,
1196            }]),
1197        }
1198    }
1199}
1200
1201/// Why `resize_profile` cannot change a config.
1202#[derive(Debug, thiserror::Error)]
1203pub enum ResizeError {
1204    /// The text is not valid TOML.
1205    #[error(transparent)]
1206    Parse(#[from] toml_edit::TomlError),
1207    /// `[profiles]` does not define the profile.
1208    #[error("the config has no profile {0:?} under [profiles]")]
1209    NoProfile(String),
1210}
1211
1212/// `text` with the profile's `memory` and `slots` replaced, and its `cpus` when `resize` has them, everything else as
1213/// written, comments included: what applying a sizing suggestion sends to `PUT /v1/config`, which validates it.
1214pub fn resize_profile(
1215    text: &str,
1216    profile: &str,
1217    resize: api::Resize,
1218) -> Result<String, ResizeError> {
1219    let mut document: toml_edit::DocumentMut = text.parse()?;
1220    let table = document
1221        .get_mut("profiles")
1222        .and_then(|profiles| profiles.get_mut(profile))
1223        .and_then(toml_edit::Item::as_table_like_mut)
1224        .ok_or_else(|| ResizeError::NoProfile(profile.to_owned()))?;
1225    let values = [
1226        (
1227            "memory",
1228            toml_edit::Value::from(ByteSize(resize.memory).to_string()),
1229        ),
1230        ("slots", toml_edit::Value::from(i64::from(resize.slots))),
1231    ];
1232    let cpus = resize
1233        .cpus
1234        .map(|cpus| ("cpus", toml_edit::Value::from(i64::from(cpus))));
1235    for (key, mut value) in values.into_iter().chain(cpus) {
1236        // The comment after the old value stays after the new one.
1237        if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) {
1238            *value.decor_mut() = old.decor().clone();
1239        }
1240        table.insert(key, toml_edit::Item::Value(value));
1241    }
1242    Ok(document.to_string())
1243}
1244
1245/// What `GET /v1/config` shows in place of a notification target's URL and header values (ADR 0051).
1246pub const SECRET_SHOWN: &str = "<secret>";
1247
1248/// `text` with each `[[notify.targets]]` entry's `url` and header values shown as [`SECRET_SHOWN`], everything else
1249/// as written: what `GET /v1/config` answers, so no client, the dashboard's browsers included, holds them. `None`
1250/// when `text` is not TOML.
1251pub fn mask_secrets(text: &str) -> Option<String> {
1252    let mut document: toml_edit::DocumentMut = text.parse().ok()?;
1253    let mut masked = false;
1254    for target in notify_targets(&mut document) {
1255        for (_, value) in secrets_of(target) {
1256            replace_value(value, toml_edit::Value::from(SECRET_SHOWN));
1257            masked = true;
1258        }
1259    }
1260    Some(if masked {
1261        document.to_string()
1262    } else {
1263        text.to_owned()
1264    })
1265}
1266
1267/// `text` with each [`SECRET_SHOWN`] in a target's `url` or headers replaced by the value `stored` has for the target
1268/// of the same name, everything else as written: what `PUT /v1/config` and `POST /v1/config/check` read, so a config
1269/// shown masked saves with its secrets. A value with nothing to keep stays [`SECRET_SHOWN`], which `Config::validate`
1270/// refuses. A text that does not parse as a config is answered as sent: a TOML error quotes the line it is on, and
1271/// that line must hold the mask, not the value put back. One that parses still does with the values put back, as they
1272/// are strings where strings were, and `Config::validate` names a target's field, never its value.
1273pub fn keep_secrets(text: &str, stored: &str) -> String {
1274    if !text.contains(SECRET_SHOWN) || Config::parse(text).is_err() {
1275        return text.to_owned();
1276    }
1277    let (Ok(mut document), Ok(mut old)) = (
1278        text.parse::<toml_edit::DocumentMut>(),
1279        stored.parse::<toml_edit::DocumentMut>(),
1280    ) else {
1281        return text.to_owned();
1282    };
1283    let mut kept: BTreeMap<String, Vec<(SecretField, toml_edit::Value)>> = BTreeMap::new();
1284    for target in notify_targets(&mut old) {
1285        let Some(name) = target_name(target) else {
1286            continue;
1287        };
1288        let secrets = secrets_of(target)
1289            .into_iter()
1290            .map(|(field, value)| (field, value.clone()))
1291            .collect();
1292        kept.insert(name, secrets);
1293    }
1294    for target in notify_targets(&mut document) {
1295        let Some(secrets) = target_name(target).and_then(|name| kept.get(&name)) else {
1296            continue;
1297        };
1298        for (field, value) in secrets_of(target) {
1299            if value.as_str() != Some(SECRET_SHOWN) {
1300                continue;
1301            }
1302            if let Some((_, old)) = secrets.iter().find(|(kept, _)| *kept == field) {
1303                replace_value(value, old.clone());
1304            }
1305        }
1306    }
1307    document.to_string()
1308}
1309
1310/// Where a target keeps a secret.
1311#[derive(Debug, PartialEq, Eq)]
1312enum SecretField {
1313    Url,
1314    Header(String),
1315}
1316
1317/// The tables of `[[notify.targets]]`, or of an inline `targets = [...]`.
1318fn notify_targets(document: &mut toml_edit::DocumentMut) -> Vec<&mut dyn toml_edit::TableLike> {
1319    match document
1320        .get_mut("notify")
1321        .and_then(|notify| notify.get_mut("targets"))
1322    {
1323        Some(toml_edit::Item::ArrayOfTables(tables)) => tables
1324            .iter_mut()
1325            .map(|table| table as &mut dyn toml_edit::TableLike)
1326            .collect(),
1327        Some(toml_edit::Item::Value(toml_edit::Value::Array(array))) => array
1328            .iter_mut()
1329            .filter_map(toml_edit::Value::as_inline_table_mut)
1330            .map(|table| table as &mut dyn toml_edit::TableLike)
1331            .collect(),
1332        _ => Vec::new(),
1333    }
1334}
1335
1336fn target_name(target: &dyn toml_edit::TableLike) -> Option<String> {
1337    target
1338        .get("name")
1339        .and_then(toml_edit::Item::as_str)
1340        .map(str::to_owned)
1341}
1342
1343/// A target's `url` and header values.
1344fn secrets_of(target: &mut dyn toml_edit::TableLike) -> Vec<(SecretField, &mut toml_edit::Value)> {
1345    let mut found = Vec::new();
1346    for (key, item) in target.iter_mut() {
1347        match key.get() {
1348            "url" => found.extend(item.as_value_mut().map(|value| (SecretField::Url, value))),
1349            "headers" => {
1350                let Some(headers) = item.as_table_like_mut() else {
1351                    continue;
1352                };
1353                for (name, item) in headers.iter_mut() {
1354                    let field = SecretField::Header(name.get().to_owned());
1355                    found.extend(item.as_value_mut().map(|value| (field, value)));
1356                }
1357            }
1358            _ => {}
1359        }
1360    }
1361    found
1362}
1363
1364/// Puts `new` in place of `value`, with the spaces and comment around it as they were.
1365fn replace_value(value: &mut toml_edit::Value, mut new: toml_edit::Value) {
1366    *new.decor_mut() = value.decor().clone();
1367    *value = new;
1368}
1369
1370/// A config text's revision: a hash of its bytes (64-bit FNV-1a, in hex), the same in every runnerd. `PUT
1371/// /v1/config?revision=` refuses a text sent against another revision than the daemon's.
1372pub fn revision(text: &str) -> String {
1373    let hash = text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
1374        (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
1375    });
1376    format!("{hash:016x}")
1377}
1378
1379/// Reads a slice's `MemoryMax` from systemd; `None` when it is unlimited.
1380pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
1381    let output = Command::new("systemctl")
1382        .args(["show", slice, "--property=MemoryMax", "--value"])
1383        .output()?;
1384    if !output.status.success() {
1385        return Err(std::io::Error::other(format!(
1386            "systemctl show {slice} failed: {}",
1387            String::from_utf8_lossy(&output.stderr).trim()
1388        )));
1389    }
1390    let value = String::from_utf8_lossy(&output.stdout);
1391    parse_memory_max(value.trim()).ok_or_else(|| {
1392        std::io::Error::other(format!(
1393            "systemctl show {slice}: unexpected MemoryMax {value:?}"
1394        ))
1395    })
1396}
1397
1398/// The CPU budget, in millicores, of `millicores` of CPUs overcommitted `overcommit` times.
1399pub fn cpu_budget(millicores: u64, overcommit: f64) -> u64 {
1400    #[expect(
1401        clippy::cast_precision_loss,
1402        clippy::cast_possible_truncation,
1403        clippy::cast_sign_loss,
1404        reason = "a host's millicores are far below 2^52, and the config check holds the factor above 0 and at \
1405                  most 16"
1406    )]
1407    let budget = (millicores as f64 * overcommit.max(0.0)).round() as u64;
1408    budget
1409}
1410
1411/// Reads the CPUs a slice's processes may use from systemd, in millicores: its `EffectiveCPUs` (or `AllowedCPUs`,
1412/// else the host's CPUs), less when its `CPUQuota` is lower.
1413pub fn slice_cpus(slice: &str) -> std::io::Result<u64> {
1414    let output = Command::new("systemctl")
1415        .args([
1416            "show",
1417            slice,
1418            "--property=EffectiveCPUs",
1419            "--property=AllowedCPUs",
1420            "--property=CPUQuotaPerSecUSec",
1421        ])
1422        .output()?;
1423    if !output.status.success() {
1424        return Err(std::io::Error::other(format!(
1425            "systemctl show {slice} failed: {}",
1426            String::from_utf8_lossy(&output.stderr).trim()
1427        )));
1428    }
1429    let host = std::thread::available_parallelism().map_or(1, std::num::NonZero::get) as u64;
1430    parse_slice_cpus(&String::from_utf8_lossy(&output.stdout), host).ok_or_else(|| {
1431        std::io::Error::other(format!(
1432            "systemctl show {slice}: unexpected CPU properties {:?}",
1433            String::from_utf8_lossy(&output.stdout).trim()
1434        ))
1435    })
1436}
1437
1438/// The CPUs, in millicores, that `systemctl show`'s `EffectiveCPUs`, `AllowedCPUs` and `CPUQuotaPerSecUSec` lines
1439/// leave a slice on a host with `host` CPUs; `None` when a value does not parse.
1440pub fn parse_slice_cpus(properties: &str, host: u64) -> Option<u64> {
1441    let value = |name: &str| {
1442        properties.lines().find_map(|line| {
1443            line.strip_prefix(name)
1444                .and_then(|rest| rest.strip_prefix('='))
1445                .map(str::trim)
1446        })
1447    };
1448    let set = [value("EffectiveCPUs"), value("AllowedCPUs")]
1449        .into_iter()
1450        .flatten()
1451        .find(|cpus| !cpus.is_empty());
1452    let cpus = match set {
1453        Some(set) => cpu_count(set)?,
1454        None => host,
1455    };
1456    let quota = match value("CPUQuotaPerSecUSec") {
1457        None | Some("" | "infinity") => u64::MAX,
1458        Some(quota) => quota_millicores(quota)?,
1459    };
1460    Some(cpus.saturating_mul(1000).min(quota))
1461}
1462
1463/// How many CPUs a set names, as systemd prints it (`0-3 8-11`) or as it is written (`0-3,8,10-11`).
1464fn cpu_count(set: &str) -> Option<u64> {
1465    set.split([',', ' '])
1466        .filter(|range| !range.is_empty())
1467        .try_fold(0u64, |count, range| {
1468            let (first, last) = range.split_once('-').unwrap_or((range, range));
1469            let (first, last): (u64, u64) = (first.parse().ok()?, last.parse().ok()?);
1470            Some(count + last.checked_sub(first)? + 1)
1471        })
1472}
1473
1474/// A `CPUQuotaPerSecUSec` as systemd prints a timespan, such as `800ms`, `2.500000s` or `1min 4s`, as the
1475/// millicores it allows: a second of CPU time each second is 1000.
1476fn quota_millicores(quota: &str) -> Option<u64> {
1477    // Each unit's length in milliseconds, as systemd's `format_timespan` names them.
1478    const UNITS: [(&str, f64); 10] = [
1479        ("y", 31_557_600_000.0),
1480        ("M", 2_629_800_000.0),
1481        ("w", 604_800_000.0),
1482        ("d", 86_400_000.0),
1483        ("h", 3_600_000.0),
1484        ("min", 60_000.0),
1485        ("s", 1000.0),
1486        ("ms", 1.0),
1487        ("us", 0.001),
1488        ("µs", 0.001),
1489    ];
1490    let millicores = quota.split_whitespace().try_fold(0.0, |total, part| {
1491        let at = part.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
1492        let (number, unit) = part.split_at(at);
1493        let per_unit = UNITS
1494            .iter()
1495            .find_map(|(name, per_unit)| (*name == unit).then_some(*per_unit))?;
1496        Some(total + number.parse::<f64>().ok()? * per_unit)
1497    })?;
1498    #[allow(
1499        clippy::cast_possible_truncation,
1500        clippy::cast_sign_loss,
1501        reason = "a quota is a few thousand millicores per CPU, checked finite and not negative first"
1502    )]
1503    (!quota.trim().is_empty() && millicores.is_finite() && millicores >= 0.0)
1504        .then(|| millicores.round() as u64)
1505}
1506
1507/// Parses a `MemoryMax` value as `systemctl show` prints it: bytes, or `infinity`.
1508pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
1509    if value == "infinity" {
1510        Some(None)
1511    } else {
1512        value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
1513    }
1514}
1515
1516/// A size in bytes, written with a binary unit as Docker and systemd do: `512M`, `9G`, `32.5G`.
1517#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1518pub struct ByteSize(pub u64);
1519
1520const UNITS: [(char, u64); 4] = [
1521    ('T', 1 << 40),
1522    ('G', 1 << 30),
1523    ('M', 1 << 20),
1524    ('K', 1 << 10),
1525];
1526
1527impl FromStr for ByteSize {
1528    type Err = String;
1529
1530    #[allow(
1531        clippy::cast_precision_loss,
1532        clippy::cast_possible_truncation,
1533        clippy::cast_sign_loss,
1534        reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
1535    )]
1536    fn from_str(s: &str) -> Result<Self, Self::Err> {
1537        let invalid = || {
1538            format!(
1539                "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
1540            )
1541        };
1542        let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
1543        let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
1544        let number = &s[..s.len() - 1];
1545        if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
1546            return Err(invalid());
1547        }
1548        let number: f64 = number.parse().map_err(|_| invalid())?;
1549        let bytes = (number * *factor as f64).round();
1550        // `u64::MAX as f64` is 2^64, the first value out of range.
1551        if bytes >= u64::MAX as f64 {
1552            return Err(format!("size {s:?} is too large"));
1553        }
1554        Ok(ByteSize(bytes as u64))
1555    }
1556}
1557
1558impl fmt::Display for ByteSize {
1559    #[allow(
1560        clippy::cast_precision_loss,
1561        reason = "shown with two decimals, far coarser than f64's precision"
1562    )]
1563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1564        let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
1565            return write!(f, "{}B", self.0);
1566        };
1567        let number = format!("{:.2}", self.0 as f64 / *factor as f64);
1568        let number = number.trim_end_matches('0').trim_end_matches('.');
1569        write!(f, "{number}{unit}")
1570    }
1571}
1572
1573impl<'de> Deserialize<'de> for ByteSize {
1574    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1575        String::deserialize(deserializer)?
1576            .parse()
1577            .map_err(de::Error::custom)
1578    }
1579}
1580
1581/// Parses a duration as Go and the registry write it, `30s`, `90m`, `168h`, or in days: `7d`.
1582pub fn parse_duration(s: &str) -> Result<Duration, String> {
1583    let invalid = || {
1584        format!(
1585            "invalid duration {s:?}, expected a number and s, m, h or d, such as \"168h\" or \"7d\""
1586        )
1587    };
1588    let (number, factor) = match s.chars().last() {
1589        Some('s') => (&s[..s.len() - 1], 1),
1590        Some('m') => (&s[..s.len() - 1], 60),
1591        Some('h') => (&s[..s.len() - 1], 3600),
1592        Some('d') => (&s[..s.len() - 1], 86400),
1593        _ => return Err(invalid()),
1594    };
1595    let number: u64 = number.parse().map_err(|_| invalid())?;
1596    number
1597        .checked_mul(factor)
1598        .map(Duration::from_secs)
1599        .ok_or_else(invalid)
1600}
1601
1602/// Parses an age as the clients' `--since` and `--older-than` take it: `30m`, `12h`, `7d`, `2w`.
1603pub fn parse_age(text: &str) -> Result<Duration, String> {
1604    let error = || "expected a number and s, m, h, d or w, such as 12h".to_owned();
1605    let split = text.find(|c: char| !c.is_ascii_digit()).ok_or_else(error)?;
1606    let (number, unit) = text.split_at(split);
1607    let number: u64 = number.parse().map_err(|_| error())?;
1608    let unit = match unit {
1609        "s" => 1,
1610        "m" => 60,
1611        "h" => 3600,
1612        "d" => 86_400,
1613        "w" => 7 * 86_400,
1614        _ => return Err(error()),
1615    };
1616    Ok(Duration::from_secs(number.saturating_mul(unit)))
1617}
1618
1619fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
1620    parse_duration(&String::deserialize(deserializer)?).map_err(de::Error::custom)
1621}
1622
1623#[cfg(test)]
1624mod tests;