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 pub priced_as: Option<api::Sku>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum Image {
257 Stage(String),
259 Pulled(String),
262}
263
264impl Image {
265 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#[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
326pub 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#[derive(Debug, Deserialize)]
341#[serde(deny_unknown_fields)]
342pub struct Repository {
343 pub name: String,
345 pub profile: String,
347 pub labels: Vec<String>,
349 #[serde(default = "default_warm")]
352 pub warm: u32,
353 #[serde(default = "default_true")]
355 pub build_cache: bool,
356 #[serde(default)]
359 pub build_cache_writers: BuildCacheWriters,
360}
361
362#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
364#[serde(rename_all = "kebab-case")]
365pub enum BuildCacheWriters {
366 #[default]
368 DefaultBranch,
369 PullRequests,
371}
372
373fn default_true() -> bool {
374 true
375}
376
377fn default_warm() -> u32 {
378 1
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
383#[serde(deny_unknown_fields, default)]
384pub struct History {
385 pub database: Option<PathBuf>,
387 #[serde(deserialize_with = "duration")]
389 pub samples: Duration,
390 #[serde(deserialize_with = "duration")]
392 pub logs: Duration,
393 #[serde(deserialize_with = "duration")]
395 pub events: Duration,
396 #[serde(deserialize_with = "duration")]
398 pub jobs: Duration,
399 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
419#[serde(deny_unknown_fields, default)]
420pub struct Records {
421 pub max_total: ByteSize,
423 pub max_count: u32,
425 #[serde(deserialize_with = "duration")]
427 pub max_age: Duration,
428 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
446#[serde(deny_unknown_fields, default)]
447pub struct Sizing {
448 pub margin: u32,
451 pub min_jobs: u32,
454 pub auto: bool,
457 #[serde(deserialize_with = "duration")]
460 pub interval: Duration,
461 #[serde(deserialize_with = "duration")]
464 pub raise_interval: Duration,
465 pub max_step: u32,
468}
469
470impl Sizing {
471 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#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
493#[serde(deny_unknown_fields, default)]
494pub struct Web {
495 pub origins: Vec<Origin>,
498 pub open: Option<Origin>,
501}
502
503impl Web {
504 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
517#[serde(deny_unknown_fields, default)]
518pub struct Notify {
519 pub digest_at: DayTime,
521 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 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
605#[serde(deny_unknown_fields)]
606pub struct NotifyTarget {
607 pub name: String,
609 pub kind: NotifyKind,
611 pub url: Secret,
614 #[serde(default)]
616 pub headers: BTreeMap<String, Secret>,
617 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
633#[serde(rename_all = "kebab-case")]
634pub enum NotifyKind {
635 Webhook,
637 Ntfy,
639 Discord,
641}
642
643#[derive(Clone, PartialEq, Eq, Deserialize)]
645#[serde(transparent)]
646pub struct Secret(String);
647
648impl Secret {
649 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
669pub struct DayTime {
670 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#[derive(Debug, Clone, PartialEq, Eq)]
715pub struct Origin(String);
716
717impl Origin {
718 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 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 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
820fn 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#[derive(Debug, thiserror::Error)]
866pub enum ConfigError {
867 #[error("cannot read {}: {source}", path.display())]
869 Read {
870 path: PathBuf,
872 source: std::io::Error,
874 },
875 #[error(transparent)]
877 Parse(#[from] toml::de::Error),
878 #[error(
880 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
881 defined_profiles(.defined)
882 )]
883 UnknownProfile {
884 repository: String,
886 profile: String,
888 defined: Vec<String>,
890 },
891 #[error("repository {repository} needs at least one non-empty label")]
893 MissingLabels {
894 repository: String,
896 },
897 #[error(
899 "repository {repository} has two entries with profile {profile:?}; give each of its pools its own profile"
900 )]
901 PoolProfileTwice {
902 repository: String,
904 profile: String,
906 },
907 #[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 repository: String,
917 first: String,
919 second: String,
921 label: String,
923 },
924 #[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 repository: String,
932 },
933 #[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 #[error("{field} is 0; set it above 0")]
941 ZeroLimit {
942 field: String,
944 },
945 #[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 value: f64,
952 },
953 #[error(
955 "web.open is {open}, which is not one of [web] origins; add it to origins, or remove open"
956 )]
957 OpenNotListed {
958 open: Origin,
960 },
961 #[error("profiles.{profile}: {why}")]
964 SizingBounds {
965 profile: String,
967 why: &'static str,
969 },
970 #[error("notify target {target:?}: {why}")]
972 Notify {
973 target: String,
975 why: &'static str,
977 },
978 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
980 BudgetOverflow,
981 #[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 slice: String,
989 },
990 #[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 profile: String,
998 needs: ByteSize,
1000 slice: String,
1002 memory_max: ByteSize,
1004 },
1005 #[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 total: ByteSize,
1014 slice: String,
1016 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#[derive(Debug, Clone, PartialEq, Eq)]
1031pub enum Warning {
1032 UnlimitedSlice {
1034 slice: String,
1036 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 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 pub fn parse(text: &str) -> Result<Config, ConfigError> {
1065 Ok(toml::from_str(text)?)
1066 }
1067
1068 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, thiserror::Error)]
1362pub enum ResizeError {
1363 #[error(transparent)]
1365 Parse(#[from] toml_edit::TomlError),
1366 #[error("the config has no profile {0:?} under [profiles]")]
1368 NoProfile(String),
1369}
1370
1371pub 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 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
1404pub const SECRET_SHOWN: &str = "<secret>";
1406
1407pub 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
1426pub 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#[derive(Debug, PartialEq, Eq)]
1471enum SecretField {
1472 Url,
1473 Header(String),
1474}
1475
1476fn 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
1502fn 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
1523fn replace_value(value: &mut toml_edit::Value, mut new: toml_edit::Value) {
1525 *new.decor_mut() = value.decor().clone();
1526 *value = new;
1527}
1528
1529pub 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
1538pub 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
1557pub 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
1570pub 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
1597pub 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
1622fn 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
1633fn quota_millicores(quota: &str) -> Option<u64> {
1636 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
1666pub 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#[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 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 #[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 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
1771pub 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
1792pub 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;