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
17#[derive(Debug, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Config {
21 pub host: Host,
23 pub caches: Caches,
25 pub profiles: BTreeMap<String, Profile>,
27 pub repositories: Vec<Repository>,
29 #[serde(default)]
31 pub history: History,
32 #[serde(default)]
34 pub records: Records,
35 #[serde(default)]
37 pub sizing: Sizing,
38 #[serde(default)]
40 pub web: Web,
41 #[serde(default)]
43 pub notify: Notify,
44}
45
46#[derive(Debug, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct Host {
50 pub slice: String,
52 pub isolation: Isolation,
54 #[serde(default)]
56 pub allow_unsafe: bool,
57 #[serde(default = "default_poll_interval", deserialize_with = "duration")]
59 pub poll_interval: Duration,
60 #[serde(default)]
62 pub admission: Admission,
63 #[serde(default)]
66 pub cpu_overcommit: Option<f64>,
67}
68
69impl Host {
70 fn check_cpu_overcommit(&self) -> Result<(), ConfigError> {
72 match self.cpu_overcommit {
73 Some(value) if !(value > 0.0 && value <= MAX_CPU_OVERCOMMIT) => {
74 Err(ConfigError::CpuOvercommit { value })
75 }
76 _ => Ok(()),
77 }
78 }
79}
80
81pub const MAX_CPU_OVERCOMMIT: f64 = 16.0;
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum Admission {
88 #[default]
90 Caps,
91 Measured,
94}
95
96fn default_poll_interval() -> Duration {
97 Duration::from_secs(15)
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum Isolation {
104 Sysbox,
106 UnsafeDind,
108}
109
110#[derive(Debug, Clone, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct Caches {
114 pub mirror: Mirror,
116 pub pnpm: CacheLimit,
118 pub maven: CacheLimit,
120 #[serde(default = "default_reseed_at")]
123 pub reseed_at: ByteSize,
124 #[serde(default = "default_snapshot_max")]
127 pub snapshot_max: ByteSize,
128 #[serde(default)]
130 pub build: BuildCache,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
136#[serde(deny_unknown_fields, default)]
137pub struct BuildCache {
138 pub enabled: bool,
140 pub max_size: ByteSize,
142 pub branches: Vec<String>,
145 pub directory: Option<PathBuf>,
147}
148
149impl BuildCache {
150 fn validate(&self) -> Result<(), ConfigError> {
151 if self.enabled && self.max_size.0 == 0 {
152 return Err(ConfigError::ZeroLimit {
153 field: "caches.build.max_size".to_owned(),
154 });
155 }
156 Ok(())
157 }
158}
159
160impl Default for BuildCache {
161 fn default() -> BuildCache {
162 BuildCache {
163 enabled: true,
164 max_size: ByteSize(10 << 30),
165 branches: Vec::new(),
166 directory: None,
167 }
168 }
169}
170
171fn default_reseed_at() -> ByteSize {
172 ByteSize(2 << 30)
173}
174
175fn default_snapshot_max() -> ByteSize {
177 ByteSize(10 << 30)
178}
179
180fn default_mirror_max_size() -> ByteSize {
181 ByteSize(10 << 30)
182}
183
184#[derive(Debug, Clone, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct Mirror {
188 pub enabled: bool,
190 pub memory: ByteSize,
192 pub cpus: u32,
194 #[serde(deserialize_with = "duration")]
196 pub ttl: Duration,
197 #[serde(default = "default_mirror_max_size")]
199 pub max_size: ByteSize,
200}
201
202#[derive(Debug, Clone, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct CacheLimit {
206 pub max_size: ByteSize,
208}
209
210pub const PUBLISHED_RUNNER: &str = "ghcr.io/ayagmar/runnerd-runner";
213
214#[derive(Debug, Clone, Deserialize)]
216#[serde(try_from = "ProfileToml")]
217pub struct Profile {
218 pub image: Image,
220 pub cpus: u32,
222 pub memory: ByteSize,
224 pub slots: u32,
226 pub record_failures: bool,
228 pub min_memory: Option<ByteSize>,
230 pub max_memory: Option<ByteSize>,
232 pub max_slots: Option<u32>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum Image {
239 Stage(String),
241 Pulled(String),
244}
245
246impl Image {
247 pub fn reference(&self) -> String {
249 match self {
250 Image::Stage(stage) => format!("runnerd-runner:{stage}"),
251 Image::Pulled(reference) => reference.clone(),
252 }
253 }
254}
255
256#[derive(Deserialize)]
258#[serde(deny_unknown_fields)]
259struct ProfileToml {
260 image_stage: Option<String>,
261 image: Option<String>,
262 cpus: u32,
263 memory: ByteSize,
264 slots: u32,
265 #[serde(default)]
266 record_failures: bool,
267 min_memory: Option<ByteSize>,
268 max_memory: Option<ByteSize>,
269 max_slots: Option<u32>,
270}
271
272impl TryFrom<ProfileToml> for Profile {
273 type Error = String;
274
275 fn try_from(profile: ProfileToml) -> Result<Profile, String> {
276 let image = match (profile.image_stage, profile.image) {
277 (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
278 (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
279 (Some(_), Some(_)) => {
280 return Err(
281 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
282 not both"
283 .to_owned(),
284 );
285 }
286 _ => {
287 return Err(
288 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
289 .to_owned(),
290 );
291 }
292 };
293 Ok(Profile {
294 image,
295 cpus: profile.cpus,
296 memory: profile.memory,
297 slots: profile.slots,
298 record_failures: profile.record_failures,
299 min_memory: profile.min_memory,
300 max_memory: profile.max_memory,
301 max_slots: profile.max_slots,
302 })
303 }
304}
305
306#[derive(Debug, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct Repository {
310 pub name: String,
312 pub profile: String,
314 pub labels: Vec<String>,
316 #[serde(default = "default_warm")]
319 pub warm: u32,
320 #[serde(default = "default_true")]
322 pub build_cache: bool,
323 #[serde(default)]
326 pub build_cache_writers: BuildCacheWriters,
327}
328
329#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
331#[serde(rename_all = "kebab-case")]
332pub enum BuildCacheWriters {
333 #[default]
335 DefaultBranch,
336 PullRequests,
338}
339
340fn default_true() -> bool {
341 true
342}
343
344fn default_warm() -> u32 {
345 1
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
350#[serde(deny_unknown_fields, default)]
351pub struct History {
352 pub database: Option<PathBuf>,
354 #[serde(deserialize_with = "duration")]
356 pub samples: Duration,
357 #[serde(deserialize_with = "duration")]
359 pub logs: Duration,
360 #[serde(deserialize_with = "duration")]
362 pub events: Duration,
363 #[serde(deserialize_with = "duration")]
365 pub jobs: Duration,
366 pub log_max: ByteSize,
368}
369
370impl Default for History {
371 fn default() -> History {
372 History {
373 database: None,
374 samples: Duration::from_hours(7 * 24),
375 logs: Duration::from_hours(30 * 24),
376 events: Duration::from_hours(30 * 24),
377 jobs: Duration::from_hours(90 * 24),
378 log_max: ByteSize(50 << 20),
379 }
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
386#[serde(deny_unknown_fields, default)]
387pub struct Records {
388 pub max_total: ByteSize,
390 pub max_count: u32,
392 #[serde(deserialize_with = "duration")]
394 pub max_age: Duration,
395 pub min_free: ByteSize,
397}
398
399impl Default for Records {
400 fn default() -> Records {
401 Records {
402 max_total: ByteSize(20 << 30),
403 max_count: 10,
404 max_age: Duration::from_hours(7 * 24),
405 min_free: ByteSize(10 << 30),
406 }
407 }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
413#[serde(deny_unknown_fields, default)]
414pub struct Sizing {
415 pub margin: u32,
418 pub min_jobs: u32,
421 pub auto: bool,
424 #[serde(deserialize_with = "duration")]
427 pub interval: Duration,
428 #[serde(deserialize_with = "duration")]
431 pub raise_interval: Duration,
432 pub max_step: u32,
434}
435
436impl Sizing {
437 pub fn raise_after(&self) -> Duration {
440 self.raise_interval.min(self.interval)
441 }
442}
443
444impl Default for Sizing {
445 fn default() -> Sizing {
446 Sizing {
447 margin: 15,
448 min_jobs: 10,
449 auto: false,
450 interval: Duration::from_hours(24),
451 raise_interval: Duration::from_hours(1),
452 max_step: 25,
453 }
454 }
455}
456
457#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
459#[serde(deny_unknown_fields, default)]
460pub struct Web {
461 pub origins: Vec<Origin>,
464 pub open: Option<Origin>,
467}
468
469impl Web {
470 fn validate(&self) -> Result<(), ConfigError> {
472 match &self.open {
473 Some(open) if !self.origins.contains(open) => {
474 Err(ConfigError::OpenNotListed { open: open.clone() })
475 }
476 _ => Ok(()),
477 }
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
483#[serde(deny_unknown_fields, default)]
484pub struct Notify {
485 pub digest_at: DayTime,
487 pub targets: Vec<NotifyTarget>,
489}
490
491impl Default for Notify {
492 fn default() -> Notify {
493 Notify {
494 digest_at: DayTime { minutes: 9 * 60 },
495 targets: Vec::new(),
496 }
497 }
498}
499
500impl Notify {
501 fn validate(&self) -> Result<(), ConfigError> {
504 let refuse = |target: &str, why: &'static str| {
505 Err(ConfigError::Notify {
506 target: target.to_owned(),
507 why,
508 })
509 };
510 for (index, target) in self.targets.iter().enumerate() {
511 let name = target.name.as_str();
512 if name.trim().is_empty() || name.chars().any(char::is_control) {
513 return refuse(name, "name is empty");
514 }
515 if self.targets[..index].iter().any(|other| other.name == name) {
516 return refuse(name, "another target has this name");
517 }
518 let url = target.url.expose();
519 if url == SECRET_SHOWN {
520 return refuse(
521 name,
522 "url is <secret>, which runnerd fills in only for a target of the same name that had one; write the url",
523 );
524 }
525 let Some(rest) = url
526 .strip_prefix("https://")
527 .or_else(|| url.strip_prefix("http://"))
528 else {
529 return refuse(name, "url does not start with https:// or http://");
530 };
531 let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
532 if host.is_empty() || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
533 return refuse(name, "url is not a URL");
534 }
535 if target.kind == NotifyKind::Ntfy
536 && (path.is_empty() || path.contains(['/', '?', '#']))
537 {
538 return refuse(
539 name,
540 "url is not an ntfy topic's, such as https://ntfy.sh/<topic>",
541 );
542 }
543 for (header, value) in &target.headers {
544 if value.expose() == SECRET_SHOWN {
545 return refuse(
546 name,
547 "a header is <secret>, which runnerd fills in only for a target of the same name that had it; write its value",
548 );
549 }
550 let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
551 if header.is_empty() || !header.chars().all(token) {
552 return refuse(name, "a header's name is not a header name");
553 }
554 if value.expose().chars().any(char::is_control) {
555 return refuse(name, "a header's value has a control character");
556 }
557 }
558 if target.events.contains(&api::NotifyEvent::Test) {
559 return refuse(
560 name,
561 "events lists test, which only runnerctl notify test sends",
562 );
563 }
564 }
565 Ok(())
566 }
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
571#[serde(deny_unknown_fields)]
572pub struct NotifyTarget {
573 pub name: String,
575 pub kind: NotifyKind,
577 pub url: Secret,
579 #[serde(default)]
581 pub headers: BTreeMap<String, Secret>,
582 #[serde(default = "default_notify_events")]
584 pub events: Vec<api::NotifyEvent>,
585}
586
587fn default_notify_events() -> Vec<api::NotifyEvent> {
588 vec![
589 api::NotifyEvent::Failed,
590 api::NotifyEvent::Lost,
591 api::NotifyEvent::Runner,
592 api::NotifyEvent::Insights,
593 ]
594}
595
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
598#[serde(rename_all = "kebab-case")]
599pub enum NotifyKind {
600 Webhook,
602 Ntfy,
604}
605
606#[derive(Clone, PartialEq, Eq, Deserialize)]
608#[serde(transparent)]
609pub struct Secret(String);
610
611impl Secret {
612 pub fn expose(&self) -> &str {
614 &self.0
615 }
616}
617
618impl From<&str> for Secret {
619 fn from(value: &str) -> Secret {
620 Secret(value.to_owned())
621 }
622}
623
624impl fmt::Debug for Secret {
625 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
626 f.write_str("<secret>")
627 }
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub struct DayTime {
633 pub minutes: u32,
635}
636
637impl FromStr for DayTime {
638 type Err = String;
639
640 fn from_str(text: &str) -> Result<DayTime, String> {
641 let invalid = || format!("invalid time of day {text:?}, expected HH:MM such as \"09:00\"");
642 let (hour, minute) = text.split_once(':').ok_or_else(invalid)?;
643 let digits = |part: &str| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit());
644 if !digits(hour) || !digits(minute) {
645 return Err(invalid());
646 }
647 let (hour, minute): (u32, u32) = (
648 hour.parse().map_err(|_| invalid())?,
649 minute.parse().map_err(|_| invalid())?,
650 );
651 if hour > 23 || minute > 59 {
652 return Err(invalid());
653 }
654 Ok(DayTime {
655 minutes: hour * 60 + minute,
656 })
657 }
658}
659
660impl fmt::Display for DayTime {
661 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
662 write!(f, "{:02}:{:02}", self.minutes / 60, self.minutes % 60)
663 }
664}
665
666impl<'de> Deserialize<'de> for DayTime {
667 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
668 String::deserialize(deserializer)?
669 .parse()
670 .map_err(de::Error::custom)
671 }
672}
673
674#[derive(Debug, Clone, PartialEq, Eq)]
678pub struct Origin(String);
679
680impl Origin {
681 pub fn as_str(&self) -> &str {
683 &self.0
684 }
685}
686
687impl FromStr for Origin {
688 type Err = String;
689
690 fn from_str(s: &str) -> Result<Origin, String> {
691 let invalid = |why: &str| {
692 format!(
693 "invalid origin {s:?}: {why}; write the scheme, host and port only, such as \
694 \"https://host.example.ts.net:8443\""
695 )
696 };
697 let (scheme, rest) = s
698 .split_once("://")
699 .ok_or_else(|| invalid("it has no scheme"))?;
700 let scheme = scheme.to_ascii_lowercase();
701 if rest.contains(['/', '?', '#']) {
702 return Err(invalid("it has a path"));
703 }
704 if rest.contains('@') {
705 return Err(invalid("it names a user"));
706 }
707 let (host, port) = match rest.strip_prefix('[') {
708 Some(v6) => {
709 let (address, after) = v6
710 .split_once(']')
711 .ok_or_else(|| invalid("its IPv6 address has no closing ]"))?;
712 let port = match after {
713 "" => None,
714 _ => Some(after.strip_prefix(':').ok_or_else(|| invalid("its port"))?),
715 };
716 (format!("[{address}]"), port)
717 }
718 None => match rest.split_once(':') {
719 Some((host, port)) => (host.to_owned(), Some(port)),
720 None => (rest.to_owned(), None),
721 },
722 };
723 let host = host.to_ascii_lowercase();
724 let not_a_host = || invalid("its host is not a name or an address");
725 let (host, loopback) =
727 if let Some(v6) = host.strip_prefix('[').and_then(|v6| v6.strip_suffix(']')) {
728 let address: Ipv6Addr = v6.parse().map_err(|_| not_a_host())?;
729 (format!("[{}]", ipv6_origin(address)), address.is_loopback())
730 } else {
731 if host.is_empty()
732 || !host
733 .chars()
734 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
735 {
736 return Err(not_a_host());
737 }
738 let name = host.strip_suffix('.').unwrap_or(&host);
740 let last = name.rsplit('.').next().unwrap_or_default();
741 let numeric = !last.is_empty()
742 && (last.bytes().all(|byte| byte.is_ascii_digit())
743 || last
744 .strip_prefix("0x")
745 .is_some_and(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit())));
746 if numeric {
747 let address: Ipv4Addr = name.parse().map_err(|_| {
748 invalid("its IPv4 address is not four decimal numbers, such as 192.0.2.1")
749 })?;
750 (address.to_string(), address.is_loopback())
751 } else {
752 let loopback = host == "localhost";
753 (host, loopback)
754 }
755 };
756 let port = port
757 .map(|port| {
758 Some(port)
759 .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit()))
760 .and_then(|port| port.parse::<u16>().ok())
761 .filter(|port| *port != 0)
762 .ok_or_else(|| invalid("its port is not a number from 1 to 65535"))
763 })
764 .transpose()?;
765 let default_port = match scheme.as_str() {
766 "https" => 443,
767 "http" if loopback => 80,
768 "http" => {
769 return Err(invalid(
770 "http is for a loopback host only, since the session cookie would cross the network in \
771 the clear; use https",
772 ));
773 }
774 _ => return Err(invalid("its scheme is not https")),
775 };
776 Ok(Origin(match port {
777 Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
778 _ => format!("{scheme}://{host}"),
779 }))
780 }
781}
782
783fn ipv6_origin(address: Ipv6Addr) -> String {
786 let pieces = address.segments();
787 let (mut zeros_at, mut zeros) = (0, 0);
788 let mut at = 0;
789 while at < pieces.len() {
790 let run = pieces[at..].iter().take_while(|piece| **piece == 0).count();
791 if run > zeros {
792 (zeros_at, zeros) = (at, run);
793 }
794 at += run.max(1);
795 }
796 let hex = |pieces: &[u16]| {
797 pieces
798 .iter()
799 .map(|piece| format!("{piece:x}"))
800 .collect::<Vec<_>>()
801 .join(":")
802 };
803 if zeros < 2 {
804 return hex(&pieces);
805 }
806 format!(
807 "{}::{}",
808 hex(&pieces[..zeros_at]),
809 hex(&pieces[zeros_at + zeros..])
810 )
811}
812
813impl fmt::Display for Origin {
814 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
815 f.write_str(&self.0)
816 }
817}
818
819impl<'de> Deserialize<'de> for Origin {
820 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
821 String::deserialize(deserializer)?
822 .parse()
823 .map_err(de::Error::custom)
824 }
825}
826
827#[derive(Debug, thiserror::Error)]
829pub enum ConfigError {
830 #[error("cannot read {}: {source}", path.display())]
832 Read {
833 path: PathBuf,
835 source: std::io::Error,
837 },
838 #[error(transparent)]
840 Parse(#[from] toml::de::Error),
841 #[error(
843 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
844 defined_profiles(.defined)
845 )]
846 UnknownProfile {
847 repository: String,
849 profile: String,
851 defined: Vec<String>,
853 },
854 #[error("repository {repository} needs at least one non-empty label")]
856 MissingLabels {
857 repository: String,
859 },
860 #[error(
862 "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
863 set allow_unsafe = true under [host] to use it anyway"
864 )]
865 UnsafeNotAllowed,
866 #[error("{field} is 0; set it above 0")]
868 ZeroLimit {
869 field: String,
871 },
872 #[error(
874 "host.cpu_overcommit is {value}; set it above 0 and at most {MAX_CPU_OVERCOMMIT}, such as 1.0"
875 )]
876 CpuOvercommit {
877 value: f64,
879 },
880 #[error(
882 "web.open is {open}, which is not one of [web] origins; add it to origins, or remove open"
883 )]
884 OpenNotListed {
885 open: Origin,
887 },
888 #[error("profiles.{profile}: {why}")]
891 SizingBounds {
892 profile: String,
894 why: &'static str,
896 },
897 #[error("notify target {target:?}: {why}")]
899 Notify {
900 target: String,
902 why: &'static str,
904 },
905 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
907 BudgetOverflow,
908 #[error(
910 "admission = \"measured\" admits runners against the slice {slice}'s MemoryMax, and it has none; install \
911 a unit file for it (`runnerd doctor` prints one)"
912 )]
913 MeasuredWithoutMemoryMax {
914 slice: String,
916 },
917 #[error(
919 "one runner of {profile} and the mirror take {needs}, the slice {slice}'s MemoryMax is {memory_max}; lower \
920 its `memory` or raise the slice's MemoryMax"
921 )]
922 ProfileOverBudget {
923 profile: String,
925 needs: ByteSize,
927 slice: String,
929 memory_max: ByteSize,
931 },
932 #[error(
934 "slots × memory over all profiles plus the mirror is {total}, \
935 the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
936 MemoryMax (`runnerd doctor` prints its unit)"
937 )]
938 OverBudget {
939 total: ByteSize,
941 slice: String,
943 memory_max: ByteSize,
945 },
946}
947
948fn defined_profiles(defined: &[String]) -> String {
949 if defined.is_empty() {
950 "it defines none".to_owned()
951 } else {
952 format!("defined: {}", defined.join(", "))
953 }
954}
955
956#[derive(Debug, Clone, PartialEq, Eq)]
958pub enum Warning {
959 UnlimitedSlice {
961 slice: String,
963 total: ByteSize,
965 },
966}
967
968impl fmt::Display for Warning {
969 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
970 match self {
971 Warning::UnlimitedSlice { slice, total } => write!(
972 f,
973 "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
974 install a unit file for it"
975 ),
976 }
977 }
978}
979
980impl Config {
981 pub fn load(path: &Path) -> Result<Config, ConfigError> {
983 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
984 path: path.to_owned(),
985 source,
986 })?;
987 Config::parse(&text)
988 }
989
990 pub fn parse(text: &str) -> Result<Config, ConfigError> {
992 Ok(toml::from_str(text)?)
993 }
994
995 pub fn check(
998 text: &str,
999 memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
1000 ) -> (Option<Config>, api::ConfigCheck) {
1001 let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
1002 api::ConfigCheck {
1003 valid: false,
1004 message,
1005 warnings: Vec::new(),
1006 memory_caps: memory_caps.map(|caps| caps.0),
1007 memory_max: max.map(|max| max.0),
1008 revision: None,
1009 }
1010 };
1011 let config = match Config::parse(text) {
1012 Ok(config) => config,
1013 Err(error) => return (None, invalid(error.to_string(), None, None)),
1014 };
1015 let caps = config.memory_caps().ok();
1016 let max = match memory_max(&config.host.slice) {
1017 Ok(max) => max,
1018 Err(error) => return (None, invalid(error.to_string(), caps, None)),
1019 };
1020 match config
1021 .validate(max)
1022 .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
1023 {
1024 Ok((warnings, total)) => {
1025 let message = match (config.host.admission, max) {
1026 (Admission::Measured, Some(max)) if total > max => format!(
1027 "valid, memory caps sum to {total}, over the {max} MemoryMax: admission = \"measured\" \
1028 starts what the predicted peaks fit"
1029 ),
1030 _ => format!("valid, memory caps sum to {total}"),
1031 };
1032 let check = api::ConfigCheck {
1033 valid: true,
1034 message,
1035 warnings: warnings.iter().map(ToString::to_string).collect(),
1036 memory_caps: Some(total.0),
1037 memory_max: max.map(|max| max.0),
1038 revision: None,
1039 };
1040 (Some(config), check)
1041 }
1042 Err(error) => (None, invalid(error.to_string(), caps, max)),
1043 }
1044 }
1045
1046 pub fn mirror_memory(&self) -> u64 {
1048 if self.caches.mirror.enabled {
1049 self.caches.mirror.memory.0
1050 } else {
1051 0
1052 }
1053 }
1054
1055 pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
1057 let mirror = self.mirror_memory();
1058 self.profiles
1059 .values()
1060 .try_fold(mirror, |total, p| {
1061 p.memory
1062 .0
1063 .checked_mul(u64::from(p.slots))?
1064 .checked_add(total)
1065 })
1066 .map(ByteSize)
1067 .ok_or(ConfigError::BudgetOverflow)
1068 }
1069
1070 fn check_sizing_bounds(&self) -> Result<(), ConfigError> {
1072 for (name, p) in &self.profiles {
1073 let memory = p.memory.0;
1074 let field = if p
1075 .min_memory
1076 .zip(p.max_memory)
1077 .is_some_and(|(min, max)| min.0 > max.0)
1078 {
1079 "min_memory is above its max_memory"
1080 } else if p.min_memory.is_some_and(|min| memory < min.0) {
1081 "memory is below its min_memory"
1082 } else if p.max_memory.is_some_and(|max| memory > max.0) {
1083 "memory is above its max_memory"
1084 } else if p.max_slots.is_some_and(|max| p.slots > max) {
1085 "slots are above its max_slots"
1086 } else {
1087 continue;
1088 };
1089 return Err(ConfigError::SizingBounds {
1090 profile: name.clone(),
1091 why: field,
1092 });
1093 }
1094 Ok(())
1095 }
1096
1097 pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
1099 if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
1100 return Err(ConfigError::UnsafeNotAllowed);
1101 }
1102 let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
1103 [
1104 (p.memory.0 == 0, format!("profiles.{name}.memory")),
1105 (p.cpus == 0, format!("profiles.{name}.cpus")),
1106 (p.slots == 0, format!("profiles.{name}.slots")),
1107 (p.max_slots == Some(0), format!("profiles.{name}.max_slots")),
1108 ]
1109 });
1110 let mirror = &self.caches.mirror;
1111 let mirror_zero_limits = [
1112 (
1113 mirror.enabled && mirror.memory.0 == 0,
1114 "caches.mirror.memory".to_owned(),
1115 ),
1116 (
1117 mirror.enabled && mirror.cpus == 0,
1118 "caches.mirror.cpus".to_owned(),
1119 ),
1120 (
1121 self.host.poll_interval.is_zero(),
1122 "host.poll_interval".to_owned(),
1123 ),
1124 (self.records.max_count == 0, "records.max_count".to_owned()),
1125 (self.sizing.min_jobs == 0, "sizing.min_jobs".to_owned()),
1126 (self.sizing.interval.is_zero(), "sizing.interval".to_owned()),
1127 (
1128 self.sizing.raise_interval.is_zero(),
1129 "sizing.raise_interval".to_owned(),
1130 ),
1131 (self.sizing.max_step == 0, "sizing.max_step".to_owned()),
1132 (
1133 self.records.max_total.0 == 0,
1134 "records.max_total".to_owned(),
1135 ),
1136 ];
1137 if let Some((_, field)) = zero_limits
1138 .chain(mirror_zero_limits)
1139 .find(|(zero, _)| *zero)
1140 {
1141 return Err(ConfigError::ZeroLimit { field });
1142 }
1143 self.check_sizing_bounds()?;
1144 self.caches.build.validate()?;
1145 self.host.check_cpu_overcommit()?;
1146 self.web.validate()?;
1147 self.notify.validate()?;
1148 for repository in &self.repositories {
1149 if !self.profiles.contains_key(&repository.profile) {
1150 return Err(ConfigError::UnknownProfile {
1151 repository: repository.name.clone(),
1152 profile: repository.profile.clone(),
1153 defined: self.profiles.keys().cloned().collect(),
1154 });
1155 }
1156 if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
1157 {
1158 return Err(ConfigError::MissingLabels {
1159 repository: repository.name.clone(),
1160 });
1161 }
1162 }
1163 let total = self.memory_caps()?;
1164 if self.host.admission == Admission::Measured {
1165 let Some(memory_max) = memory_max else {
1166 return Err(ConfigError::MeasuredWithoutMemoryMax {
1167 slice: self.host.slice.clone(),
1168 });
1169 };
1170 let mirror = self.mirror_memory();
1171 if let Some((profile, needs)) = self
1172 .profiles
1173 .iter()
1174 .map(|(name, profile)| (name, profile.memory.0.saturating_add(mirror)))
1175 .find(|(_, needs)| *needs > memory_max.0)
1176 {
1177 return Err(ConfigError::ProfileOverBudget {
1178 profile: profile.clone(),
1179 needs: ByteSize(needs),
1180 slice: self.host.slice.clone(),
1181 memory_max,
1182 });
1183 }
1184 return Ok(Vec::new());
1185 }
1186 match memory_max {
1187 Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
1188 total,
1189 slice: self.host.slice.clone(),
1190 memory_max,
1191 }),
1192 Some(_) => Ok(Vec::new()),
1193 None => Ok(vec![Warning::UnlimitedSlice {
1194 slice: self.host.slice.clone(),
1195 total,
1196 }]),
1197 }
1198 }
1199}
1200
1201#[derive(Debug, thiserror::Error)]
1203pub enum ResizeError {
1204 #[error(transparent)]
1206 Parse(#[from] toml_edit::TomlError),
1207 #[error("the config has no profile {0:?} under [profiles]")]
1209 NoProfile(String),
1210}
1211
1212pub fn resize_profile(
1215 text: &str,
1216 profile: &str,
1217 resize: api::Resize,
1218) -> Result<String, ResizeError> {
1219 let mut document: toml_edit::DocumentMut = text.parse()?;
1220 let table = document
1221 .get_mut("profiles")
1222 .and_then(|profiles| profiles.get_mut(profile))
1223 .and_then(toml_edit::Item::as_table_like_mut)
1224 .ok_or_else(|| ResizeError::NoProfile(profile.to_owned()))?;
1225 let values = [
1226 (
1227 "memory",
1228 toml_edit::Value::from(ByteSize(resize.memory).to_string()),
1229 ),
1230 ("slots", toml_edit::Value::from(i64::from(resize.slots))),
1231 ];
1232 let cpus = resize
1233 .cpus
1234 .map(|cpus| ("cpus", toml_edit::Value::from(i64::from(cpus))));
1235 for (key, mut value) in values.into_iter().chain(cpus) {
1236 if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) {
1238 *value.decor_mut() = old.decor().clone();
1239 }
1240 table.insert(key, toml_edit::Item::Value(value));
1241 }
1242 Ok(document.to_string())
1243}
1244
1245pub const SECRET_SHOWN: &str = "<secret>";
1247
1248pub fn mask_secrets(text: &str) -> Option<String> {
1252 let mut document: toml_edit::DocumentMut = text.parse().ok()?;
1253 let mut masked = false;
1254 for target in notify_targets(&mut document) {
1255 for (_, value) in secrets_of(target) {
1256 replace_value(value, toml_edit::Value::from(SECRET_SHOWN));
1257 masked = true;
1258 }
1259 }
1260 Some(if masked {
1261 document.to_string()
1262 } else {
1263 text.to_owned()
1264 })
1265}
1266
1267pub fn keep_secrets(text: &str, stored: &str) -> String {
1274 if !text.contains(SECRET_SHOWN) || Config::parse(text).is_err() {
1275 return text.to_owned();
1276 }
1277 let (Ok(mut document), Ok(mut old)) = (
1278 text.parse::<toml_edit::DocumentMut>(),
1279 stored.parse::<toml_edit::DocumentMut>(),
1280 ) else {
1281 return text.to_owned();
1282 };
1283 let mut kept: BTreeMap<String, Vec<(SecretField, toml_edit::Value)>> = BTreeMap::new();
1284 for target in notify_targets(&mut old) {
1285 let Some(name) = target_name(target) else {
1286 continue;
1287 };
1288 let secrets = secrets_of(target)
1289 .into_iter()
1290 .map(|(field, value)| (field, value.clone()))
1291 .collect();
1292 kept.insert(name, secrets);
1293 }
1294 for target in notify_targets(&mut document) {
1295 let Some(secrets) = target_name(target).and_then(|name| kept.get(&name)) else {
1296 continue;
1297 };
1298 for (field, value) in secrets_of(target) {
1299 if value.as_str() != Some(SECRET_SHOWN) {
1300 continue;
1301 }
1302 if let Some((_, old)) = secrets.iter().find(|(kept, _)| *kept == field) {
1303 replace_value(value, old.clone());
1304 }
1305 }
1306 }
1307 document.to_string()
1308}
1309
1310#[derive(Debug, PartialEq, Eq)]
1312enum SecretField {
1313 Url,
1314 Header(String),
1315}
1316
1317fn notify_targets(document: &mut toml_edit::DocumentMut) -> Vec<&mut dyn toml_edit::TableLike> {
1319 match document
1320 .get_mut("notify")
1321 .and_then(|notify| notify.get_mut("targets"))
1322 {
1323 Some(toml_edit::Item::ArrayOfTables(tables)) => tables
1324 .iter_mut()
1325 .map(|table| table as &mut dyn toml_edit::TableLike)
1326 .collect(),
1327 Some(toml_edit::Item::Value(toml_edit::Value::Array(array))) => array
1328 .iter_mut()
1329 .filter_map(toml_edit::Value::as_inline_table_mut)
1330 .map(|table| table as &mut dyn toml_edit::TableLike)
1331 .collect(),
1332 _ => Vec::new(),
1333 }
1334}
1335
1336fn target_name(target: &dyn toml_edit::TableLike) -> Option<String> {
1337 target
1338 .get("name")
1339 .and_then(toml_edit::Item::as_str)
1340 .map(str::to_owned)
1341}
1342
1343fn secrets_of(target: &mut dyn toml_edit::TableLike) -> Vec<(SecretField, &mut toml_edit::Value)> {
1345 let mut found = Vec::new();
1346 for (key, item) in target.iter_mut() {
1347 match key.get() {
1348 "url" => found.extend(item.as_value_mut().map(|value| (SecretField::Url, value))),
1349 "headers" => {
1350 let Some(headers) = item.as_table_like_mut() else {
1351 continue;
1352 };
1353 for (name, item) in headers.iter_mut() {
1354 let field = SecretField::Header(name.get().to_owned());
1355 found.extend(item.as_value_mut().map(|value| (field, value)));
1356 }
1357 }
1358 _ => {}
1359 }
1360 }
1361 found
1362}
1363
1364fn replace_value(value: &mut toml_edit::Value, mut new: toml_edit::Value) {
1366 *new.decor_mut() = value.decor().clone();
1367 *value = new;
1368}
1369
1370pub fn revision(text: &str) -> String {
1373 let hash = text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
1374 (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
1375 });
1376 format!("{hash:016x}")
1377}
1378
1379pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
1381 let output = Command::new("systemctl")
1382 .args(["show", slice, "--property=MemoryMax", "--value"])
1383 .output()?;
1384 if !output.status.success() {
1385 return Err(std::io::Error::other(format!(
1386 "systemctl show {slice} failed: {}",
1387 String::from_utf8_lossy(&output.stderr).trim()
1388 )));
1389 }
1390 let value = String::from_utf8_lossy(&output.stdout);
1391 parse_memory_max(value.trim()).ok_or_else(|| {
1392 std::io::Error::other(format!(
1393 "systemctl show {slice}: unexpected MemoryMax {value:?}"
1394 ))
1395 })
1396}
1397
1398pub fn cpu_budget(millicores: u64, overcommit: f64) -> u64 {
1400 #[expect(
1401 clippy::cast_precision_loss,
1402 clippy::cast_possible_truncation,
1403 clippy::cast_sign_loss,
1404 reason = "a host's millicores are far below 2^52, and the config check holds the factor above 0 and at \
1405 most 16"
1406 )]
1407 let budget = (millicores as f64 * overcommit.max(0.0)).round() as u64;
1408 budget
1409}
1410
1411pub fn slice_cpus(slice: &str) -> std::io::Result<u64> {
1414 let output = Command::new("systemctl")
1415 .args([
1416 "show",
1417 slice,
1418 "--property=EffectiveCPUs",
1419 "--property=AllowedCPUs",
1420 "--property=CPUQuotaPerSecUSec",
1421 ])
1422 .output()?;
1423 if !output.status.success() {
1424 return Err(std::io::Error::other(format!(
1425 "systemctl show {slice} failed: {}",
1426 String::from_utf8_lossy(&output.stderr).trim()
1427 )));
1428 }
1429 let host = std::thread::available_parallelism().map_or(1, std::num::NonZero::get) as u64;
1430 parse_slice_cpus(&String::from_utf8_lossy(&output.stdout), host).ok_or_else(|| {
1431 std::io::Error::other(format!(
1432 "systemctl show {slice}: unexpected CPU properties {:?}",
1433 String::from_utf8_lossy(&output.stdout).trim()
1434 ))
1435 })
1436}
1437
1438pub fn parse_slice_cpus(properties: &str, host: u64) -> Option<u64> {
1441 let value = |name: &str| {
1442 properties.lines().find_map(|line| {
1443 line.strip_prefix(name)
1444 .and_then(|rest| rest.strip_prefix('='))
1445 .map(str::trim)
1446 })
1447 };
1448 let set = [value("EffectiveCPUs"), value("AllowedCPUs")]
1449 .into_iter()
1450 .flatten()
1451 .find(|cpus| !cpus.is_empty());
1452 let cpus = match set {
1453 Some(set) => cpu_count(set)?,
1454 None => host,
1455 };
1456 let quota = match value("CPUQuotaPerSecUSec") {
1457 None | Some("" | "infinity") => u64::MAX,
1458 Some(quota) => quota_millicores(quota)?,
1459 };
1460 Some(cpus.saturating_mul(1000).min(quota))
1461}
1462
1463fn cpu_count(set: &str) -> Option<u64> {
1465 set.split([',', ' '])
1466 .filter(|range| !range.is_empty())
1467 .try_fold(0u64, |count, range| {
1468 let (first, last) = range.split_once('-').unwrap_or((range, range));
1469 let (first, last): (u64, u64) = (first.parse().ok()?, last.parse().ok()?);
1470 Some(count + last.checked_sub(first)? + 1)
1471 })
1472}
1473
1474fn quota_millicores(quota: &str) -> Option<u64> {
1477 const UNITS: [(&str, f64); 10] = [
1479 ("y", 31_557_600_000.0),
1480 ("M", 2_629_800_000.0),
1481 ("w", 604_800_000.0),
1482 ("d", 86_400_000.0),
1483 ("h", 3_600_000.0),
1484 ("min", 60_000.0),
1485 ("s", 1000.0),
1486 ("ms", 1.0),
1487 ("us", 0.001),
1488 ("µs", 0.001),
1489 ];
1490 let millicores = quota.split_whitespace().try_fold(0.0, |total, part| {
1491 let at = part.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
1492 let (number, unit) = part.split_at(at);
1493 let per_unit = UNITS
1494 .iter()
1495 .find_map(|(name, per_unit)| (*name == unit).then_some(*per_unit))?;
1496 Some(total + number.parse::<f64>().ok()? * per_unit)
1497 })?;
1498 #[allow(
1499 clippy::cast_possible_truncation,
1500 clippy::cast_sign_loss,
1501 reason = "a quota is a few thousand millicores per CPU, checked finite and not negative first"
1502 )]
1503 (!quota.trim().is_empty() && millicores.is_finite() && millicores >= 0.0)
1504 .then(|| millicores.round() as u64)
1505}
1506
1507pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
1509 if value == "infinity" {
1510 Some(None)
1511 } else {
1512 value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
1513 }
1514}
1515
1516#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1518pub struct ByteSize(pub u64);
1519
1520const UNITS: [(char, u64); 4] = [
1521 ('T', 1 << 40),
1522 ('G', 1 << 30),
1523 ('M', 1 << 20),
1524 ('K', 1 << 10),
1525];
1526
1527impl FromStr for ByteSize {
1528 type Err = String;
1529
1530 #[allow(
1531 clippy::cast_precision_loss,
1532 clippy::cast_possible_truncation,
1533 clippy::cast_sign_loss,
1534 reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
1535 )]
1536 fn from_str(s: &str) -> Result<Self, Self::Err> {
1537 let invalid = || {
1538 format!(
1539 "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
1540 )
1541 };
1542 let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
1543 let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
1544 let number = &s[..s.len() - 1];
1545 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
1546 return Err(invalid());
1547 }
1548 let number: f64 = number.parse().map_err(|_| invalid())?;
1549 let bytes = (number * *factor as f64).round();
1550 if bytes >= u64::MAX as f64 {
1552 return Err(format!("size {s:?} is too large"));
1553 }
1554 Ok(ByteSize(bytes as u64))
1555 }
1556}
1557
1558impl fmt::Display for ByteSize {
1559 #[allow(
1560 clippy::cast_precision_loss,
1561 reason = "shown with two decimals, far coarser than f64's precision"
1562 )]
1563 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1564 let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
1565 return write!(f, "{}B", self.0);
1566 };
1567 let number = format!("{:.2}", self.0 as f64 / *factor as f64);
1568 let number = number.trim_end_matches('0').trim_end_matches('.');
1569 write!(f, "{number}{unit}")
1570 }
1571}
1572
1573impl<'de> Deserialize<'de> for ByteSize {
1574 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1575 String::deserialize(deserializer)?
1576 .parse()
1577 .map_err(de::Error::custom)
1578 }
1579}
1580
1581pub fn parse_duration(s: &str) -> Result<Duration, String> {
1583 let invalid = || {
1584 format!(
1585 "invalid duration {s:?}, expected a number and s, m, h or d, such as \"168h\" or \"7d\""
1586 )
1587 };
1588 let (number, factor) = match s.chars().last() {
1589 Some('s') => (&s[..s.len() - 1], 1),
1590 Some('m') => (&s[..s.len() - 1], 60),
1591 Some('h') => (&s[..s.len() - 1], 3600),
1592 Some('d') => (&s[..s.len() - 1], 86400),
1593 _ => return Err(invalid()),
1594 };
1595 let number: u64 = number.parse().map_err(|_| invalid())?;
1596 number
1597 .checked_mul(factor)
1598 .map(Duration::from_secs)
1599 .ok_or_else(invalid)
1600}
1601
1602pub fn parse_age(text: &str) -> Result<Duration, String> {
1604 let error = || "expected a number and s, m, h, d or w, such as 12h".to_owned();
1605 let split = text.find(|c: char| !c.is_ascii_digit()).ok_or_else(error)?;
1606 let (number, unit) = text.split_at(split);
1607 let number: u64 = number.parse().map_err(|_| error())?;
1608 let unit = match unit {
1609 "s" => 1,
1610 "m" => 60,
1611 "h" => 3600,
1612 "d" => 86_400,
1613 "w" => 7 * 86_400,
1614 _ => return Err(error()),
1615 };
1616 Ok(Duration::from_secs(number.saturating_mul(unit)))
1617}
1618
1619fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
1620 parse_duration(&String::deserialize(deserializer)?).map_err(de::Error::custom)
1621}
1622
1623#[cfg(test)]
1624mod tests;