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}
129
130fn default_reseed_at() -> ByteSize {
131 ByteSize(2 << 30)
132}
133
134fn default_snapshot_max() -> ByteSize {
136 ByteSize(10 << 30)
137}
138
139fn default_mirror_max_size() -> ByteSize {
140 ByteSize(10 << 30)
141}
142
143#[derive(Debug, Clone, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct Mirror {
147 pub enabled: bool,
149 pub memory: ByteSize,
151 pub cpus: u32,
153 #[serde(deserialize_with = "duration")]
155 pub ttl: Duration,
156 #[serde(default = "default_mirror_max_size")]
158 pub max_size: ByteSize,
159}
160
161#[derive(Debug, Clone, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct CacheLimit {
165 pub max_size: ByteSize,
167}
168
169pub const PUBLISHED_RUNNER: &str = "ghcr.io/ayagmar/runnerd-runner";
172
173#[derive(Debug, Clone, Deserialize)]
175#[serde(try_from = "ProfileToml")]
176pub struct Profile {
177 pub image: Image,
179 pub cpus: u32,
181 pub memory: ByteSize,
183 pub slots: u32,
185 pub record_failures: bool,
187 pub min_memory: Option<ByteSize>,
189 pub max_memory: Option<ByteSize>,
191 pub max_slots: Option<u32>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum Image {
198 Stage(String),
200 Pulled(String),
203}
204
205impl Image {
206 pub fn reference(&self) -> String {
208 match self {
209 Image::Stage(stage) => format!("runnerd-runner:{stage}"),
210 Image::Pulled(reference) => reference.clone(),
211 }
212 }
213}
214
215#[derive(Deserialize)]
217#[serde(deny_unknown_fields)]
218struct ProfileToml {
219 image_stage: Option<String>,
220 image: Option<String>,
221 cpus: u32,
222 memory: ByteSize,
223 slots: u32,
224 #[serde(default)]
225 record_failures: bool,
226 min_memory: Option<ByteSize>,
227 max_memory: Option<ByteSize>,
228 max_slots: Option<u32>,
229}
230
231impl TryFrom<ProfileToml> for Profile {
232 type Error = String;
233
234 fn try_from(profile: ProfileToml) -> Result<Profile, String> {
235 let image = match (profile.image_stage, profile.image) {
236 (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
237 (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
238 (Some(_), Some(_)) => {
239 return Err(
240 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
241 not both"
242 .to_owned(),
243 );
244 }
245 _ => {
246 return Err(
247 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
248 .to_owned(),
249 );
250 }
251 };
252 Ok(Profile {
253 image,
254 cpus: profile.cpus,
255 memory: profile.memory,
256 slots: profile.slots,
257 record_failures: profile.record_failures,
258 min_memory: profile.min_memory,
259 max_memory: profile.max_memory,
260 max_slots: profile.max_slots,
261 })
262 }
263}
264
265#[derive(Debug, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct Repository {
269 pub name: String,
271 pub profile: String,
273 pub labels: Vec<String>,
275 #[serde(default = "default_warm")]
278 pub warm: u32,
279}
280
281fn default_warm() -> u32 {
282 1
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
287#[serde(deny_unknown_fields, default)]
288pub struct History {
289 pub database: Option<PathBuf>,
291 #[serde(deserialize_with = "duration")]
293 pub samples: Duration,
294 #[serde(deserialize_with = "duration")]
296 pub logs: Duration,
297 #[serde(deserialize_with = "duration")]
299 pub events: Duration,
300 #[serde(deserialize_with = "duration")]
302 pub jobs: Duration,
303 pub log_max: ByteSize,
305}
306
307impl Default for History {
308 fn default() -> History {
309 History {
310 database: None,
311 samples: Duration::from_hours(7 * 24),
312 logs: Duration::from_hours(30 * 24),
313 events: Duration::from_hours(30 * 24),
314 jobs: Duration::from_hours(90 * 24),
315 log_max: ByteSize(50 << 20),
316 }
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
323#[serde(deny_unknown_fields, default)]
324pub struct Records {
325 pub max_total: ByteSize,
327 pub max_count: u32,
329 #[serde(deserialize_with = "duration")]
331 pub max_age: Duration,
332 pub min_free: ByteSize,
334}
335
336impl Default for Records {
337 fn default() -> Records {
338 Records {
339 max_total: ByteSize(20 << 30),
340 max_count: 10,
341 max_age: Duration::from_hours(7 * 24),
342 min_free: ByteSize(10 << 30),
343 }
344 }
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
350#[serde(deny_unknown_fields, default)]
351pub struct Sizing {
352 pub margin: u32,
355 pub min_jobs: u32,
358 pub auto: bool,
361 #[serde(deserialize_with = "duration")]
364 pub interval: Duration,
365 #[serde(deserialize_with = "duration")]
368 pub raise_interval: Duration,
369 pub max_step: u32,
371}
372
373impl Sizing {
374 pub fn raise_after(&self) -> Duration {
377 self.raise_interval.min(self.interval)
378 }
379}
380
381impl Default for Sizing {
382 fn default() -> Sizing {
383 Sizing {
384 margin: 15,
385 min_jobs: 10,
386 auto: false,
387 interval: Duration::from_hours(24),
388 raise_interval: Duration::from_hours(1),
389 max_step: 25,
390 }
391 }
392}
393
394#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
396#[serde(deny_unknown_fields, default)]
397pub struct Web {
398 pub origins: Vec<Origin>,
401 pub open: Option<Origin>,
404}
405
406impl Web {
407 fn validate(&self) -> Result<(), ConfigError> {
409 match &self.open {
410 Some(open) if !self.origins.contains(open) => {
411 Err(ConfigError::OpenNotListed { open: open.clone() })
412 }
413 _ => Ok(()),
414 }
415 }
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
420#[serde(deny_unknown_fields, default)]
421pub struct Notify {
422 pub digest_at: DayTime,
424 pub targets: Vec<NotifyTarget>,
426}
427
428impl Default for Notify {
429 fn default() -> Notify {
430 Notify {
431 digest_at: DayTime { minutes: 9 * 60 },
432 targets: Vec::new(),
433 }
434 }
435}
436
437impl Notify {
438 fn validate(&self) -> Result<(), ConfigError> {
441 let refuse = |target: &str, why: &'static str| {
442 Err(ConfigError::Notify {
443 target: target.to_owned(),
444 why,
445 })
446 };
447 for (index, target) in self.targets.iter().enumerate() {
448 let name = target.name.as_str();
449 if name.trim().is_empty() || name.chars().any(char::is_control) {
450 return refuse(name, "name is empty");
451 }
452 if self.targets[..index].iter().any(|other| other.name == name) {
453 return refuse(name, "another target has this name");
454 }
455 let url = target.url.expose();
456 if url == SECRET_SHOWN {
457 return refuse(
458 name,
459 "url is <secret>, which runnerd fills in only for a target of the same name that had one; write the url",
460 );
461 }
462 let Some(rest) = url
463 .strip_prefix("https://")
464 .or_else(|| url.strip_prefix("http://"))
465 else {
466 return refuse(name, "url does not start with https:// or http://");
467 };
468 let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
469 if host.is_empty() || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
470 return refuse(name, "url is not a URL");
471 }
472 if target.kind == NotifyKind::Ntfy
473 && (path.is_empty() || path.contains(['/', '?', '#']))
474 {
475 return refuse(
476 name,
477 "url is not an ntfy topic's, such as https://ntfy.sh/<topic>",
478 );
479 }
480 for (header, value) in &target.headers {
481 if value.expose() == SECRET_SHOWN {
482 return refuse(
483 name,
484 "a header is <secret>, which runnerd fills in only for a target of the same name that had it; write its value",
485 );
486 }
487 let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
488 if header.is_empty() || !header.chars().all(token) {
489 return refuse(name, "a header's name is not a header name");
490 }
491 if value.expose().chars().any(char::is_control) {
492 return refuse(name, "a header's value has a control character");
493 }
494 }
495 if target.events.contains(&api::NotifyEvent::Test) {
496 return refuse(
497 name,
498 "events lists test, which only runnerctl notify test sends",
499 );
500 }
501 }
502 Ok(())
503 }
504}
505
506#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
508#[serde(deny_unknown_fields)]
509pub struct NotifyTarget {
510 pub name: String,
512 pub kind: NotifyKind,
514 pub url: Secret,
516 #[serde(default)]
518 pub headers: BTreeMap<String, Secret>,
519 #[serde(default = "default_notify_events")]
521 pub events: Vec<api::NotifyEvent>,
522}
523
524fn default_notify_events() -> Vec<api::NotifyEvent> {
525 vec![
526 api::NotifyEvent::Failed,
527 api::NotifyEvent::Lost,
528 api::NotifyEvent::Runner,
529 api::NotifyEvent::Insights,
530 ]
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
535#[serde(rename_all = "kebab-case")]
536pub enum NotifyKind {
537 Webhook,
539 Ntfy,
541}
542
543#[derive(Clone, PartialEq, Eq, Deserialize)]
545#[serde(transparent)]
546pub struct Secret(String);
547
548impl Secret {
549 pub fn expose(&self) -> &str {
551 &self.0
552 }
553}
554
555impl From<&str> for Secret {
556 fn from(value: &str) -> Secret {
557 Secret(value.to_owned())
558 }
559}
560
561impl fmt::Debug for Secret {
562 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
563 f.write_str("<secret>")
564 }
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569pub struct DayTime {
570 pub minutes: u32,
572}
573
574impl FromStr for DayTime {
575 type Err = String;
576
577 fn from_str(text: &str) -> Result<DayTime, String> {
578 let invalid = || format!("invalid time of day {text:?}, expected HH:MM such as \"09:00\"");
579 let (hour, minute) = text.split_once(':').ok_or_else(invalid)?;
580 let digits = |part: &str| part.len() == 2 && part.chars().all(|c| c.is_ascii_digit());
581 if !digits(hour) || !digits(minute) {
582 return Err(invalid());
583 }
584 let (hour, minute): (u32, u32) = (
585 hour.parse().map_err(|_| invalid())?,
586 minute.parse().map_err(|_| invalid())?,
587 );
588 if hour > 23 || minute > 59 {
589 return Err(invalid());
590 }
591 Ok(DayTime {
592 minutes: hour * 60 + minute,
593 })
594 }
595}
596
597impl fmt::Display for DayTime {
598 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
599 write!(f, "{:02}:{:02}", self.minutes / 60, self.minutes % 60)
600 }
601}
602
603impl<'de> Deserialize<'de> for DayTime {
604 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
605 String::deserialize(deserializer)?
606 .parse()
607 .map_err(de::Error::custom)
608 }
609}
610
611#[derive(Debug, Clone, PartialEq, Eq)]
615pub struct Origin(String);
616
617impl Origin {
618 pub fn as_str(&self) -> &str {
620 &self.0
621 }
622}
623
624impl FromStr for Origin {
625 type Err = String;
626
627 fn from_str(s: &str) -> Result<Origin, String> {
628 let invalid = |why: &str| {
629 format!(
630 "invalid origin {s:?}: {why}; write the scheme, host and port only, such as \
631 \"https://host.example.ts.net:8443\""
632 )
633 };
634 let (scheme, rest) = s
635 .split_once("://")
636 .ok_or_else(|| invalid("it has no scheme"))?;
637 let scheme = scheme.to_ascii_lowercase();
638 if rest.contains(['/', '?', '#']) {
639 return Err(invalid("it has a path"));
640 }
641 if rest.contains('@') {
642 return Err(invalid("it names a user"));
643 }
644 let (host, port) = match rest.strip_prefix('[') {
645 Some(v6) => {
646 let (address, after) = v6
647 .split_once(']')
648 .ok_or_else(|| invalid("its IPv6 address has no closing ]"))?;
649 let port = match after {
650 "" => None,
651 _ => Some(after.strip_prefix(':').ok_or_else(|| invalid("its port"))?),
652 };
653 (format!("[{address}]"), port)
654 }
655 None => match rest.split_once(':') {
656 Some((host, port)) => (host.to_owned(), Some(port)),
657 None => (rest.to_owned(), None),
658 },
659 };
660 let host = host.to_ascii_lowercase();
661 let not_a_host = || invalid("its host is not a name or an address");
662 let (host, loopback) =
664 if let Some(v6) = host.strip_prefix('[').and_then(|v6| v6.strip_suffix(']')) {
665 let address: Ipv6Addr = v6.parse().map_err(|_| not_a_host())?;
666 (format!("[{}]", ipv6_origin(address)), address.is_loopback())
667 } else {
668 if host.is_empty()
669 || !host
670 .chars()
671 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
672 {
673 return Err(not_a_host());
674 }
675 let name = host.strip_suffix('.').unwrap_or(&host);
677 let last = name.rsplit('.').next().unwrap_or_default();
678 let numeric = !last.is_empty()
679 && (last.bytes().all(|byte| byte.is_ascii_digit())
680 || last
681 .strip_prefix("0x")
682 .is_some_and(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit())));
683 if numeric {
684 let address: Ipv4Addr = name.parse().map_err(|_| {
685 invalid("its IPv4 address is not four decimal numbers, such as 192.0.2.1")
686 })?;
687 (address.to_string(), address.is_loopback())
688 } else {
689 let loopback = host == "localhost";
690 (host, loopback)
691 }
692 };
693 let port = port
694 .map(|port| {
695 Some(port)
696 .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit()))
697 .and_then(|port| port.parse::<u16>().ok())
698 .filter(|port| *port != 0)
699 .ok_or_else(|| invalid("its port is not a number from 1 to 65535"))
700 })
701 .transpose()?;
702 let default_port = match scheme.as_str() {
703 "https" => 443,
704 "http" if loopback => 80,
705 "http" => {
706 return Err(invalid(
707 "http is for a loopback host only, since the session cookie would cross the network in \
708 the clear; use https",
709 ));
710 }
711 _ => return Err(invalid("its scheme is not https")),
712 };
713 Ok(Origin(match port {
714 Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
715 _ => format!("{scheme}://{host}"),
716 }))
717 }
718}
719
720fn ipv6_origin(address: Ipv6Addr) -> String {
723 let pieces = address.segments();
724 let (mut zeros_at, mut zeros) = (0, 0);
725 let mut at = 0;
726 while at < pieces.len() {
727 let run = pieces[at..].iter().take_while(|piece| **piece == 0).count();
728 if run > zeros {
729 (zeros_at, zeros) = (at, run);
730 }
731 at += run.max(1);
732 }
733 let hex = |pieces: &[u16]| {
734 pieces
735 .iter()
736 .map(|piece| format!("{piece:x}"))
737 .collect::<Vec<_>>()
738 .join(":")
739 };
740 if zeros < 2 {
741 return hex(&pieces);
742 }
743 format!(
744 "{}::{}",
745 hex(&pieces[..zeros_at]),
746 hex(&pieces[zeros_at + zeros..])
747 )
748}
749
750impl fmt::Display for Origin {
751 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
752 f.write_str(&self.0)
753 }
754}
755
756impl<'de> Deserialize<'de> for Origin {
757 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
758 String::deserialize(deserializer)?
759 .parse()
760 .map_err(de::Error::custom)
761 }
762}
763
764#[derive(Debug, thiserror::Error)]
766pub enum ConfigError {
767 #[error("cannot read {}: {source}", path.display())]
769 Read {
770 path: PathBuf,
772 source: std::io::Error,
774 },
775 #[error(transparent)]
777 Parse(#[from] toml::de::Error),
778 #[error(
780 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
781 defined_profiles(.defined)
782 )]
783 UnknownProfile {
784 repository: String,
786 profile: String,
788 defined: Vec<String>,
790 },
791 #[error("repository {repository} needs at least one non-empty label")]
793 MissingLabels {
794 repository: String,
796 },
797 #[error(
799 "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
800 set allow_unsafe = true under [host] to use it anyway"
801 )]
802 UnsafeNotAllowed,
803 #[error("{field} is 0; set it above 0")]
805 ZeroLimit {
806 field: String,
808 },
809 #[error(
811 "host.cpu_overcommit is {value}; set it above 0 and at most {MAX_CPU_OVERCOMMIT}, such as 1.0"
812 )]
813 CpuOvercommit {
814 value: f64,
816 },
817 #[error(
819 "web.open is {open}, which is not one of [web] origins; add it to origins, or remove open"
820 )]
821 OpenNotListed {
822 open: Origin,
824 },
825 #[error("profiles.{profile}: {why}")]
828 SizingBounds {
829 profile: String,
831 why: &'static str,
833 },
834 #[error("notify target {target:?}: {why}")]
836 Notify {
837 target: String,
839 why: &'static str,
841 },
842 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
844 BudgetOverflow,
845 #[error(
847 "admission = \"measured\" admits runners against the slice {slice}'s MemoryMax, and it has none; install \
848 a unit file for it (`runnerd doctor` prints one)"
849 )]
850 MeasuredWithoutMemoryMax {
851 slice: String,
853 },
854 #[error(
856 "one runner of {profile} and the mirror take {needs}, the slice {slice}'s MemoryMax is {memory_max}; lower \
857 its `memory` or raise the slice's MemoryMax"
858 )]
859 ProfileOverBudget {
860 profile: String,
862 needs: ByteSize,
864 slice: String,
866 memory_max: ByteSize,
868 },
869 #[error(
871 "slots × memory over all profiles plus the mirror is {total}, \
872 the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
873 MemoryMax (`runnerd doctor` prints its unit)"
874 )]
875 OverBudget {
876 total: ByteSize,
878 slice: String,
880 memory_max: ByteSize,
882 },
883}
884
885fn defined_profiles(defined: &[String]) -> String {
886 if defined.is_empty() {
887 "it defines none".to_owned()
888 } else {
889 format!("defined: {}", defined.join(", "))
890 }
891}
892
893#[derive(Debug, Clone, PartialEq, Eq)]
895pub enum Warning {
896 UnlimitedSlice {
898 slice: String,
900 total: ByteSize,
902 },
903}
904
905impl fmt::Display for Warning {
906 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
907 match self {
908 Warning::UnlimitedSlice { slice, total } => write!(
909 f,
910 "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
911 install a unit file for it"
912 ),
913 }
914 }
915}
916
917impl Config {
918 pub fn load(path: &Path) -> Result<Config, ConfigError> {
920 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
921 path: path.to_owned(),
922 source,
923 })?;
924 Config::parse(&text)
925 }
926
927 pub fn parse(text: &str) -> Result<Config, ConfigError> {
929 Ok(toml::from_str(text)?)
930 }
931
932 pub fn check(
935 text: &str,
936 memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
937 ) -> (Option<Config>, api::ConfigCheck) {
938 let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
939 api::ConfigCheck {
940 valid: false,
941 message,
942 warnings: Vec::new(),
943 memory_caps: memory_caps.map(|caps| caps.0),
944 memory_max: max.map(|max| max.0),
945 revision: None,
946 }
947 };
948 let config = match Config::parse(text) {
949 Ok(config) => config,
950 Err(error) => return (None, invalid(error.to_string(), None, None)),
951 };
952 let caps = config.memory_caps().ok();
953 let max = match memory_max(&config.host.slice) {
954 Ok(max) => max,
955 Err(error) => return (None, invalid(error.to_string(), caps, None)),
956 };
957 match config
958 .validate(max)
959 .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
960 {
961 Ok((warnings, total)) => {
962 let message = match (config.host.admission, max) {
963 (Admission::Measured, Some(max)) if total > max => format!(
964 "valid, memory caps sum to {total}, over the {max} MemoryMax: admission = \"measured\" \
965 starts what the predicted peaks fit"
966 ),
967 _ => format!("valid, memory caps sum to {total}"),
968 };
969 let check = api::ConfigCheck {
970 valid: true,
971 message,
972 warnings: warnings.iter().map(ToString::to_string).collect(),
973 memory_caps: Some(total.0),
974 memory_max: max.map(|max| max.0),
975 revision: None,
976 };
977 (Some(config), check)
978 }
979 Err(error) => (None, invalid(error.to_string(), caps, max)),
980 }
981 }
982
983 pub fn mirror_memory(&self) -> u64 {
985 if self.caches.mirror.enabled {
986 self.caches.mirror.memory.0
987 } else {
988 0
989 }
990 }
991
992 pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
994 let mirror = self.mirror_memory();
995 self.profiles
996 .values()
997 .try_fold(mirror, |total, p| {
998 p.memory
999 .0
1000 .checked_mul(u64::from(p.slots))?
1001 .checked_add(total)
1002 })
1003 .map(ByteSize)
1004 .ok_or(ConfigError::BudgetOverflow)
1005 }
1006
1007 fn check_sizing_bounds(&self) -> Result<(), ConfigError> {
1009 for (name, p) in &self.profiles {
1010 let memory = p.memory.0;
1011 let field = if p
1012 .min_memory
1013 .zip(p.max_memory)
1014 .is_some_and(|(min, max)| min.0 > max.0)
1015 {
1016 "min_memory is above its max_memory"
1017 } else if p.min_memory.is_some_and(|min| memory < min.0) {
1018 "memory is below its min_memory"
1019 } else if p.max_memory.is_some_and(|max| memory > max.0) {
1020 "memory is above its max_memory"
1021 } else if p.max_slots.is_some_and(|max| p.slots > max) {
1022 "slots are above its max_slots"
1023 } else {
1024 continue;
1025 };
1026 return Err(ConfigError::SizingBounds {
1027 profile: name.clone(),
1028 why: field,
1029 });
1030 }
1031 Ok(())
1032 }
1033
1034 pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
1036 if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
1037 return Err(ConfigError::UnsafeNotAllowed);
1038 }
1039 let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
1040 [
1041 (p.memory.0 == 0, format!("profiles.{name}.memory")),
1042 (p.cpus == 0, format!("profiles.{name}.cpus")),
1043 (p.slots == 0, format!("profiles.{name}.slots")),
1044 (p.max_slots == Some(0), format!("profiles.{name}.max_slots")),
1045 ]
1046 });
1047 let mirror = &self.caches.mirror;
1048 let mirror_zero_limits = [
1049 (
1050 mirror.enabled && mirror.memory.0 == 0,
1051 "caches.mirror.memory".to_owned(),
1052 ),
1053 (
1054 mirror.enabled && mirror.cpus == 0,
1055 "caches.mirror.cpus".to_owned(),
1056 ),
1057 (
1058 self.host.poll_interval.is_zero(),
1059 "host.poll_interval".to_owned(),
1060 ),
1061 (self.records.max_count == 0, "records.max_count".to_owned()),
1062 (self.sizing.min_jobs == 0, "sizing.min_jobs".to_owned()),
1063 (self.sizing.interval.is_zero(), "sizing.interval".to_owned()),
1064 (
1065 self.sizing.raise_interval.is_zero(),
1066 "sizing.raise_interval".to_owned(),
1067 ),
1068 (self.sizing.max_step == 0, "sizing.max_step".to_owned()),
1069 (
1070 self.records.max_total.0 == 0,
1071 "records.max_total".to_owned(),
1072 ),
1073 ];
1074 if let Some((_, field)) = zero_limits
1075 .chain(mirror_zero_limits)
1076 .find(|(zero, _)| *zero)
1077 {
1078 return Err(ConfigError::ZeroLimit { field });
1079 }
1080 self.check_sizing_bounds()?;
1081 self.host.check_cpu_overcommit()?;
1082 self.web.validate()?;
1083 self.notify.validate()?;
1084 for repository in &self.repositories {
1085 if !self.profiles.contains_key(&repository.profile) {
1086 return Err(ConfigError::UnknownProfile {
1087 repository: repository.name.clone(),
1088 profile: repository.profile.clone(),
1089 defined: self.profiles.keys().cloned().collect(),
1090 });
1091 }
1092 if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
1093 {
1094 return Err(ConfigError::MissingLabels {
1095 repository: repository.name.clone(),
1096 });
1097 }
1098 }
1099 let total = self.memory_caps()?;
1100 if self.host.admission == Admission::Measured {
1101 let Some(memory_max) = memory_max else {
1102 return Err(ConfigError::MeasuredWithoutMemoryMax {
1103 slice: self.host.slice.clone(),
1104 });
1105 };
1106 let mirror = self.mirror_memory();
1107 if let Some((profile, needs)) = self
1108 .profiles
1109 .iter()
1110 .map(|(name, profile)| (name, profile.memory.0.saturating_add(mirror)))
1111 .find(|(_, needs)| *needs > memory_max.0)
1112 {
1113 return Err(ConfigError::ProfileOverBudget {
1114 profile: profile.clone(),
1115 needs: ByteSize(needs),
1116 slice: self.host.slice.clone(),
1117 memory_max,
1118 });
1119 }
1120 return Ok(Vec::new());
1121 }
1122 match memory_max {
1123 Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
1124 total,
1125 slice: self.host.slice.clone(),
1126 memory_max,
1127 }),
1128 Some(_) => Ok(Vec::new()),
1129 None => Ok(vec![Warning::UnlimitedSlice {
1130 slice: self.host.slice.clone(),
1131 total,
1132 }]),
1133 }
1134 }
1135}
1136
1137#[derive(Debug, thiserror::Error)]
1139pub enum ResizeError {
1140 #[error(transparent)]
1142 Parse(#[from] toml_edit::TomlError),
1143 #[error("the config has no profile {0:?} under [profiles]")]
1145 NoProfile(String),
1146}
1147
1148pub fn resize_profile(
1151 text: &str,
1152 profile: &str,
1153 resize: api::Resize,
1154) -> Result<String, ResizeError> {
1155 let mut document: toml_edit::DocumentMut = text.parse()?;
1156 let table = document
1157 .get_mut("profiles")
1158 .and_then(|profiles| profiles.get_mut(profile))
1159 .and_then(toml_edit::Item::as_table_like_mut)
1160 .ok_or_else(|| ResizeError::NoProfile(profile.to_owned()))?;
1161 let values = [
1162 (
1163 "memory",
1164 toml_edit::Value::from(ByteSize(resize.memory).to_string()),
1165 ),
1166 ("slots", toml_edit::Value::from(i64::from(resize.slots))),
1167 ];
1168 let cpus = resize
1169 .cpus
1170 .map(|cpus| ("cpus", toml_edit::Value::from(i64::from(cpus))));
1171 for (key, mut value) in values.into_iter().chain(cpus) {
1172 if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) {
1174 *value.decor_mut() = old.decor().clone();
1175 }
1176 table.insert(key, toml_edit::Item::Value(value));
1177 }
1178 Ok(document.to_string())
1179}
1180
1181pub const SECRET_SHOWN: &str = "<secret>";
1183
1184pub fn mask_secrets(text: &str) -> Option<String> {
1188 let mut document: toml_edit::DocumentMut = text.parse().ok()?;
1189 let mut masked = false;
1190 for target in notify_targets(&mut document) {
1191 for (_, value) in secrets_of(target) {
1192 replace_value(value, toml_edit::Value::from(SECRET_SHOWN));
1193 masked = true;
1194 }
1195 }
1196 Some(if masked {
1197 document.to_string()
1198 } else {
1199 text.to_owned()
1200 })
1201}
1202
1203pub fn keep_secrets(text: &str, stored: &str) -> String {
1210 if !text.contains(SECRET_SHOWN) || Config::parse(text).is_err() {
1211 return text.to_owned();
1212 }
1213 let (Ok(mut document), Ok(mut old)) = (
1214 text.parse::<toml_edit::DocumentMut>(),
1215 stored.parse::<toml_edit::DocumentMut>(),
1216 ) else {
1217 return text.to_owned();
1218 };
1219 let mut kept: BTreeMap<String, Vec<(SecretField, toml_edit::Value)>> = BTreeMap::new();
1220 for target in notify_targets(&mut old) {
1221 let Some(name) = target_name(target) else {
1222 continue;
1223 };
1224 let secrets = secrets_of(target)
1225 .into_iter()
1226 .map(|(field, value)| (field, value.clone()))
1227 .collect();
1228 kept.insert(name, secrets);
1229 }
1230 for target in notify_targets(&mut document) {
1231 let Some(secrets) = target_name(target).and_then(|name| kept.get(&name)) else {
1232 continue;
1233 };
1234 for (field, value) in secrets_of(target) {
1235 if value.as_str() != Some(SECRET_SHOWN) {
1236 continue;
1237 }
1238 if let Some((_, old)) = secrets.iter().find(|(kept, _)| *kept == field) {
1239 replace_value(value, old.clone());
1240 }
1241 }
1242 }
1243 document.to_string()
1244}
1245
1246#[derive(Debug, PartialEq, Eq)]
1248enum SecretField {
1249 Url,
1250 Header(String),
1251}
1252
1253fn notify_targets(document: &mut toml_edit::DocumentMut) -> Vec<&mut dyn toml_edit::TableLike> {
1255 match document
1256 .get_mut("notify")
1257 .and_then(|notify| notify.get_mut("targets"))
1258 {
1259 Some(toml_edit::Item::ArrayOfTables(tables)) => tables
1260 .iter_mut()
1261 .map(|table| table as &mut dyn toml_edit::TableLike)
1262 .collect(),
1263 Some(toml_edit::Item::Value(toml_edit::Value::Array(array))) => array
1264 .iter_mut()
1265 .filter_map(toml_edit::Value::as_inline_table_mut)
1266 .map(|table| table as &mut dyn toml_edit::TableLike)
1267 .collect(),
1268 _ => Vec::new(),
1269 }
1270}
1271
1272fn target_name(target: &dyn toml_edit::TableLike) -> Option<String> {
1273 target
1274 .get("name")
1275 .and_then(toml_edit::Item::as_str)
1276 .map(str::to_owned)
1277}
1278
1279fn secrets_of(target: &mut dyn toml_edit::TableLike) -> Vec<(SecretField, &mut toml_edit::Value)> {
1281 let mut found = Vec::new();
1282 for (key, item) in target.iter_mut() {
1283 match key.get() {
1284 "url" => found.extend(item.as_value_mut().map(|value| (SecretField::Url, value))),
1285 "headers" => {
1286 let Some(headers) = item.as_table_like_mut() else {
1287 continue;
1288 };
1289 for (name, item) in headers.iter_mut() {
1290 let field = SecretField::Header(name.get().to_owned());
1291 found.extend(item.as_value_mut().map(|value| (field, value)));
1292 }
1293 }
1294 _ => {}
1295 }
1296 }
1297 found
1298}
1299
1300fn replace_value(value: &mut toml_edit::Value, mut new: toml_edit::Value) {
1302 *new.decor_mut() = value.decor().clone();
1303 *value = new;
1304}
1305
1306pub fn revision(text: &str) -> String {
1309 let hash = text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
1310 (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
1311 });
1312 format!("{hash:016x}")
1313}
1314
1315pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
1317 let output = Command::new("systemctl")
1318 .args(["show", slice, "--property=MemoryMax", "--value"])
1319 .output()?;
1320 if !output.status.success() {
1321 return Err(std::io::Error::other(format!(
1322 "systemctl show {slice} failed: {}",
1323 String::from_utf8_lossy(&output.stderr).trim()
1324 )));
1325 }
1326 let value = String::from_utf8_lossy(&output.stdout);
1327 parse_memory_max(value.trim()).ok_or_else(|| {
1328 std::io::Error::other(format!(
1329 "systemctl show {slice}: unexpected MemoryMax {value:?}"
1330 ))
1331 })
1332}
1333
1334pub fn cpu_budget(millicores: u64, overcommit: f64) -> u64 {
1336 #[expect(
1337 clippy::cast_precision_loss,
1338 clippy::cast_possible_truncation,
1339 clippy::cast_sign_loss,
1340 reason = "a host's millicores are far below 2^52, and the config check holds the factor above 0 and at \
1341 most 16"
1342 )]
1343 let budget = (millicores as f64 * overcommit.max(0.0)).round() as u64;
1344 budget
1345}
1346
1347pub fn slice_cpus(slice: &str) -> std::io::Result<u64> {
1350 let output = Command::new("systemctl")
1351 .args([
1352 "show",
1353 slice,
1354 "--property=EffectiveCPUs",
1355 "--property=AllowedCPUs",
1356 "--property=CPUQuotaPerSecUSec",
1357 ])
1358 .output()?;
1359 if !output.status.success() {
1360 return Err(std::io::Error::other(format!(
1361 "systemctl show {slice} failed: {}",
1362 String::from_utf8_lossy(&output.stderr).trim()
1363 )));
1364 }
1365 let host = std::thread::available_parallelism().map_or(1, std::num::NonZero::get) as u64;
1366 parse_slice_cpus(&String::from_utf8_lossy(&output.stdout), host).ok_or_else(|| {
1367 std::io::Error::other(format!(
1368 "systemctl show {slice}: unexpected CPU properties {:?}",
1369 String::from_utf8_lossy(&output.stdout).trim()
1370 ))
1371 })
1372}
1373
1374pub fn parse_slice_cpus(properties: &str, host: u64) -> Option<u64> {
1377 let value = |name: &str| {
1378 properties.lines().find_map(|line| {
1379 line.strip_prefix(name)
1380 .and_then(|rest| rest.strip_prefix('='))
1381 .map(str::trim)
1382 })
1383 };
1384 let set = [value("EffectiveCPUs"), value("AllowedCPUs")]
1385 .into_iter()
1386 .flatten()
1387 .find(|cpus| !cpus.is_empty());
1388 let cpus = match set {
1389 Some(set) => cpu_count(set)?,
1390 None => host,
1391 };
1392 let quota = match value("CPUQuotaPerSecUSec") {
1393 None | Some("" | "infinity") => u64::MAX,
1394 Some(quota) => quota_millicores(quota)?,
1395 };
1396 Some(cpus.saturating_mul(1000).min(quota))
1397}
1398
1399fn cpu_count(set: &str) -> Option<u64> {
1401 set.split([',', ' '])
1402 .filter(|range| !range.is_empty())
1403 .try_fold(0u64, |count, range| {
1404 let (first, last) = range.split_once('-').unwrap_or((range, range));
1405 let (first, last): (u64, u64) = (first.parse().ok()?, last.parse().ok()?);
1406 Some(count + last.checked_sub(first)? + 1)
1407 })
1408}
1409
1410fn quota_millicores(quota: &str) -> Option<u64> {
1413 const UNITS: [(&str, f64); 10] = [
1415 ("y", 31_557_600_000.0),
1416 ("M", 2_629_800_000.0),
1417 ("w", 604_800_000.0),
1418 ("d", 86_400_000.0),
1419 ("h", 3_600_000.0),
1420 ("min", 60_000.0),
1421 ("s", 1000.0),
1422 ("ms", 1.0),
1423 ("us", 0.001),
1424 ("µs", 0.001),
1425 ];
1426 let millicores = quota.split_whitespace().try_fold(0.0, |total, part| {
1427 let at = part.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
1428 let (number, unit) = part.split_at(at);
1429 let per_unit = UNITS
1430 .iter()
1431 .find_map(|(name, per_unit)| (*name == unit).then_some(*per_unit))?;
1432 Some(total + number.parse::<f64>().ok()? * per_unit)
1433 })?;
1434 #[allow(
1435 clippy::cast_possible_truncation,
1436 clippy::cast_sign_loss,
1437 reason = "a quota is a few thousand millicores per CPU, checked finite and not negative first"
1438 )]
1439 (!quota.trim().is_empty() && millicores.is_finite() && millicores >= 0.0)
1440 .then(|| millicores.round() as u64)
1441}
1442
1443pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
1445 if value == "infinity" {
1446 Some(None)
1447 } else {
1448 value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
1449 }
1450}
1451
1452#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1454pub struct ByteSize(pub u64);
1455
1456const UNITS: [(char, u64); 4] = [
1457 ('T', 1 << 40),
1458 ('G', 1 << 30),
1459 ('M', 1 << 20),
1460 ('K', 1 << 10),
1461];
1462
1463impl FromStr for ByteSize {
1464 type Err = String;
1465
1466 #[allow(
1467 clippy::cast_precision_loss,
1468 clippy::cast_possible_truncation,
1469 clippy::cast_sign_loss,
1470 reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
1471 )]
1472 fn from_str(s: &str) -> Result<Self, Self::Err> {
1473 let invalid = || {
1474 format!(
1475 "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
1476 )
1477 };
1478 let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
1479 let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
1480 let number = &s[..s.len() - 1];
1481 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
1482 return Err(invalid());
1483 }
1484 let number: f64 = number.parse().map_err(|_| invalid())?;
1485 let bytes = (number * *factor as f64).round();
1486 if bytes >= u64::MAX as f64 {
1488 return Err(format!("size {s:?} is too large"));
1489 }
1490 Ok(ByteSize(bytes as u64))
1491 }
1492}
1493
1494impl fmt::Display for ByteSize {
1495 #[allow(
1496 clippy::cast_precision_loss,
1497 reason = "shown with two decimals, far coarser than f64's precision"
1498 )]
1499 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1500 let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
1501 return write!(f, "{}B", self.0);
1502 };
1503 let number = format!("{:.2}", self.0 as f64 / *factor as f64);
1504 let number = number.trim_end_matches('0').trim_end_matches('.');
1505 write!(f, "{number}{unit}")
1506 }
1507}
1508
1509impl<'de> Deserialize<'de> for ByteSize {
1510 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1511 String::deserialize(deserializer)?
1512 .parse()
1513 .map_err(de::Error::custom)
1514 }
1515}
1516
1517pub fn parse_duration(s: &str) -> Result<Duration, String> {
1519 let invalid = || {
1520 format!(
1521 "invalid duration {s:?}, expected a number and s, m, h or d, such as \"168h\" or \"7d\""
1522 )
1523 };
1524 let (number, factor) = match s.chars().last() {
1525 Some('s') => (&s[..s.len() - 1], 1),
1526 Some('m') => (&s[..s.len() - 1], 60),
1527 Some('h') => (&s[..s.len() - 1], 3600),
1528 Some('d') => (&s[..s.len() - 1], 86400),
1529 _ => return Err(invalid()),
1530 };
1531 let number: u64 = number.parse().map_err(|_| invalid())?;
1532 number
1533 .checked_mul(factor)
1534 .map(Duration::from_secs)
1535 .ok_or_else(invalid)
1536}
1537
1538pub fn parse_age(text: &str) -> Result<Duration, String> {
1540 let error = || "expected a number and s, m, h, d or w, such as 12h".to_owned();
1541 let split = text.find(|c: char| !c.is_ascii_digit()).ok_or_else(error)?;
1542 let (number, unit) = text.split_at(split);
1543 let number: u64 = number.parse().map_err(|_| error())?;
1544 let unit = match unit {
1545 "s" => 1,
1546 "m" => 60,
1547 "h" => 3600,
1548 "d" => 86_400,
1549 "w" => 7 * 86_400,
1550 _ => return Err(error()),
1551 };
1552 Ok(Duration::from_secs(number.saturating_mul(unit)))
1553}
1554
1555fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
1556 parse_duration(&String::deserialize(deserializer)?).map_err(de::Error::custom)
1557}
1558
1559#[cfg(test)]
1560mod tests;