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}
42
43#[derive(Debug, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct Host {
47 pub slice: String,
49 pub isolation: Isolation,
51 #[serde(default)]
53 pub allow_unsafe: bool,
54 #[serde(default = "default_poll_interval", deserialize_with = "duration")]
56 pub poll_interval: Duration,
57 #[serde(default)]
59 pub admission: Admission,
60 #[serde(default)]
63 pub cpu_overcommit: Option<f64>,
64}
65
66pub const MAX_CPU_OVERCOMMIT: f64 = 16.0;
68
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Admission {
73 #[default]
75 Caps,
76 Measured,
79}
80
81fn default_poll_interval() -> Duration {
82 Duration::from_secs(15)
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum Isolation {
89 Sysbox,
91 UnsafeDind,
93}
94
95#[derive(Debug, Clone, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct Caches {
99 pub mirror: Mirror,
101 pub pnpm: CacheLimit,
103 pub maven: CacheLimit,
105 #[serde(default = "default_reseed_at")]
108 pub reseed_at: ByteSize,
109 #[serde(default = "default_snapshot_max")]
112 pub snapshot_max: ByteSize,
113}
114
115fn default_reseed_at() -> ByteSize {
116 ByteSize(2 << 30)
117}
118
119fn default_snapshot_max() -> ByteSize {
121 ByteSize(10 << 30)
122}
123
124fn default_mirror_max_size() -> ByteSize {
125 ByteSize(10 << 30)
126}
127
128#[derive(Debug, Clone, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct Mirror {
132 pub enabled: bool,
134 pub memory: ByteSize,
136 pub cpus: u32,
138 #[serde(deserialize_with = "duration")]
140 pub ttl: Duration,
141 #[serde(default = "default_mirror_max_size")]
143 pub max_size: ByteSize,
144}
145
146#[derive(Debug, Clone, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct CacheLimit {
150 pub max_size: ByteSize,
152}
153
154pub const PUBLISHED_RUNNER: &str = "ghcr.io/ayagmar/runnerd-runner";
157
158#[derive(Debug, Clone, Deserialize)]
160#[serde(try_from = "ProfileToml")]
161pub struct Profile {
162 pub image: Image,
164 pub cpus: u32,
166 pub memory: ByteSize,
168 pub slots: u32,
170 pub record_failures: bool,
172 pub min_memory: Option<ByteSize>,
174 pub max_memory: Option<ByteSize>,
176 pub max_slots: Option<u32>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum Image {
183 Stage(String),
185 Pulled(String),
188}
189
190impl Image {
191 pub fn reference(&self) -> String {
193 match self {
194 Image::Stage(stage) => format!("runnerd-runner:{stage}"),
195 Image::Pulled(reference) => reference.clone(),
196 }
197 }
198}
199
200#[derive(Deserialize)]
202#[serde(deny_unknown_fields)]
203struct ProfileToml {
204 image_stage: Option<String>,
205 image: Option<String>,
206 cpus: u32,
207 memory: ByteSize,
208 slots: u32,
209 #[serde(default)]
210 record_failures: bool,
211 min_memory: Option<ByteSize>,
212 max_memory: Option<ByteSize>,
213 max_slots: Option<u32>,
214}
215
216impl TryFrom<ProfileToml> for Profile {
217 type Error = String;
218
219 fn try_from(profile: ProfileToml) -> Result<Profile, String> {
220 let image = match (profile.image_stage, profile.image) {
221 (Some(stage), None) if !stage.is_empty() => Image::Stage(stage),
222 (None, Some(reference)) if !reference.is_empty() => Image::Pulled(reference),
223 (Some(_), Some(_)) => {
224 return Err(
225 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull), \
226 not both"
227 .to_owned(),
228 );
229 }
230 _ => {
231 return Err(
232 "set image_stage (a stage built from image/Dockerfile) or image (a reference to pull)"
233 .to_owned(),
234 );
235 }
236 };
237 Ok(Profile {
238 image,
239 cpus: profile.cpus,
240 memory: profile.memory,
241 slots: profile.slots,
242 record_failures: profile.record_failures,
243 min_memory: profile.min_memory,
244 max_memory: profile.max_memory,
245 max_slots: profile.max_slots,
246 })
247 }
248}
249
250#[derive(Debug, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Repository {
254 pub name: String,
256 pub profile: String,
258 pub labels: Vec<String>,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
264#[serde(deny_unknown_fields, default)]
265pub struct History {
266 pub database: Option<PathBuf>,
268 #[serde(deserialize_with = "duration")]
270 pub samples: Duration,
271 #[serde(deserialize_with = "duration")]
273 pub logs: Duration,
274 #[serde(deserialize_with = "duration")]
276 pub events: Duration,
277 #[serde(deserialize_with = "duration")]
279 pub jobs: Duration,
280 pub log_max: ByteSize,
282}
283
284impl Default for History {
285 fn default() -> History {
286 History {
287 database: None,
288 samples: Duration::from_hours(7 * 24),
289 logs: Duration::from_hours(30 * 24),
290 events: Duration::from_hours(30 * 24),
291 jobs: Duration::from_hours(90 * 24),
292 log_max: ByteSize(50 << 20),
293 }
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
300#[serde(deny_unknown_fields, default)]
301pub struct Records {
302 pub max_total: ByteSize,
304 pub max_count: u32,
306 #[serde(deserialize_with = "duration")]
308 pub max_age: Duration,
309 pub min_free: ByteSize,
311}
312
313impl Default for Records {
314 fn default() -> Records {
315 Records {
316 max_total: ByteSize(20 << 30),
317 max_count: 10,
318 max_age: Duration::from_hours(7 * 24),
319 min_free: ByteSize(10 << 30),
320 }
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
327#[serde(deny_unknown_fields, default)]
328pub struct Sizing {
329 pub margin: u32,
332 pub min_jobs: u32,
335 pub auto: bool,
338 #[serde(deserialize_with = "duration")]
340 pub interval: Duration,
341 pub max_step: u32,
343}
344
345impl Default for Sizing {
346 fn default() -> Sizing {
347 Sizing {
348 margin: 15,
349 min_jobs: 10,
350 auto: false,
351 interval: Duration::from_hours(24),
352 max_step: 25,
353 }
354 }
355}
356
357#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
359#[serde(deny_unknown_fields, default)]
360pub struct Web {
361 pub origins: Vec<Origin>,
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct Origin(String);
371
372impl Origin {
373 pub fn as_str(&self) -> &str {
375 &self.0
376 }
377}
378
379impl FromStr for Origin {
380 type Err = String;
381
382 fn from_str(s: &str) -> Result<Origin, String> {
383 let invalid = |why: &str| {
384 format!(
385 "invalid origin {s:?}: {why}; write the scheme, host and port only, such as \
386 \"https://host.example.ts.net:8443\""
387 )
388 };
389 let (scheme, rest) = s
390 .split_once("://")
391 .ok_or_else(|| invalid("it has no scheme"))?;
392 let scheme = scheme.to_ascii_lowercase();
393 if rest.contains(['/', '?', '#']) {
394 return Err(invalid("it has a path"));
395 }
396 if rest.contains('@') {
397 return Err(invalid("it names a user"));
398 }
399 let (host, port) = match rest.strip_prefix('[') {
400 Some(v6) => {
401 let (address, after) = v6
402 .split_once(']')
403 .ok_or_else(|| invalid("its IPv6 address has no closing ]"))?;
404 let port = match after {
405 "" => None,
406 _ => Some(after.strip_prefix(':').ok_or_else(|| invalid("its port"))?),
407 };
408 (format!("[{address}]"), port)
409 }
410 None => match rest.split_once(':') {
411 Some((host, port)) => (host.to_owned(), Some(port)),
412 None => (rest.to_owned(), None),
413 },
414 };
415 let host = host.to_ascii_lowercase();
416 let not_a_host = || invalid("its host is not a name or an address");
417 let (host, loopback) =
419 if let Some(v6) = host.strip_prefix('[').and_then(|v6| v6.strip_suffix(']')) {
420 let address: Ipv6Addr = v6.parse().map_err(|_| not_a_host())?;
421 (format!("[{}]", ipv6_origin(address)), address.is_loopback())
422 } else {
423 if host.is_empty()
424 || !host
425 .chars()
426 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
427 {
428 return Err(not_a_host());
429 }
430 let name = host.strip_suffix('.').unwrap_or(&host);
432 let last = name.rsplit('.').next().unwrap_or_default();
433 let numeric = !last.is_empty()
434 && (last.bytes().all(|byte| byte.is_ascii_digit())
435 || last
436 .strip_prefix("0x")
437 .is_some_and(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit())));
438 if numeric {
439 let address: Ipv4Addr = name.parse().map_err(|_| {
440 invalid("its IPv4 address is not four decimal numbers, such as 192.0.2.1")
441 })?;
442 (address.to_string(), address.is_loopback())
443 } else {
444 let loopback = host == "localhost";
445 (host, loopback)
446 }
447 };
448 let port = port
449 .map(|port| {
450 Some(port)
451 .filter(|port| port.bytes().all(|byte| byte.is_ascii_digit()))
452 .and_then(|port| port.parse::<u16>().ok())
453 .filter(|port| *port != 0)
454 .ok_or_else(|| invalid("its port is not a number from 1 to 65535"))
455 })
456 .transpose()?;
457 let default_port = match scheme.as_str() {
458 "https" => 443,
459 "http" if loopback => 80,
460 "http" => {
461 return Err(invalid(
462 "http is for a loopback host only, since the session cookie would cross the network in \
463 the clear; use https",
464 ));
465 }
466 _ => return Err(invalid("its scheme is not https")),
467 };
468 Ok(Origin(match port {
469 Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
470 _ => format!("{scheme}://{host}"),
471 }))
472 }
473}
474
475fn ipv6_origin(address: Ipv6Addr) -> String {
478 let pieces = address.segments();
479 let (mut zeros_at, mut zeros) = (0, 0);
480 let mut at = 0;
481 while at < pieces.len() {
482 let run = pieces[at..].iter().take_while(|piece| **piece == 0).count();
483 if run > zeros {
484 (zeros_at, zeros) = (at, run);
485 }
486 at += run.max(1);
487 }
488 let hex = |pieces: &[u16]| {
489 pieces
490 .iter()
491 .map(|piece| format!("{piece:x}"))
492 .collect::<Vec<_>>()
493 .join(":")
494 };
495 if zeros < 2 {
496 return hex(&pieces);
497 }
498 format!(
499 "{}::{}",
500 hex(&pieces[..zeros_at]),
501 hex(&pieces[zeros_at + zeros..])
502 )
503}
504
505impl fmt::Display for Origin {
506 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507 f.write_str(&self.0)
508 }
509}
510
511impl<'de> Deserialize<'de> for Origin {
512 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
513 String::deserialize(deserializer)?
514 .parse()
515 .map_err(de::Error::custom)
516 }
517}
518
519#[derive(Debug, thiserror::Error)]
521pub enum ConfigError {
522 #[error("cannot read {}: {source}", path.display())]
524 Read {
525 path: PathBuf,
527 source: std::io::Error,
529 },
530 #[error(transparent)]
532 Parse(#[from] toml::de::Error),
533 #[error(
535 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]; {}",
536 defined_profiles(.defined)
537 )]
538 UnknownProfile {
539 repository: String,
541 profile: String,
543 defined: Vec<String>,
545 },
546 #[error("repository {repository} needs at least one non-empty label")]
548 MissingLabels {
549 repository: String,
551 },
552 #[error(
554 "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
555 set allow_unsafe = true under [host] to use it anyway"
556 )]
557 UnsafeNotAllowed,
558 #[error("{field} is 0; set it above 0")]
560 ZeroLimit {
561 field: String,
563 },
564 #[error(
566 "host.cpu_overcommit is {value}; set it above 0 and at most {MAX_CPU_OVERCOMMIT}, such as 1.0"
567 )]
568 CpuOvercommit {
569 value: f64,
571 },
572 #[error("profiles.{profile}: {why}")]
575 SizingBounds {
576 profile: String,
578 why: &'static str,
580 },
581 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
583 BudgetOverflow,
584 #[error(
586 "admission = \"measured\" admits runners against the slice {slice}'s MemoryMax, and it has none; install \
587 a unit file for it (`runnerd doctor` prints one)"
588 )]
589 MeasuredWithoutMemoryMax {
590 slice: String,
592 },
593 #[error(
595 "one runner of {profile} and the mirror take {needs}, the slice {slice}'s MemoryMax is {memory_max}; lower \
596 its `memory` or raise the slice's MemoryMax"
597 )]
598 ProfileOverBudget {
599 profile: String,
601 needs: ByteSize,
603 slice: String,
605 memory_max: ByteSize,
607 },
608 #[error(
610 "slots × memory over all profiles plus the mirror is {total}, \
611 the slice {slice}'s MemoryMax is {memory_max}; lower `slots` or `memory`, or raise the slice's \
612 MemoryMax (`runnerd doctor` prints its unit)"
613 )]
614 OverBudget {
615 total: ByteSize,
617 slice: String,
619 memory_max: ByteSize,
621 },
622}
623
624fn defined_profiles(defined: &[String]) -> String {
625 if defined.is_empty() {
626 "it defines none".to_owned()
627 } else {
628 format!("defined: {}", defined.join(", "))
629 }
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
634pub enum Warning {
635 UnlimitedSlice {
637 slice: String,
639 total: ByteSize,
641 },
642}
643
644impl fmt::Display for Warning {
645 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
646 match self {
647 Warning::UnlimitedSlice { slice, total } => write!(
648 f,
649 "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
650 install a unit file for it"
651 ),
652 }
653 }
654}
655
656impl Config {
657 pub fn load(path: &Path) -> Result<Config, ConfigError> {
659 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
660 path: path.to_owned(),
661 source,
662 })?;
663 Config::parse(&text)
664 }
665
666 pub fn parse(text: &str) -> Result<Config, ConfigError> {
668 Ok(toml::from_str(text)?)
669 }
670
671 pub fn check(
674 text: &str,
675 memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
676 ) -> (Option<Config>, api::ConfigCheck) {
677 let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
678 api::ConfigCheck {
679 valid: false,
680 message,
681 warnings: Vec::new(),
682 memory_caps: memory_caps.map(|caps| caps.0),
683 memory_max: max.map(|max| max.0),
684 revision: None,
685 }
686 };
687 let config = match Config::parse(text) {
688 Ok(config) => config,
689 Err(error) => return (None, invalid(error.to_string(), None, None)),
690 };
691 let caps = config.memory_caps().ok();
692 let max = match memory_max(&config.host.slice) {
693 Ok(max) => max,
694 Err(error) => return (None, invalid(error.to_string(), caps, None)),
695 };
696 match config
697 .validate(max)
698 .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
699 {
700 Ok((warnings, total)) => {
701 let message = match (config.host.admission, max) {
702 (Admission::Measured, Some(max)) if total > max => format!(
703 "valid, memory caps sum to {total}, over the {max} MemoryMax: admission = \"measured\" \
704 starts what the predicted peaks fit"
705 ),
706 _ => format!("valid, memory caps sum to {total}"),
707 };
708 let check = api::ConfigCheck {
709 valid: true,
710 message,
711 warnings: warnings.iter().map(ToString::to_string).collect(),
712 memory_caps: Some(total.0),
713 memory_max: max.map(|max| max.0),
714 revision: None,
715 };
716 (Some(config), check)
717 }
718 Err(error) => (None, invalid(error.to_string(), caps, max)),
719 }
720 }
721
722 pub fn mirror_memory(&self) -> u64 {
724 if self.caches.mirror.enabled {
725 self.caches.mirror.memory.0
726 } else {
727 0
728 }
729 }
730
731 pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
733 let mirror = self.mirror_memory();
734 self.profiles
735 .values()
736 .try_fold(mirror, |total, p| {
737 p.memory
738 .0
739 .checked_mul(u64::from(p.slots))?
740 .checked_add(total)
741 })
742 .map(ByteSize)
743 .ok_or(ConfigError::BudgetOverflow)
744 }
745
746 fn check_sizing_bounds(&self) -> Result<(), ConfigError> {
748 for (name, p) in &self.profiles {
749 let memory = p.memory.0;
750 let field = if p
751 .min_memory
752 .zip(p.max_memory)
753 .is_some_and(|(min, max)| min.0 > max.0)
754 {
755 "min_memory is above its max_memory"
756 } else if p.min_memory.is_some_and(|min| memory < min.0) {
757 "memory is below its min_memory"
758 } else if p.max_memory.is_some_and(|max| memory > max.0) {
759 "memory is above its max_memory"
760 } else if p.max_slots.is_some_and(|max| p.slots > max) {
761 "slots are above its max_slots"
762 } else {
763 continue;
764 };
765 return Err(ConfigError::SizingBounds {
766 profile: name.clone(),
767 why: field,
768 });
769 }
770 Ok(())
771 }
772
773 pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
775 if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
776 return Err(ConfigError::UnsafeNotAllowed);
777 }
778 let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
779 [
780 (p.memory.0 == 0, format!("profiles.{name}.memory")),
781 (p.cpus == 0, format!("profiles.{name}.cpus")),
782 (p.slots == 0, format!("profiles.{name}.slots")),
783 (p.max_slots == Some(0), format!("profiles.{name}.max_slots")),
784 ]
785 });
786 let mirror = &self.caches.mirror;
787 let mirror_zero_limits = [
788 (
789 mirror.enabled && mirror.memory.0 == 0,
790 "caches.mirror.memory".to_owned(),
791 ),
792 (
793 mirror.enabled && mirror.cpus == 0,
794 "caches.mirror.cpus".to_owned(),
795 ),
796 (
797 self.host.poll_interval.is_zero(),
798 "host.poll_interval".to_owned(),
799 ),
800 (self.records.max_count == 0, "records.max_count".to_owned()),
801 (self.sizing.min_jobs == 0, "sizing.min_jobs".to_owned()),
802 (self.sizing.interval.is_zero(), "sizing.interval".to_owned()),
803 (self.sizing.max_step == 0, "sizing.max_step".to_owned()),
804 (
805 self.records.max_total.0 == 0,
806 "records.max_total".to_owned(),
807 ),
808 ];
809 if let Some((_, field)) = zero_limits
810 .chain(mirror_zero_limits)
811 .find(|(zero, _)| *zero)
812 {
813 return Err(ConfigError::ZeroLimit { field });
814 }
815 self.check_sizing_bounds()?;
816 if let Some(value) = self
817 .host
818 .cpu_overcommit
819 .filter(|value| !(*value > 0.0 && *value <= MAX_CPU_OVERCOMMIT))
820 {
821 return Err(ConfigError::CpuOvercommit { value });
822 }
823 for repository in &self.repositories {
824 if !self.profiles.contains_key(&repository.profile) {
825 return Err(ConfigError::UnknownProfile {
826 repository: repository.name.clone(),
827 profile: repository.profile.clone(),
828 defined: self.profiles.keys().cloned().collect(),
829 });
830 }
831 if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
832 {
833 return Err(ConfigError::MissingLabels {
834 repository: repository.name.clone(),
835 });
836 }
837 }
838 let total = self.memory_caps()?;
839 if self.host.admission == Admission::Measured {
840 let Some(memory_max) = memory_max else {
841 return Err(ConfigError::MeasuredWithoutMemoryMax {
842 slice: self.host.slice.clone(),
843 });
844 };
845 let mirror = self.mirror_memory();
846 if let Some((profile, needs)) = self
847 .profiles
848 .iter()
849 .map(|(name, profile)| (name, profile.memory.0.saturating_add(mirror)))
850 .find(|(_, needs)| *needs > memory_max.0)
851 {
852 return Err(ConfigError::ProfileOverBudget {
853 profile: profile.clone(),
854 needs: ByteSize(needs),
855 slice: self.host.slice.clone(),
856 memory_max,
857 });
858 }
859 return Ok(Vec::new());
860 }
861 match memory_max {
862 Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
863 total,
864 slice: self.host.slice.clone(),
865 memory_max,
866 }),
867 Some(_) => Ok(Vec::new()),
868 None => Ok(vec![Warning::UnlimitedSlice {
869 slice: self.host.slice.clone(),
870 total,
871 }]),
872 }
873 }
874}
875
876#[derive(Debug, thiserror::Error)]
878pub enum ResizeError {
879 #[error(transparent)]
881 Parse(#[from] toml_edit::TomlError),
882 #[error("the config has no profile {0:?} under [profiles]")]
884 NoProfile(String),
885}
886
887pub fn resize_profile(
890 text: &str,
891 profile: &str,
892 resize: api::Resize,
893) -> Result<String, ResizeError> {
894 let mut document: toml_edit::DocumentMut = text.parse()?;
895 let table = document
896 .get_mut("profiles")
897 .and_then(|profiles| profiles.get_mut(profile))
898 .and_then(toml_edit::Item::as_table_like_mut)
899 .ok_or_else(|| ResizeError::NoProfile(profile.to_owned()))?;
900 let values = [
901 (
902 "memory",
903 toml_edit::Value::from(ByteSize(resize.memory).to_string()),
904 ),
905 ("slots", toml_edit::Value::from(i64::from(resize.slots))),
906 ];
907 for (key, mut value) in values {
908 if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) {
910 *value.decor_mut() = old.decor().clone();
911 }
912 table.insert(key, toml_edit::Item::Value(value));
913 }
914 Ok(document.to_string())
915}
916
917pub fn revision(text: &str) -> String {
920 let hash = text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
921 (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
922 });
923 format!("{hash:016x}")
924}
925
926pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
928 let output = Command::new("systemctl")
929 .args(["show", slice, "--property=MemoryMax", "--value"])
930 .output()?;
931 if !output.status.success() {
932 return Err(std::io::Error::other(format!(
933 "systemctl show {slice} failed: {}",
934 String::from_utf8_lossy(&output.stderr).trim()
935 )));
936 }
937 let value = String::from_utf8_lossy(&output.stdout);
938 parse_memory_max(value.trim()).ok_or_else(|| {
939 std::io::Error::other(format!(
940 "systemctl show {slice}: unexpected MemoryMax {value:?}"
941 ))
942 })
943}
944
945pub fn cpu_budget(millicores: u64, overcommit: f64) -> u64 {
947 #[expect(
948 clippy::cast_precision_loss,
949 clippy::cast_possible_truncation,
950 clippy::cast_sign_loss,
951 reason = "a host's millicores are far below 2^52, and the config check holds the factor above 0 and at \
952 most 16"
953 )]
954 let budget = (millicores as f64 * overcommit.max(0.0)).round() as u64;
955 budget
956}
957
958pub fn slice_cpus(slice: &str) -> std::io::Result<u64> {
961 let output = Command::new("systemctl")
962 .args([
963 "show",
964 slice,
965 "--property=EffectiveCPUs",
966 "--property=AllowedCPUs",
967 "--property=CPUQuotaPerSecUSec",
968 ])
969 .output()?;
970 if !output.status.success() {
971 return Err(std::io::Error::other(format!(
972 "systemctl show {slice} failed: {}",
973 String::from_utf8_lossy(&output.stderr).trim()
974 )));
975 }
976 let host = std::thread::available_parallelism().map_or(1, std::num::NonZero::get) as u64;
977 parse_slice_cpus(&String::from_utf8_lossy(&output.stdout), host).ok_or_else(|| {
978 std::io::Error::other(format!(
979 "systemctl show {slice}: unexpected CPU properties {:?}",
980 String::from_utf8_lossy(&output.stdout).trim()
981 ))
982 })
983}
984
985pub fn parse_slice_cpus(properties: &str, host: u64) -> Option<u64> {
988 let value = |name: &str| {
989 properties.lines().find_map(|line| {
990 line.strip_prefix(name)
991 .and_then(|rest| rest.strip_prefix('='))
992 .map(str::trim)
993 })
994 };
995 let set = [value("EffectiveCPUs"), value("AllowedCPUs")]
996 .into_iter()
997 .flatten()
998 .find(|cpus| !cpus.is_empty());
999 let cpus = match set {
1000 Some(set) => cpu_count(set)?,
1001 None => host,
1002 };
1003 let quota = match value("CPUQuotaPerSecUSec") {
1004 None | Some("" | "infinity") => u64::MAX,
1005 Some(quota) => quota_millicores(quota)?,
1006 };
1007 Some(cpus.saturating_mul(1000).min(quota))
1008}
1009
1010fn cpu_count(set: &str) -> Option<u64> {
1012 set.split([',', ' '])
1013 .filter(|range| !range.is_empty())
1014 .try_fold(0u64, |count, range| {
1015 let (first, last) = range.split_once('-').unwrap_or((range, range));
1016 let (first, last): (u64, u64) = (first.parse().ok()?, last.parse().ok()?);
1017 Some(count + last.checked_sub(first)? + 1)
1018 })
1019}
1020
1021fn quota_millicores(quota: &str) -> Option<u64> {
1024 const UNITS: [(&str, f64); 10] = [
1026 ("y", 31_557_600_000.0),
1027 ("M", 2_629_800_000.0),
1028 ("w", 604_800_000.0),
1029 ("d", 86_400_000.0),
1030 ("h", 3_600_000.0),
1031 ("min", 60_000.0),
1032 ("s", 1000.0),
1033 ("ms", 1.0),
1034 ("us", 0.001),
1035 ("µs", 0.001),
1036 ];
1037 let millicores = quota.split_whitespace().try_fold(0.0, |total, part| {
1038 let at = part.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
1039 let (number, unit) = part.split_at(at);
1040 let per_unit = UNITS
1041 .iter()
1042 .find_map(|(name, per_unit)| (*name == unit).then_some(*per_unit))?;
1043 Some(total + number.parse::<f64>().ok()? * per_unit)
1044 })?;
1045 #[allow(
1046 clippy::cast_possible_truncation,
1047 clippy::cast_sign_loss,
1048 reason = "a quota is a few thousand millicores per CPU, checked finite and not negative first"
1049 )]
1050 (!quota.trim().is_empty() && millicores.is_finite() && millicores >= 0.0)
1051 .then(|| millicores.round() as u64)
1052}
1053
1054pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
1056 if value == "infinity" {
1057 Some(None)
1058 } else {
1059 value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
1060 }
1061}
1062
1063#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1065pub struct ByteSize(pub u64);
1066
1067const UNITS: [(char, u64); 4] = [
1068 ('T', 1 << 40),
1069 ('G', 1 << 30),
1070 ('M', 1 << 20),
1071 ('K', 1 << 10),
1072];
1073
1074impl FromStr for ByteSize {
1075 type Err = String;
1076
1077 #[allow(
1078 clippy::cast_precision_loss,
1079 clippy::cast_possible_truncation,
1080 clippy::cast_sign_loss,
1081 reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
1082 )]
1083 fn from_str(s: &str) -> Result<Self, Self::Err> {
1084 let invalid = || {
1085 format!(
1086 "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
1087 )
1088 };
1089 let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
1090 let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
1091 let number = &s[..s.len() - 1];
1092 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
1093 return Err(invalid());
1094 }
1095 let number: f64 = number.parse().map_err(|_| invalid())?;
1096 let bytes = (number * *factor as f64).round();
1097 if bytes >= u64::MAX as f64 {
1099 return Err(format!("size {s:?} is too large"));
1100 }
1101 Ok(ByteSize(bytes as u64))
1102 }
1103}
1104
1105impl fmt::Display for ByteSize {
1106 #[allow(
1107 clippy::cast_precision_loss,
1108 reason = "shown with two decimals, far coarser than f64's precision"
1109 )]
1110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1111 let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
1112 return write!(f, "{}B", self.0);
1113 };
1114 let number = format!("{:.2}", self.0 as f64 / *factor as f64);
1115 let number = number.trim_end_matches('0').trim_end_matches('.');
1116 write!(f, "{number}{unit}")
1117 }
1118}
1119
1120impl<'de> Deserialize<'de> for ByteSize {
1121 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1122 String::deserialize(deserializer)?
1123 .parse()
1124 .map_err(de::Error::custom)
1125 }
1126}
1127
1128pub fn parse_duration(s: &str) -> Result<Duration, String> {
1130 let invalid = || {
1131 format!(
1132 "invalid duration {s:?}, expected a number and s, m, h or d, such as \"168h\" or \"7d\""
1133 )
1134 };
1135 let (number, factor) = match s.chars().last() {
1136 Some('s') => (&s[..s.len() - 1], 1),
1137 Some('m') => (&s[..s.len() - 1], 60),
1138 Some('h') => (&s[..s.len() - 1], 3600),
1139 Some('d') => (&s[..s.len() - 1], 86400),
1140 _ => return Err(invalid()),
1141 };
1142 let number: u64 = number.parse().map_err(|_| invalid())?;
1143 number
1144 .checked_mul(factor)
1145 .map(Duration::from_secs)
1146 .ok_or_else(invalid)
1147}
1148
1149pub fn parse_age(text: &str) -> Result<Duration, String> {
1151 let error = || "expected a number and s, m, h, d or w, such as 12h".to_owned();
1152 let split = text.find(|c: char| !c.is_ascii_digit()).ok_or_else(error)?;
1153 let (number, unit) = text.split_at(split);
1154 let number: u64 = number.parse().map_err(|_| error())?;
1155 let unit = match unit {
1156 "s" => 1,
1157 "m" => 60,
1158 "h" => 3600,
1159 "d" => 86_400,
1160 "w" => 7 * 86_400,
1161 _ => return Err(error()),
1162 };
1163 Ok(Duration::from_secs(number.saturating_mul(unit)))
1164}
1165
1166fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
1167 parse_duration(&String::deserialize(deserializer)?).map_err(de::Error::custom)
1168}
1169
1170#[cfg(test)]
1171mod tests;