1use std::collections::BTreeMap;
6use std::fmt;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::str::FromStr;
10use std::time::Duration;
11
12use serde::{Deserialize, Deserializer, de};
13
14use crate::api;
15
16#[derive(Debug, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct Config {
20 pub host: Host,
22 pub caches: Caches,
24 pub profiles: BTreeMap<String, Profile>,
26 pub repositories: Vec<Repository>,
28 #[serde(default)]
30 pub history: History,
31}
32
33#[derive(Debug, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Host {
37 pub slice: String,
39 pub isolation: Isolation,
41 #[serde(default)]
43 pub allow_unsafe: bool,
44 #[serde(default = "default_poll_interval", deserialize_with = "duration")]
46 pub poll_interval: Duration,
47}
48
49fn default_poll_interval() -> Duration {
50 Duration::from_secs(15)
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum Isolation {
57 Sysbox,
59 UnsafeDind,
61}
62
63#[derive(Debug, Clone, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct Caches {
67 pub mirror: Mirror,
69 pub pnpm: CacheLimit,
71 pub maven: CacheLimit,
73 #[serde(default = "default_reseed_at")]
76 pub reseed_at: ByteSize,
77}
78
79fn default_reseed_at() -> ByteSize {
80 ByteSize(2 << 30)
81}
82
83#[derive(Debug, Clone, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Mirror {
87 pub enabled: bool,
89 pub memory: ByteSize,
91 pub cpus: u32,
93 #[serde(deserialize_with = "duration")]
95 pub ttl: Duration,
96}
97
98#[derive(Debug, Clone, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct CacheLimit {
102 pub max_size: ByteSize,
104}
105
106#[derive(Debug, Clone, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct Profile {
110 pub image_stage: String,
112 pub cpus: u32,
114 pub memory: ByteSize,
116 pub slots: u32,
118}
119
120#[derive(Debug, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct Repository {
124 pub name: String,
126 pub profile: String,
128 pub labels: Vec<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
134#[serde(deny_unknown_fields, default)]
135pub struct History {
136 pub database: Option<PathBuf>,
138 #[serde(deserialize_with = "duration")]
140 pub samples: Duration,
141 #[serde(deserialize_with = "duration")]
143 pub logs: Duration,
144 #[serde(deserialize_with = "duration")]
146 pub events: Duration,
147 pub log_max: ByteSize,
149}
150
151impl Default for History {
152 fn default() -> History {
153 History {
154 database: None,
155 samples: Duration::from_hours(7 * 24),
156 logs: Duration::from_hours(30 * 24),
157 events: Duration::from_hours(30 * 24),
158 log_max: ByteSize(50 << 20),
159 }
160 }
161}
162
163#[derive(Debug, thiserror::Error)]
165pub enum ConfigError {
166 #[error("cannot read {}: {source}", path.display())]
168 Read {
169 path: PathBuf,
171 source: std::io::Error,
173 },
174 #[error(transparent)]
176 Parse(#[from] toml::de::Error),
177 #[error(
179 "repository {repository} uses profile {profile:?}, which is not defined under [profiles]"
180 )]
181 UnknownProfile {
182 repository: String,
184 profile: String,
186 },
187 #[error("repository {repository} needs at least one non-empty label")]
189 MissingLabels {
190 repository: String,
192 },
193 #[error(
195 "isolation \"unsafe-dind\" runs runner containers privileged, so a job can take over the host; \
196 set allow_unsafe = true under [host] to use it anyway"
197 )]
198 UnsafeNotAllowed,
199 #[error("{field} is 0; set it above 0")]
201 ZeroLimit {
202 field: String,
204 },
205 #[error("slots × memory over all profiles plus the mirror is too large to add up")]
207 BudgetOverflow,
208 #[error(
210 "slots × memory over all profiles plus the mirror is {total}, \
211 the slice {slice}'s MemoryMax is {memory_max}"
212 )]
213 OverBudget {
214 total: ByteSize,
216 slice: String,
218 memory_max: ByteSize,
220 },
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum Warning {
226 UnlimitedSlice {
228 slice: String,
230 total: ByteSize,
232 },
233}
234
235impl fmt::Display for Warning {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 match self {
238 Warning::UnlimitedSlice { slice, total } => write!(
239 f,
240 "the slice {slice} has no MemoryMax, so nothing holds runners to their {total} of caps; \
241 install a unit file for it"
242 ),
243 }
244 }
245}
246
247impl Config {
248 pub fn load(path: &Path) -> Result<Config, ConfigError> {
250 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
251 path: path.to_owned(),
252 source,
253 })?;
254 Config::parse(&text)
255 }
256
257 pub fn parse(text: &str) -> Result<Config, ConfigError> {
259 Ok(toml::from_str(text)?)
260 }
261
262 pub fn check(
265 text: &str,
266 memory_max: impl FnOnce(&str) -> std::io::Result<Option<ByteSize>>,
267 ) -> (Option<Config>, api::ConfigCheck) {
268 let invalid = |message: String, memory_caps: Option<ByteSize>, max: Option<ByteSize>| {
269 api::ConfigCheck {
270 valid: false,
271 message,
272 warnings: Vec::new(),
273 memory_caps: memory_caps.map(|caps| caps.0),
274 memory_max: max.map(|max| max.0),
275 }
276 };
277 let config = match Config::parse(text) {
278 Ok(config) => config,
279 Err(error) => return (None, invalid(error.to_string(), None, None)),
280 };
281 let caps = config.memory_caps().ok();
282 let max = match memory_max(&config.host.slice) {
283 Ok(max) => max,
284 Err(error) => return (None, invalid(error.to_string(), caps, None)),
285 };
286 match config
287 .validate(max)
288 .and_then(|warnings| Ok((warnings, config.memory_caps()?)))
289 {
290 Ok((warnings, total)) => {
291 let check = api::ConfigCheck {
292 valid: true,
293 message: format!("valid, memory caps sum to {total}"),
294 warnings: warnings.iter().map(ToString::to_string).collect(),
295 memory_caps: Some(total.0),
296 memory_max: max.map(|max| max.0),
297 };
298 (Some(config), check)
299 }
300 Err(error) => (None, invalid(error.to_string(), caps, max)),
301 }
302 }
303
304 pub fn memory_caps(&self) -> Result<ByteSize, ConfigError> {
306 let mirror = if self.caches.mirror.enabled {
307 self.caches.mirror.memory.0
308 } else {
309 0
310 };
311 self.profiles
312 .values()
313 .try_fold(mirror, |total, p| {
314 p.memory
315 .0
316 .checked_mul(u64::from(p.slots))?
317 .checked_add(total)
318 })
319 .map(ByteSize)
320 .ok_or(ConfigError::BudgetOverflow)
321 }
322
323 pub fn validate(&self, memory_max: Option<ByteSize>) -> Result<Vec<Warning>, ConfigError> {
325 if self.host.isolation == Isolation::UnsafeDind && !self.host.allow_unsafe {
326 return Err(ConfigError::UnsafeNotAllowed);
327 }
328 let zero_limits = self.profiles.iter().flat_map(|(name, p)| {
329 [
330 (p.memory.0 == 0, format!("profiles.{name}.memory")),
331 (p.cpus == 0, format!("profiles.{name}.cpus")),
332 (p.slots == 0, format!("profiles.{name}.slots")),
333 ]
334 });
335 let mirror = &self.caches.mirror;
336 let mirror_zero_limits = [
337 (
338 mirror.enabled && mirror.memory.0 == 0,
339 "caches.mirror.memory".to_owned(),
340 ),
341 (
342 mirror.enabled && mirror.cpus == 0,
343 "caches.mirror.cpus".to_owned(),
344 ),
345 (
346 self.host.poll_interval.is_zero(),
347 "host.poll_interval".to_owned(),
348 ),
349 ];
350 if let Some((_, field)) = zero_limits
351 .chain(mirror_zero_limits)
352 .find(|(zero, _)| *zero)
353 {
354 return Err(ConfigError::ZeroLimit { field });
355 }
356 for repository in &self.repositories {
357 if !self.profiles.contains_key(&repository.profile) {
358 return Err(ConfigError::UnknownProfile {
359 repository: repository.name.clone(),
360 profile: repository.profile.clone(),
361 });
362 }
363 if repository.labels.is_empty() || repository.labels.iter().any(|l| l.trim().is_empty())
364 {
365 return Err(ConfigError::MissingLabels {
366 repository: repository.name.clone(),
367 });
368 }
369 }
370 let total = self.memory_caps()?;
371 match memory_max {
372 Some(memory_max) if total > memory_max => Err(ConfigError::OverBudget {
373 total,
374 slice: self.host.slice.clone(),
375 memory_max,
376 }),
377 Some(_) => Ok(Vec::new()),
378 None => Ok(vec![Warning::UnlimitedSlice {
379 slice: self.host.slice.clone(),
380 total,
381 }]),
382 }
383 }
384}
385
386pub fn slice_memory_max(slice: &str) -> std::io::Result<Option<ByteSize>> {
388 let output = Command::new("systemctl")
389 .args(["show", slice, "--property=MemoryMax", "--value"])
390 .output()?;
391 if !output.status.success() {
392 return Err(std::io::Error::other(format!(
393 "systemctl show {slice} failed: {}",
394 String::from_utf8_lossy(&output.stderr).trim()
395 )));
396 }
397 let value = String::from_utf8_lossy(&output.stdout);
398 parse_memory_max(value.trim()).ok_or_else(|| {
399 std::io::Error::other(format!(
400 "systemctl show {slice}: unexpected MemoryMax {value:?}"
401 ))
402 })
403}
404
405pub fn parse_memory_max(value: &str) -> Option<Option<ByteSize>> {
407 if value == "infinity" {
408 Some(None)
409 } else {
410 value.parse().ok().map(|bytes| Some(ByteSize(bytes)))
411 }
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
416pub struct ByteSize(pub u64);
417
418const UNITS: [(char, u64); 4] = [
419 ('T', 1 << 40),
420 ('G', 1 << 30),
421 ('M', 1 << 20),
422 ('K', 1 << 10),
423];
424
425impl FromStr for ByteSize {
426 type Err = String;
427
428 #[allow(
429 clippy::cast_precision_loss,
430 clippy::cast_possible_truncation,
431 clippy::cast_sign_loss,
432 reason = "sizes in a config are far below 2^52 bytes, and the result is checked against u64's range first"
433 )]
434 fn from_str(s: &str) -> Result<Self, Self::Err> {
435 let invalid = || {
436 format!(
437 "invalid size {s:?}, expected a number and a unit (K, M, G or T), such as \"9G\""
438 )
439 };
440 let unit = s.chars().last().ok_or_else(invalid)?.to_ascii_uppercase();
441 let (_, factor) = UNITS.iter().find(|(u, _)| *u == unit).ok_or_else(invalid)?;
442 let number = &s[..s.len() - 1];
443 if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit() || c == '.') {
444 return Err(invalid());
445 }
446 let number: f64 = number.parse().map_err(|_| invalid())?;
447 let bytes = (number * *factor as f64).round();
448 if bytes >= u64::MAX as f64 {
450 return Err(format!("size {s:?} is too large"));
451 }
452 Ok(ByteSize(bytes as u64))
453 }
454}
455
456impl fmt::Display for ByteSize {
457 #[allow(
458 clippy::cast_precision_loss,
459 reason = "shown with two decimals, far coarser than f64's precision"
460 )]
461 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462 let Some((unit, factor)) = UNITS.iter().find(|(_, factor)| self.0 >= *factor) else {
463 return write!(f, "{}B", self.0);
464 };
465 let number = format!("{:.2}", self.0 as f64 / *factor as f64);
466 let number = number.trim_end_matches('0').trim_end_matches('.');
467 write!(f, "{number}{unit}")
468 }
469}
470
471impl<'de> Deserialize<'de> for ByteSize {
472 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
473 String::deserialize(deserializer)?
474 .parse()
475 .map_err(de::Error::custom)
476 }
477}
478
479fn duration<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
481 let s = String::deserialize(deserializer)?;
482 let invalid = || {
483 de::Error::custom(format!(
484 "invalid duration {s:?}, expected a number and s, m or h, such as \"168h\""
485 ))
486 };
487 let (number, factor) = match s.chars().last() {
488 Some('s') => (&s[..s.len() - 1], 1),
489 Some('m') => (&s[..s.len() - 1], 60),
490 Some('h') => (&s[..s.len() - 1], 3600),
491 _ => return Err(invalid()),
492 };
493 let number: u64 = number.parse().map_err(|_| invalid())?;
494 number
495 .checked_mul(factor)
496 .map(Duration::from_secs)
497 .ok_or_else(invalid)
498}
499
500#[cfg(test)]
501mod tests;