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