1use 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#[derive(Debug, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Config {
25 pub host: Host,
27 pub caches: Caches,
29 pub profiles: BTreeMap<String, Profile>,
31 pub repositories: Vec<Repository>,
34 #[serde(default)]
36 pub history: History,
37 #[serde(default)]
39 pub records: Records,
40 #[serde(default)]
42 pub sizing: Sizing,
43 #[serde(default)]
45 pub web: Web,
46 #[serde(default)]
48 pub notify: Notify,
49}
50
51#[derive(Debug, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct Host {
55 pub slice: String,
57 pub isolation: Isolation,
59 #[serde(default)]
61 pub allow_unsafe: bool,
62 #[serde(default = "default_poll_interval", deserialize_with = "duration")]
64 pub poll_interval: Duration,
65 #[serde(default)]
67 pub admission: Admission,
68 #[serde(default)]
71 pub cpu_overcommit: Option<f64>,
72}
73
74impl Host {
75 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
86pub const MAX_CPU_OVERCOMMIT: f64 = 16.0;
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
91#[serde(rename_all = "kebab-case")]
92pub enum Admission {
93 #[default]
95 Caps,
96 Measured,
99}
100
101impl Admission {
102 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
117#[serde(rename_all = "kebab-case")]
118pub enum Isolation {
119 Sysbox,
121 UnsafeDind,
123}
124
125#[derive(Debug, Clone, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct Caches {
129 pub mirror: Mirror,
131 pub pnpm: CacheLimit,
133 pub maven: CacheLimit,
135 #[serde(default = "default_reseed_at")]
138 pub reseed_at: ByteSize,
139 #[serde(default = "default_snapshot_max")]
142 pub snapshot_max: ByteSize,
143 #[serde(default)]
145 pub build: BuildCache,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
151#[serde(deny_unknown_fields, default)]
152pub struct BuildCache {
153 pub enabled: bool,
155 pub max_size: ByteSize,
157 pub branches: Vec<String>,
160 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
190fn default_snapshot_max() -> ByteSize {
192 ByteSize(10 << 30)
193}
194
195fn default_mirror_max_size() -> ByteSize {
196 ByteSize(10 << 30)
197}
198
199#[derive(Debug, Clone, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct Mirror {
203 pub enabled: bool,
205 pub memory: ByteSize,
207 pub cpus: u32,
209 #[serde(deserialize_with = "duration")]
211 pub ttl: Duration,
212 #[serde(default = "default_mirror_max_size")]
214 pub max_size: ByteSize,
215}
216
217#[derive(Debug, Clone, Deserialize)]
219#[serde(deny_unknown_fields)]
220pub struct CacheLimit {
221 pub max_size: ByteSize,
223}
224
225pub const PUBLISHED_RUNNER: &str = "ghcr.io/ayagmar/runnerd-runner";
228
229#[derive(Debug, Clone, Deserialize)]
231#[serde(try_from = "ProfileToml")]
232pub struct Profile {
233 pub image: Image,
235 pub cpus: u32,
237 pub memory: ByteSize,
239 pub slots: u32,
241 pub record_failures: bool,
243 pub min_memory: Option<ByteSize>,
245 pub max_memory: Option<ByteSize>,
247 pub max_slots: Option<u32>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum Image {
254 Stage(String),
256 Pulled(String),
259}
260
261impl Image {
262 pub fn reference(&self) -> String {
264 match self {
265 Image::Stage(stage) => format!("runnerd-runner:{stage}"),
266 Image::Pulled(reference) => reference.clone(),
267 }
268 }
269}
270
271#[derive(Deserialize)]
273#[serde(deny_unknown_fields)]
274struct ProfileToml {
275 image_stage: Option<String>,
276 image: Option<String>,
277 cpus: u32,
278 memory: ByteSize,
279 slots: u32,
280 #[serde(default)]
281 record_failures: bool,
282 min_memory: Option<ByteSize>,
283 max_memory: Option<ByteSize>,
284 max_slots: Option<u32>,
285}
286
287impl TryFrom<ProfileToml> for Profile {
288 type Error = String;
289
290 fn try_from(profile: ProfileToml) -> Result<Profile, String> {
291 let image = match (profile.image_stage, profile.image) {
292 (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
293 (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
294 (Some(_), Some(_)) => {
295 return Err(
296 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
297 not both"
298 .to_owned(),
299 );
300 }
301 _ => {
302 return Err(
303 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
304 .to_owned(),
305 );
306 }
307 };
308 Ok(Profile {
309 image,
310 cpus: profile.cpus,
311 memory: profile.memory,
312 slots: profile.slots,
313 record_failures: profile.record_failures,
314 min_memory: profile.min_memory,
315 max_memory: profile.max_memory,
316 max_slots: profile.max_slots,
317 })
318 }
319}
320
321pub fn labels_match(wanted: &[String], labels: &[String]) -> bool {
325 !wanted.is_empty()
326 && wanted.iter().all(|wanted| {
327 labels
328 .iter()
329 .any(|label| label.eq_ignore_ascii_case(wanted))
330 })
331}
332
333#[derive(Debug, Deserialize)]
336#[serde(deny_unknown_fields)]
337pub struct Repository {
338 pub name: String,
340 pub profile: String,
342 pub labels: Vec<String>,
344 #[serde(default = "default_warm")]
347 pub warm: u32,
348 #[serde(default = "default_true")]
350 pub build_cache: bool,
351 #[serde(default)]
354 pub build_cache_writers: BuildCacheWriters,
355}
356
357#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
359#[serde(rename_all = "kebab-case")]
360pub enum BuildCacheWriters {
361 #[default]
363 DefaultBranch,
364 PullRequests,
366}
367
368fn default_true() -> bool {
369 true
370}
371
372fn default_warm() -> u32 {
373 1
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
378#[serde(deny_unknown_fields, default)]
379pub struct History {
380 pub database: Option<PathBuf>,
382 #[serde(deserialize_with = "duration")]
384 pub samples: Duration,
385 #[serde(deserialize_with = "duration")]
387 pub logs: Duration,
388 #[serde(deserialize_with = "duration")]
390 pub events: Duration,
391 #[serde(deserialize_with = "duration")]
393 pub jobs: Duration,
394 pub log_max: ByteSize,
396}
397
398impl Default for History {
399 fn default() -> History {
400 History {
401 database: None,
402 samples: Duration::from_hours(7 * 24),
403 logs: Duration::from_hours(30 * 24),
404 events: Duration::from_hours(30 * 24),
405 jobs: Duration::from_hours(90 * 24),
406 log_max: ByteSize(50 << 20),
407 }
408 }
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
414#[serde(deny_unknown_fields, default)]
415pub struct Records {
416 pub max_total: ByteSize,
418 pub max_count: u32,
420 #[serde(deserialize_with = "duration")]
422 pub max_age: Duration,
423 pub min_free: ByteSize,
425}
426
427impl Default for Records {
428 fn default() -> Records {
429 Records {
430 max_total: ByteSize(20 << 30),
431 max_count: 10,
432 max_age: Duration::from_hours(7 * 24),
433 min_free: ByteSize(10 << 30),
434 }
435 }
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
441#[serde(deny_unknown_fields, default)]
442pub struct Sizing {
443 pub margin: u32,
446 pub min_jobs: u32,
449 pub auto: bool,
452 #[serde(deserialize_with = "duration")]
455 pub interval: Duration,
456 #[serde(deserialize_with = "duration")]
459 pub raise_interval: Duration,
460 pub max_step: u32,
463}
464
465impl Sizing {
466 pub fn raise_after(&self) -> Duration {
469 self.raise_interval.min(self.interval)
470 }
471}
472
473impl Default for Sizing {
474 fn default() -> Sizing {
475 Sizing {
476 margin: 15,
477 min_jobs: 10,
478 auto: false,
479 interval: Duration::from_hours(24),
480 raise_interval: Duration::from_hours(1),
481 max_step: 25,
482 }
483 }
484}
485
486#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
488#[serde(deny_unknown_fields, default)]
489pub struct Web {
490 pub origins: Vec<Origin>,
493 pub open: Option<Origin>,
496}
497
498impl Web {
499 fn validate(&self) -> Result<(), ConfigError> {
501 match &self.open {
502 Some(open) if !self.origins.contains(open) => {
503 Err(ConfigError::OpenNotListed { open: open.clone() })
504 }
505 _ => Ok(()),
506 }
507 }
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
512#[serde(deny_unknown_fields, default)]
513pub struct Notify {
514 pub digest_at: DayTime,
516 pub targets: Vec<NotifyTarget>,
518}
519
520impl Default for Notify {
521 fn default() -> Notify {
522 Notify {
523 digest_at: DayTime { minutes: 9 * 60 },
524 targets: Vec::new(),
525 }
526 }
527}
528
529impl Notify {
530 fn validate(&self) -> Result<(), ConfigError> {
533 let refuse = |target: &str, why: &'static str| {
534 Err(ConfigError::Notify {
535 target: target.to_owned(),
536 why,
537 })
538 };
539 for (index, target) in self.targets.iter().enumerate() {
540 let name = target.name.as_str();
541 if name.trim().is_empty() || name.chars().any(char::is_control) {
542 return refuse(name, "name is empty");
543 }
544 if self.targets[..index].iter().any(|other| other.name == name) {
545 return refuse(name, "another target has this name");
546 }
547 let url = target.url.expose();
548 if url == SECRET_SHOWN {
549 return refuse(
550 name,
551 "url is <secret>, which runnerd fills in only for a target of the same name that had one; write the url",
552 );
553 }
554 let Some(rest) = url
555 .strip_prefix("https://")
556 .or_else(|| url.strip_prefix("http://"))
557 else {
558 return refuse(name, "url does not start with https:// or http://");
559 };
560 let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
561 if host.is_empty() || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
562 return refuse(name, "url is not a URL");
563 }
564 if target.kind == NotifyKind::Ntfy
565 && (path.is_empty() || path.contains(['/', '?', '#']))
566 {
567 return refuse(
568 name,
569 "url is not an ntfy topic's, such as https://ntfy.sh/<topic>",
570 );
571 }
572 for (header, value) in &target.headers {
573 if value.expose() == SECRET_SHOWN {
574 return refuse(
575 name,
576 "a header is <secret>, which runnerd fills in only for a target of the same name that had it; write its value",
577 );
578 }
579 let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
580 if header.is_empty() || !header.chars().all(token) {
581 return refuse(name, "a header's name is not a header name");
582 }
583 if value.expose().chars().any(char::is_control) {
584 return refuse(name, "a header's value has a control character");
585 }
586 }
587 if target.events.contains(&api::NotifyEvent::Test) {
588 return refuse(
589 name,
590 "events lists test, which only runnerctl notify test sends",
591 );
592 }
593 }
594 Ok(())
595 }
596}
597
598#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
600#[serde(deny_unknown_fields)]
601pub struct NotifyTarget {
602 pub name: String,
604 pub kind: NotifyKind,
606 pub url: Secret,
609 #[serde(default)]
611 pub headers: BTreeMap<String, Secret>,
612 #[serde(default = "default_notify_events")]
614 pub events: Vec<api::NotifyEvent>,
615}
616
617fn default_notify_events() -> Vec<api::NotifyEvent> {
618 vec![
619 api::NotifyEvent::Failed,
620 api::NotifyEvent::Lost,
621 api::NotifyEvent::Runner,
622 api::NotifyEvent::Insights,
623 ]
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
628#[serde(rename_all = "kebab-case")]
629pub enum NotifyKind {
630 Webhook,
632 Ntfy,
634 Discord,
636}
637
638#[derive(Clone, PartialEq, Eq, Deserialize)]
640#[serde(transparent)]
641pub struct Secret(String);
642
643impl Secret {
644 pub fn expose(&self) -> &str {
646 &self.0
647 }
648}
649
650impl From<&str> for Secret {
651 fn from(value: &str) -> Secret {
652 Secret(value.to_owned())
653 }
654}
655
656impl fmt::Debug for Secret {
657 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
658 f.write_str("<secret>")
659 }
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub struct DayTime {
665 pub minutes: u32,
667}
668
669impl FromStr for DayTime {
670 type Err = String;
671
672 fn from_str(text: &str) -> Result<DayTime, String> {
673 let invalid = || format!("invalid time of day {text:?}, expected HH:MM such as \"09:00\"");
674 let (hour, minute) = text.split_once(':').ok_or_else(invalid)?;
675 let digits = |part: &str| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit());
676 if !digits(hour) || !digits(minute) {
677 return Err(invalid());
678 }
679 let (hour, minute): (u32, u32) = (
680 hour.parse().map_err(|_| invalid())?,
681 minute.parse().map_err(|_| invalid())?,
682 );
683 if hour > 23 || minute > 59 {
684 return Err(invalid());
685 }
686 Ok(DayTime {
687 minutes: hour * 60 + minute,
688 })
689 }
690}
691
692impl fmt::Display for DayTime {
693 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
694 write!(f, "{:02}:{:02}", self.minutes / 60, self.minutes % 60)
695 }
696}
697
698impl<'de> Deserialize<'de> for DayTime {
699 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
700 String::deserialize(deserializer)?
701 .parse()
702 .map_err(de::Error::custom)
703 }
704}
705
706#[derive(Debug, Clone, PartialEq, Eq)]
710pub struct Origin(String);
711
712impl Origin {
713 pub fn as_str(&self) -> &str {
715 &self.0
716 }
717}
718
719impl FromStr for Origin {
720 type Err = String;
721
722 fn from_str(s: &str) -> Result<Origin, String> {
723 let invalid = |why: &str| {
724 format!(
725 "invalid origin {s:?}: {why}; write the scheme, host and port only, such as \
726 \"https://host.example.ts.net:8443\""
727 )
728 };
729 let (scheme, rest) = s
730 .split_once("://")
731 .ok_or_else(|| invalid("it has no scheme"))?;
732 let scheme = scheme.to_ascii_lowercase();
733 if rest.contains(['/', '?', '#']) {
734 return Err(invalid("it has a path"));
735 }
736 if rest.contains('@') {
737 return Err(invalid("it names a user"));
738 }
739 let (host, port) = match rest.strip_prefix('[') {
740 Some(v6) => {
741 let (address, after) = v6
742 .split_once(']')
743 .ok_or_else(|| invalid("its IPv6 address has no closing ]"))?;
744 let port = match after {
745 "" => None,
746 _ => Some(after.strip_prefix(':').ok_or_else(|| invalid("its port"))?),
747 };
748 (format!("[{address}]"), port)
749 }
750 None => match rest.split_once(':') {
751 Some((host, port)) => (host.to_owned(), Some(port)),
752 None => (rest.to_owned(), None),
753 },
754 };
755 let host = host.to_ascii_lowercase();
756 let not_a_host = || invalid("its host is not a name or an address");
757 let (host, loopback) =
759 if let Some(v6) = host.strip_prefix('[').and_then(|v6| v6.strip_suffix(']')) {
760 let address: Ipv6Addr = v6.parse().map_err(|_| not_a_host())?;
761 (format!("[{}]", ipv6_origin(address)), address.is_loopback())
762 } else {
763 if host.is_empty()
764 || !host
765 .chars()
766 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
767 {
768 return Err(not_a_host());
769 }
770 let name = host.strip_suffix('.').unwrap_or(&host);
772 let last = name.rsplit('.').next().unwrap_or_default();
773 let numeric = !last.is_empty()
774 && (last.bytes().all(|byte| byte.is_ascii_digit())
775 || last
776 .strip_prefix("0x")
777 .is_some_and(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit())));
778 if numeric {
779 let address: Ipv4Addr = name.parse().map_err(|_| {
780 invalid("its IPv4 address is not four decimal numbers, such as 192.0.2.1")
781 })?;
782 (address.to_string(), address.is_loopback())
783 } else {
784 let loopback = host == "localhost";
785 (host, loopback)
786 }
787 };
788 let port = port
789 .map(|port| {
790 Some(port)
791 .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit()))
792 .and_then(|port| port.parse::<u16>().ok())
793 .filter(|port| *port != 0)
794 .ok_or_else(|| invalid("its port is not a number from 1 to 65535"))
795 })
796 .transpose()?;
797 let default_port = match scheme.as_str() {
798 "https" => 443,
799 "http" if loopback => 80,
800 "http" => {
801 return Err(invalid(
802 "http is for a loopback host only, since the session cookie would cross the network in \
803 the clear; use https",
804 ));
805 }
806 _ => return Err(invalid("its scheme is not https")),
807 };
808 Ok(Origin(match port {
809 Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
810 _ => format!("{scheme}://{host}"),
811 }))
812 }
813}
814
815fn ipv6_origin(address: Ipv6Addr) -> String {
818 let pieces = address.segments();
819 let (mut zeros_at, mut zeros) = (0, 0);
820 let mut at = 0;
821 while at < pieces.len() {
822 let run = pieces[at..].iter().take_while(|piece| **piece == 0).count();
823 if run > zeros {
824 (zeros_at, zeros) = (at, run);
825 }
826 at += run.max(1);
827 }
828 let hex = |pieces: &[u16]| {
829 pieces
830 .iter()
831 .map(|piece| format!("{piece:x}"))
832 .collect::<Vec<_>>()
833 .join(":")
834 };
835 if zeros < 2 {
836 return hex(&pieces);
837 }
838 format!(
839 "{}::{}",
840 hex(&pieces[..zeros_at]),
841 hex(&pieces[zeros_at + zeros..])
842 )
843}
844
845impl fmt::Display for Origin {
846 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
847 f.write_str(&self.0)
848 }
849}
850
851impl<'de> Deserialize<'de> for Origin {
852 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
853 String::deserialize(deserializer)?
854 .parse()
855 .map_err(de::Error::custom)
856 }
857}
858
859#[derive(Debug, thiserror::Error)]
861pub enum ConfigError {
862 #[error("cannot read {}: {source}", path.display())]
864 Read {
865 path: PathBuf,
867 source: std::io::Error,
869 },
870 #[error(transparent)]
872 Parse(#[from] toml::de::Error),
873 #[error(
875 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
876 defined_profiles(.defined)
877 )]
878 UnknownProfile {
879 repository: String,
881 profile: String,
883 defined: Vec<String>,
885 },
886 #[error("repository {repository} needs at least one non-empty label")]
888 MissingLabels {
889 repository: String,
891 },
892 #[error(
894 "repository {repository} has two entries with profile {profile:?}; give each of its pools its own profile"
895 )]
896 PoolProfileTwice {
897 repository: String,
899 profile: String,
901 },
902 #[error(
905 "repository {repository}'s pools in {first} and {second} both have the label {label:?}, so a job asking for \
906 it could land in either; give each pool labels of its own, such as only {second:?}'s own label for the \
907 lighter one"
908 )]
909 PoolLabelsOverlap {
910 repository: String,
912 first: String,
914 second: String,
916 label: String,
918 },
919 #[error(
921 "repository {repository}'s entries set build_cache_writers differently; whose runs may write to the build \
922 cache is the repository's, so set the same in each"
923 )]
924 PoolWritersDiffer {
925 repository: String,
927 },
928 #[error(
930 "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
931 set allow_unsafe = true under [host] to use it anyway"
932 )]
933 UnsafeNotAllowed,
934 #[error("{field} is 0; set it above 0")]
936 ZeroLimit {
937 field: String,
939 },
940 #[error(
942 "host.cpu_overcommit is {value}; set it above 0 and at most {MAX_CPU_OVERCOMMIT}, such as 1.0"
943 )]
944 CpuOvercommit {
945 value: f64,
947 },
948 #[error(
950 "web.open is {open}, which is not one of [web] origins; add it to origins, or remove open"
951 )]
952 OpenNotListed {
953 open: Origin,
955 },
956 #[error("profiles.{profile}: {why}")]
959 SizingBounds {
960 profile: String,
962 why: &'static str,
964 },
965 #[error("notify target {target:?}: {why}")]
967 Notify {
968 target: String,
970 why: &'static str,
972 },
973 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
975 BudgetOverflow,
976 #[error(
978 "admission = \"measured\" admits runners against the slice {slice}'s MemoryMax, and it has none; install \
979 a unit file for it (`runnerd doctor` prints one)"
980 )]
981 MeasuredWithoutMemoryMax {
982 slice: String,
984 },
985 #[error(
987 "one runner of {profile} and the mirror take {needs}, the slice {slice}'s MemoryMax is {memory_max}; lower \
988 its `memory` or raise the slice's MemoryMax"
989 )]
990 ProfileOverBudget {
991 profile: String,
993 needs: ByteSize,
995 slice: String,
997 memory_max: ByteSize,
999 },
1000 #[error(
1002 "slots × memory over all profiles plus the mirror is {total}, \
1003 the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
1004 MemoryMax (`runnerd doctor` prints its unit)"
1005 )]
1006 OverBudget {
1007 total: ByteSize,
1009 slice: String,
1011 memory_max: ByteSize,
1013 },
1014}
1015
1016fn defined_profiles(defined: &[String]) -> String {
1017 if defined.is_empty() {
1018 "it defines none".to_owned()
1019 } else {
1020 format!("defined: {}", defined.join(", "))
1021 }
1022}
1023
1024#[derive(Debug, Clone, PartialEq, Eq)]
1026pub enum Warning {
1027 UnlimitedSlice {
1029 slice: String,
1031 total: ByteSize,
1033 },
1034}
1035
1036impl fmt::Display for Warning {
1037 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1038 match self {
1039 Warning::UnlimitedSlice { slice, total } => write!(
1040 f,
1041 "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
1042 install a unit file for it"
1043 ),
1044 }
1045 }
1046}
1047
1048impl Config {
1049 pub fn load(path: &Path) -> Result<Config, ConfigError> {
1051 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
1052 path: path.to_owned(),
1053 source,
1054 })?;
1055 Config::parse(&text)
1056 }
1057
1058 pub fn parse(text: &str) -> Result<Config, ConfigError> {
1060 Ok(toml::from_str(text)?)
1061 }
1062
1063 pub fn check(
1066 text: &str,
1067 memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
1068 ) -> (Option<Config>, api::ConfigCheck) {
1069 let config = match Config::parse(text) {
1070 Ok(config) => config,
1071 Err(error) => return (None, budget::invalid(error.to_string(), None, None)),
1072 };
1073 let max = match memory_max(&config.host.slice) {
1074 Ok(max) => max,
1075 Err(error) => {
1076 return (
1077 None,
1078 budget::invalid(error.to_string(), Some(&config), None),
1079 );
1080 }
1081 };
1082 let check = config.check_against(max);
1083 if check.valid {
1084 (Some(config), check)
1085 } else {
1086 (None, check)
1087 }
1088 }
1089
1090 pub fn mirror_memory(&self) -> u64 {
1092 if self.caches.mirror.enabled {
1093 self.caches.mirror.memory.0
1094 } else {
1095 0
1096 }
1097 }
1098
1099 pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
1101 let mirror = self.mirror_memory();
1102 self.profiles
1103 .values()
1104 .try_fold(mirror, |total, p| {
1105 p.memory
1106 .0
1107 .checked_mul(u64::from(p.slots))?
1108 .checked_add(total)
1109 })
1110 .map(ByteSize)
1111 .ok_or(ConfigError::BudgetOverflow)
1112 }
1113
1114 pub fn memory_needed(&self) -> Result<ByteSize, ConfigError> {
1117 match self.host.admission {
1118 Admission::Caps => self.memory_caps(),
1119 Admission::Measured => {
1120 let largest = self
1121 .profiles
1122 .values()
1123 .map(|p| p.memory.0)
1124 .max()
1125 .unwrap_or(0);
1126 largest
1127 .checked_add(self.mirror_memory())
1128 .map(ByteSize)
1129 .ok_or(ConfigError::BudgetOverflow)
1130 }
1131 }
1132 }
1133
1134 fn check_sizing_bounds(&self) -> Result<(), ConfigError> {
1136 for (name, p) in &self.profiles {
1137 let memory = p.memory.0;
1138 let field = if p
1139 .min_memory
1140 .zip(p.max_memory)
1141 .is_some_and(|(min, max)| min.0 > max.0)
1142 {
1143 "min_memory is above its max_memory"
1144 } else if p.min_memory.is_some_and(|min| memory < min.0) {
1145 "memory is below its min_memory"
1146 } else if p.max_memory.is_some_and(|max| memory > max.0) {
1147 "memory is above its max_memory"
1148 } else if p.max_slots.is_some_and(|max| p.slots > max) {
1149 "slots are above its max_slots"
1150 } else {
1151 continue;
1152 };
1153 return Err(ConfigError::SizingBounds {
1154 profile: name.clone(),
1155 why: field,
1156 });
1157 }
1158 Ok(())
1159 }
1160
1161 fn check_pools(&self) -> Result<(), ConfigError> {
1164 for (at, first) in self.repositories.iter().enumerate() {
1165 for second in self.repositories[at + 1..]
1166 .iter()
1167 .filter(|second| second.name == first.name)
1168 {
1169 if second.profile == first.profile {
1170 return Err(ConfigError::PoolProfileTwice {
1171 repository: first.name.clone(),
1172 profile: first.profile.clone(),
1173 });
1174 }
1175 if let Some(label) = first.labels.iter().find(|label| {
1176 second
1177 .labels
1178 .iter()
1179 .any(|other| other.eq_ignore_ascii_case(label))
1180 }) {
1181 return Err(ConfigError::PoolLabelsOverlap {
1182 repository: first.name.clone(),
1183 first: first.profile.clone(),
1184 second: second.profile.clone(),
1185 label: label.clone(),
1186 });
1187 }
1188 if second.build_cache_writers != first.build_cache_writers {
1189 return Err(ConfigError::PoolWritersDiffer {
1190 repository: first.name.clone(),
1191 });
1192 }
1193 }
1194 }
1195 Ok(())
1196 }
1197
1198 pub fn pool(&self, repository: &str, labels: &[String]) -> Option<&Repository> {
1201 self.repositories
1202 .iter()
1203 .find(|pool| pool.name == repository && labels_match(labels, &pool.labels))
1204 }
1205
1206 pub fn pool_in(&self, repository: &str, profile: &str) -> Option<&Repository> {
1208 self.repositories
1209 .iter()
1210 .find(|pool| pool.name == repository && pool.profile == profile)
1211 }
1212
1213 pub fn repository_names(&self) -> Vec<String> {
1215 let mut names: Vec<String> = Vec::new();
1216 for repository in &self.repositories {
1217 if !names.contains(&repository.name) {
1218 names.push(repository.name.clone());
1219 }
1220 }
1221 names
1222 }
1223
1224 pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
1226 if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
1227 return Err(ConfigError::UnsafeNotAllowed);
1228 }
1229 let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
1230 [
1231 (p.memory.0 == 0, format!("profiles.{name}.memory")),
1232 (p.cpus == 0, format!("profiles.{name}.cpus")),
1233 (p.slots == 0, format!("profiles.{name}.slots")),
1234 (p.max_slots == Some(0), format!("profiles.{name}.max_slots")),
1235 ]
1236 });
1237 let mirror = &self.caches.mirror;
1238 let mirror_zero_limits = [
1239 (
1240 mirror.enabled && mirror.memory.0 == 0,
1241 "caches.mirror.memory".to_owned(),
1242 ),
1243 (
1244 mirror.enabled && mirror.cpus == 0,
1245 "caches.mirror.cpus".to_owned(),
1246 ),
1247 (
1248 self.host.poll_interval.is_zero(),
1249 "host.poll_interval".to_owned(),
1250 ),
1251 (self.records.max_count == 0, "records.max_count".to_owned()),
1252 (self.sizing.min_jobs == 0, "sizing.min_jobs".to_owned()),
1253 (self.sizing.interval.is_zero(), "sizing.interval".to_owned()),
1254 (
1255 self.sizing.raise_interval.is_zero(),
1256 "sizing.raise_interval".to_owned(),
1257 ),
1258 (self.sizing.max_step == 0, "sizing.max_step".to_owned()),
1259 (
1260 self.records.max_total.0 == 0,
1261 "records.max_total".to_owned(),
1262 ),
1263 ];
1264 if let Some((_, field)) = zero_limits
1265 .chain(mirror_zero_limits)
1266 .find(|(zero, _)| *zero)
1267 {
1268 return Err(ConfigError::ZeroLimit { field });
1269 }
1270 self.check_sizing_bounds()?;
1271 self.caches.build.validate()?;
1272 self.host.check_cpu_overcommit()?;
1273 self.web.validate()?;
1274 self.notify.validate()?;
1275 for repository in &self.repositories {
1276 if !self.profiles.contains_key(&repository.profile) {
1277 return Err(ConfigError::UnknownProfile {
1278 repository: repository.name.clone(),
1279 profile: repository.profile.clone(),
1280 defined: self.profiles.keys().cloned().collect(),
1281 });
1282 }
1283 if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
1284 {
1285 return Err(ConfigError::MissingLabels {
1286 repository: repository.name.clone(),
1287 });
1288 }
1289 }
1290 self.check_pools()?;
1291 self.check_budget(memory_max)
1292 }
1293
1294 fn check_budget(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
1296 let total = self.memory_caps()?;
1297 if self.host.admission == Admission::Measured {
1298 let Some(memory_max) = memory_max else {
1299 return Err(ConfigError::MeasuredWithoutMemoryMax {
1300 slice: self.host.slice.clone(),
1301 });
1302 };
1303 let mirror = self.mirror_memory();
1304 if let Some((profile, needs)) = self
1305 .profiles
1306 .iter()
1307 .map(|(name, profile)| (name, profile.memory.0.saturating_add(mirror)))
1308 .find(|(_, needs)| *needs > memory_max.0)
1309 {
1310 return Err(ConfigError::ProfileOverBudget {
1311 profile: profile.clone(),
1312 needs: ByteSize(needs),
1313 slice: self.host.slice.clone(),
1314 memory_max,
1315 });
1316 }
1317 let needed = self.memory_needed()?;
1319 if needed > memory_max {
1320 return Err(ConfigError::OverBudget {
1321 total: needed,
1322 slice: self.host.slice.clone(),
1323 memory_max,
1324 });
1325 }
1326 return Ok(Vec::new());
1327 }
1328 match memory_max {
1329 Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
1330 total,
1331 slice: self.host.slice.clone(),
1332 memory_max,
1333 }),
1334 Some(_) => Ok(Vec::new()),
1335 None => Ok(vec![Warning::UnlimitedSlice {
1336 slice: self.host.slice.clone(),
1337 total,
1338 }]),
1339 }
1340 }
1341}
1342
1343#[derive(Debug, thiserror::Error)]
1345pub enum ResizeError {
1346 #[error(transparent)]
1348 Parse(#[from] toml_edit::TomlError),
1349 #[error("the config has no profile {0:?} under [profiles]")]
1351 NoProfile(String),
1352}
1353
1354pub fn resize_profile(
1357 text: &str,
1358 profile: &str,
1359 resize: api::Resize,
1360) -> Result<String, ResizeError> {
1361 let mut document: toml_edit::DocumentMut = text.parse()?;
1362 let table = document
1363 .get_mut("profiles")
1364 .and_then(|profiles| profiles.get_mut(profile))
1365 .and_then(toml_edit::Item::as_table_like_mut)
1366 .ok_or_else(|| ResizeError::NoProfile(profile.to_owned()))?;
1367 let values = [
1368 (
1369 "memory",
1370 toml_edit::Value::from(ByteSize(resize.memory).to_string()),
1371 ),
1372 ("slots", toml_edit::Value::from(i64::from(resize.slots))),
1373 ];
1374 let cpus = resize
1375 .cpus
1376 .map(|cpus| ("cpus", toml_edit::Value::from(i64::from(cpus))));
1377 for (key, mut value) in values.into_iter().chain(cpus) {
1378 if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) {
1380 *value.decor_mut() = old.decor().clone();
1381 }
1382 table.insert(key, toml_edit::Item::Value(value));
1383 }
1384 Ok(document.to_string())
1385}
1386
1387pub const SECRET_SHOWN: &str = "<secret>";
1389
1390pub fn mask_secrets(text: &str) -> Option<String> {
1394 let mut document: toml_edit::DocumentMut = text.parse().ok()?;
1395 let mut masked = false;
1396 for target in notify_targets(&mut document) {
1397 for (_, value) in secrets_of(target) {
1398 replace_value(value, toml_edit::Value::from(SECRET_SHOWN));
1399 masked = true;
1400 }
1401 }
1402 Some(if masked {
1403 document.to_string()
1404 } else {
1405 text.to_owned()
1406 })
1407}
1408
1409pub fn keep_secrets(text: &str, stored: &str) -> String {
1416 if !text.contains(SECRET_SHOWN) || Config::parse(text).is_err() {
1417 return text.to_owned();
1418 }
1419 let (Ok(mut document), Ok(mut old)) = (
1420 text.parse::<toml_edit::DocumentMut>(),
1421 stored.parse::<toml_edit::DocumentMut>(),
1422 ) else {
1423 return text.to_owned();
1424 };
1425 let mut kept: BTreeMap<String, Vec<(SecretField, toml_edit::Value)>> = BTreeMap::new();
1426 for target in notify_targets(&mut old) {
1427 let Some(name) = target_name(target) else {
1428 continue;
1429 };
1430 let secrets = secrets_of(target)
1431 .into_iter()
1432 .map(|(field, value)| (field, value.clone()))
1433 .collect();
1434 kept.insert(name, secrets);
1435 }
1436 for target in notify_targets(&mut document) {
1437 let Some(secrets) = target_name(target).and_then(|name| kept.get(&name)) else {
1438 continue;
1439 };
1440 for (field, value) in secrets_of(target) {
1441 if value.as_str() != Some(SECRET_SHOWN) {
1442 continue;
1443 }
1444 if let Some((_, old)) = secrets.iter().find(|(kept, _)| *kept == field) {
1445 replace_value(value, old.clone());
1446 }
1447 }
1448 }
1449 document.to_string()
1450}
1451
1452#[derive(Debug, PartialEq, Eq)]
1454enum SecretField {
1455 Url,
1456 Header(String),
1457}
1458
1459fn notify_targets(document: &mut toml_edit::DocumentMut) -> Vec<&mut dyn toml_edit::TableLike> {
1461 match document
1462 .get_mut("notify")
1463 .and_then(|notify| notify.get_mut("targets"))
1464 {
1465 Some(toml_edit::Item::ArrayOfTables(tables)) => tables
1466 .iter_mut()
1467 .map(|table| table as &mut dyn toml_edit::TableLike)
1468 .collect(),
1469 Some(toml_edit::Item::Value(toml_edit::Value::Array(array))) => array
1470 .iter_mut()
1471 .filter_map(toml_edit::Value::as_inline_table_mut)
1472 .map(|table| table as &mut dyn toml_edit::TableLike)
1473 .collect(),
1474 _ => Vec::new(),
1475 }
1476}
1477
1478fn target_name(target: &dyn toml_edit::TableLike) -> Option<String> {
1479 target
1480 .get("name")
1481 .and_then(toml_edit::Item::as_str)
1482 .map(str::to_owned)
1483}
1484
1485fn secrets_of(target: &mut dyn toml_edit::TableLike) -> Vec<(SecretField, &mut toml_edit::Value)> {
1487 let mut found = Vec::new();
1488 for (key, item) in target.iter_mut() {
1489 match key.get() {
1490 "url" => found.extend(item.as_value_mut().map(|value| (SecretField::Url, value))),
1491 "headers" => {
1492 let Some(headers) = item.as_table_like_mut() else {
1493 continue;
1494 };
1495 for (name, item) in headers.iter_mut() {
1496 let field = SecretField::Header(name.get().to_owned());
1497 found.extend(item.as_value_mut().map(|value| (field, value)));
1498 }
1499 }
1500 _ => {}
1501 }
1502 }
1503 found
1504}
1505
1506fn replace_value(value: &mut toml_edit::Value, mut new: toml_edit::Value) {
1508 *new.decor_mut() = value.decor().clone();
1509 *value = new;
1510}
1511
1512pub fn revision(text: &str) -> String {
1515 let hash = text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
1516 (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
1517 });
1518 format!("{hash:016x}")
1519}
1520
1521pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
1523 let output = Command::new("systemctl")
1524 .args(["show", slice, "--property=MemoryMax", "--value"])
1525 .output()?;
1526 if !output.status.success() {
1527 return Err(std::io::Error::other(format!(
1528 "systemctl show {slice} failed: {}",
1529 String::from_utf8_lossy(&output.stderr).trim()
1530 )));
1531 }
1532 let value = String::from_utf8_lossy(&output.stdout);
1533 parse_memory_max(value.trim()).ok_or_else(|| {
1534 std::io::Error::other(format!(
1535 "systemctl show {slice}: unexpected MemoryMax {value:?}"
1536 ))
1537 })
1538}
1539
1540pub fn cpu_budget(millicores: u64, overcommit: f64) -> u64 {
1542 #[expect(
1543 clippy::cast_precision_loss,
1544 clippy::cast_possible_truncation,
1545 clippy::cast_sign_loss,
1546 reason = "a host's millicores are far below 2^52, and the config check holds the factor above 0 and at \
1547 most 16"
1548 )]
1549 let budget = (millicores as f64 * overcommit.max(0.0)).round() as u64;
1550 budget
1551}
1552
1553pub fn slice_cpus(slice: &str) -> std::io::Result<u64> {
1556 let output = Command::new("systemctl")
1557 .args([
1558 "show",
1559 slice,
1560 "--property=EffectiveCPUs",
1561 "--property=AllowedCPUs",
1562 "--property=CPUQuotaPerSecUSec",
1563 ])
1564 .output()?;
1565 if !output.status.success() {
1566 return Err(std::io::Error::other(format!(
1567 "systemctl show {slice} failed: {}",
1568 String::from_utf8_lossy(&output.stderr).trim()
1569 )));
1570 }
1571 let host = std::thread::available_parallelism().map_or(1, std::num::NonZero::get) as u64;
1572 parse_slice_cpus(&String::from_utf8_lossy(&output.stdout), host).ok_or_else(|| {
1573 std::io::Error::other(format!(
1574 "systemctl show {slice}: unexpected CPU properties {:?}",
1575 String::from_utf8_lossy(&output.stdout).trim()
1576 ))
1577 })
1578}
1579
1580pub fn parse_slice_cpus(properties: &str, host: u64) -> Option<u64> {
1583 let value = |name: &str| {
1584 properties.lines().find_map(|line| {
1585 line.strip_prefix(name)
1586 .and_then(|rest| rest.strip_prefix('='))
1587 .map(str::trim)
1588 })
1589 };
1590 let set = [value("EffectiveCPUs"), value("AllowedCPUs")]
1591 .into_iter()
1592 .flatten()
1593 .find(|cpus| !cpus.is_empty());
1594 let cpus = match set {
1595 Some(set) => cpu_count(set)?,
1596 None => host,
1597 };
1598 let quota = match value("CPUQuotaPerSecUSec") {
1599 None | Some("" | "infinity") => u64::MAX,
1600 Some(quota) => quota_millicores(quota)?,
1601 };
1602 Some(cpus.saturating_mul(1000).min(quota))
1603}
1604
1605fn cpu_count(set: &str) -> Option<u64> {
1607 set.split([',', ' '])
1608 .filter(|range| !range.is_empty())
1609 .try_fold(0u64, |count, range| {
1610 let (first, last) = range.split_once('-').unwrap_or((range, range));
1611 let (first, last): (u64, u64) = (first.parse().ok()?, last.parse().ok()?);
1612 Some(count + last.checked_sub(first)? + 1)
1613 })
1614}
1615
1616fn quota_millicores(quota: &str) -> Option<u64> {
1619 const UNITS: [(&str, f64); 10] = [
1621 ("y", 31_557_600_000.0),
1622 ("M", 2_629_800_000.0),
1623 ("w", 604_800_000.0),
1624 ("d", 86_400_000.0),
1625 ("h", 3_600_000.0),
1626 ("min", 60_000.0),
1627 ("s", 1000.0),
1628 ("ms", 1.0),
1629 ("us", 0.001),
1630 ("µs", 0.001),
1631 ];
1632 let millicores = quota.split_whitespace().try_fold(0.0, |total, part| {
1633 let at = part.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
1634 let (number, unit) = part.split_at(at);
1635 let per_unit = UNITS
1636 .iter()
1637 .find_map(|(name, per_unit)| (*name == unit).then_some(*per_unit))?;
1638 Some(total + number.parse::<f64>().ok()? * per_unit)
1639 })?;
1640 #[allow(
1641 clippy::cast_possible_truncation,
1642 clippy::cast_sign_loss,
1643 reason = "a quota is a few thousand millicores per CPU, checked finite and not negative first"
1644 )]
1645 (!quota.trim().is_empty() && millicores.is_finite() && millicores >= 0.0)
1646 .then(|| millicores.round() as u64)
1647}
1648
1649pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
1651 if value == "infinity" {
1652 Some(None)
1653 } else {
1654 value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
1655 }
1656}
1657
1658#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1660pub struct ByteSize(pub u64);
1661
1662const UNITS: [(char, u64); 4] = [
1663 ('T', 1 << 40),
1664 ('G', 1 << 30),
1665 ('M', 1 << 20),
1666 ('K', 1 << 10),
1667];
1668
1669impl FromStr for ByteSize {
1670 type Err = String;
1671
1672 #[allow(
1673 clippy::cast_precision_loss,
1674 clippy::cast_possible_truncation,
1675 clippy::cast_sign_loss,
1676 reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
1677 )]
1678 fn from_str(s: &str) -> Result<Self, Self::Err> {
1679 let invalid = || {
1680 format!(
1681 "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
1682 )
1683 };
1684 let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
1685 let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
1686 let number = &s[..s.len() - 1];
1687 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
1688 return Err(invalid());
1689 }
1690 let number: f64 = number.parse().map_err(|_| invalid())?;
1691 let bytes = (number * *factor as f64).round();
1692 if bytes >= u64::MAX as f64 {
1694 return Err(format!("size {s:?} is too large"));
1695 }
1696 Ok(ByteSize(bytes as u64))
1697 }
1698}
1699
1700impl fmt::Display for ByteSize {
1701 #[allow(
1702 clippy::cast_precision_loss,
1703 reason = "shown with two decimals, far coarser than f64's precision"
1704 )]
1705 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1706 let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
1707 return write!(f, "{}B", self.0);
1708 };
1709 let number = format!("{:.2}", self.0 as f64 / *factor as f64);
1710 let number = number.trim_end_matches('0').trim_end_matches('.');
1711 write!(f, "{number}{unit}")
1712 }
1713}
1714
1715impl ByteSize {
1716 #[allow(
1720 clippy::cast_precision_loss,
1721 reason = "shown with one decimal at most, far coarser than f64's precision"
1722 )]
1723 pub fn short(self) -> String {
1724 let Some(index) = UNITS.iter().position(|(_, factor)| self.0 >= *factor) else {
1725 return format!("{}B", self.0);
1726 };
1727 let (mut unit, factor) = UNITS[index];
1728 let mut value = self.0 as f64 / factor as f64;
1729 if value.round() >= 1024.0
1730 && let Some(larger) = index.checked_sub(1)
1731 {
1732 unit = UNITS[larger].0;
1733 value /= 1024.0;
1734 }
1735 let tenths = (value * 10.0).round() / 10.0;
1737 if tenths < 10.0 {
1738 let number = format!("{tenths:.1}");
1739 format!("{}{unit}", number.trim_end_matches(".0"))
1740 } else {
1741 format!("{:.0}{unit}", value.round())
1742 }
1743 }
1744}
1745
1746impl<'de> Deserialize<'de> for ByteSize {
1747 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1748 String::deserialize(deserializer)?
1749 .parse()
1750 .map_err(de::Error::custom)
1751 }
1752}
1753
1754pub fn parse_duration(s: &str) -> Result<Duration, String> {
1756 let invalid = || {
1757 format!(
1758 "invalid duration {s:?}, expected a number and s, m, h or d, such as \"168h\" or \"7d\""
1759 )
1760 };
1761 let (number, factor) = match s.chars().last() {
1762 Some('s') => (&s[..s.len() - 1], 1),
1763 Some('m') => (&s[..s.len() - 1], 60),
1764 Some('h') => (&s[..s.len() - 1], 3600),
1765 Some('d') => (&s[..s.len() - 1], 86400),
1766 _ => return Err(invalid()),
1767 };
1768 let number: u64 = number.parse().map_err(|_| invalid())?;
1769 number
1770 .checked_mul(factor)
1771 .map(Duration::from_secs)
1772 .ok_or_else(invalid)
1773}
1774
1775pub fn parse_age(text: &str) -> Result<Duration, String> {
1777 let error = || "expected a number and s, m, h, d or w, such as 12h".to_owned();
1778 let split = text.find(|c: char| !c.is_ascii_digit()).ok_or_else(error)?;
1779 let (number, unit) = text.split_at(split);
1780 let number: u64 = number.parse().map_err(|_| error())?;
1781 let unit = match unit {
1782 "s" => 1,
1783 "m" => 60,
1784 "h" => 3600,
1785 "d" => 86_400,
1786 "w" => 7 * 86_400,
1787 _ => return Err(error()),
1788 };
1789 Ok(Duration::from_secs(number.saturating_mul(unit)))
1790}
1791
1792fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
1793 parse_duration(&String::deserialize(deserializer)?).map_err(de::Error::custom)
1794}
1795
1796#[cfg(test)]
1797mod tests;