1use crate::clone_backend::BackendKind;
2use crate::error::{ErrorCode, MitosisError, Result};
3use crate::names::parse_duration;
4use crate::paths::Home;
5use serde::Serialize;
6use std::io::Write;
7use std::net::{IpAddr, Ipv4Addr};
8use std::ops::RangeInclusive;
9use std::os::unix::fs::OpenOptionsExt;
10use std::time::Duration;
11
12#[derive(Clone, Debug)]
13pub struct Config {
14 pub max_clones: usize,
15 pub min_free_bytes: u64,
16 pub ram_budget: Option<u64>,
18 pub disk_budget: Option<u64>,
20 pub queue_wait: Duration,
22 pub clone_disk_warn: u64,
24 pub port_range: RangeInclusive<u16>,
25 pub default_max_age_secs: u64,
26 pub ready_timeout: Duration,
27 pub stop_timeout: Duration,
28 pub scheduler_tick: Duration,
30 pub router: bool,
32 pub router_port: u16,
33 pub router_listen: IpAddr,
35 pub idle_sleep: Option<Duration>,
37 pub idle_reap: Option<Duration>,
39 pub backend: BackendKind,
41 pub zfs_dataset: Option<String>,
43}
44
45impl Default for Config {
46 fn default() -> Self {
47 Config {
48 max_clones: 8,
49 min_free_bytes: 2_000_000_000,
50 ram_budget: None,
51 disk_budget: None,
52 queue_wait: Duration::from_secs(60),
53 clone_disk_warn: 5_000_000_000,
54 port_range: 54320..=54999,
55 default_max_age_secs: 604_800,
56 ready_timeout: Duration::from_secs(10),
57 stop_timeout: Duration::from_secs(10),
58 scheduler_tick: Duration::from_secs(60),
59 router: true,
60 router_port: 54300,
61 router_listen: IpAddr::V4(Ipv4Addr::LOCALHOST),
62 idle_sleep: Some(Duration::from_secs(600)),
63 idle_reap: None,
64 backend: BackendKind::Auto,
65 zfs_dataset: None,
66 }
67 }
68}
69
70#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
73#[serde(tag = "source", content = "env_var", rename_all = "snake_case")]
74pub enum ConfigSource {
75 Default,
76 File,
77 Env(String),
79}
80
81impl ConfigSource {
82 pub fn label(&self) -> String {
84 match self {
85 ConfigSource::Default => "default".into(),
86 ConfigSource::File => "config.toml".into(),
87 ConfigSource::Env(v) => format!("env {v}"),
88 }
89 }
90}
91
92#[derive(Clone, Debug, Serialize)]
93pub struct ConfigEntry {
94 pub key: String,
95 pub value: String,
96 #[serde(flatten)]
97 pub source: ConfigSource,
98}
99
100struct KeySpec {
101 key: &'static str,
102 env: &'static str,
103 want: &'static str,
105 example: &'static str,
106}
107
108const KEYS: [KeySpec; 13] = [
109 KeySpec {
110 key: "max_clones",
111 env: "MITOSIS_MAX_CLONES",
112 want: "a whole number of at least 1",
113 example: "8",
114 },
115 KeySpec {
116 key: "min_free_disk",
117 env: "MITOSIS_MIN_FREE_DISK",
118 want: "a size like 2GB, or a number of bytes",
119 example: "2GB",
120 },
121 KeySpec {
122 key: "ram_budget",
123 env: "MITOSIS_RAM_BUDGET",
124 want: "a size like 4GB (more than 0), or off",
125 example: "4GB",
126 },
127 KeySpec {
128 key: "disk_budget",
129 env: "MITOSIS_DISK_BUDGET",
130 want: "a size like 50GB (more than 0), or off",
131 example: "50GB",
132 },
133 KeySpec {
134 key: "queue_wait",
135 env: "MITOSIS_QUEUE_WAIT",
136 want: "a duration like 60s or 2m, or 0 for no wait",
137 example: "60s",
138 },
139 KeySpec {
140 key: "clone_disk_warn",
141 env: "MITOSIS_CLONE_DISK_WARN",
142 want: "a size like 5GB, or a number of bytes",
143 example: "5GB",
144 },
145 KeySpec {
146 key: "router",
147 env: "MITOSIS_ROUTER",
148 want: "on or off",
149 example: "on",
150 },
151 KeySpec {
152 key: "router_port",
153 env: "MITOSIS_ROUTER_PORT",
154 want: "a port number from 1 to 65535, outside the clone ports 54320-54999",
155 example: "54300",
156 },
157 KeySpec {
158 key: "router_listen",
159 env: "MITOSIS_ROUTER_LISTEN",
160 want: "127.0.0.1, 0.0.0.0 or ::",
161 example: "127.0.0.1",
162 },
163 KeySpec {
164 key: "idle_sleep",
165 env: "MITOSIS_IDLE_SLEEP",
166 want: "a duration like 10m or 1h, or off",
167 example: "10m",
168 },
169 KeySpec {
170 key: "idle_reap",
171 env: "MITOSIS_IDLE_REAP",
172 want: "a duration like 2h or 1d, or off",
173 example: "1d",
174 },
175 KeySpec {
176 key: "backend",
177 env: "MITOSIS_BACKEND",
178 want: "auto, apfs, reflink, zfs or copy",
179 example: "auto",
180 },
181 KeySpec {
182 key: "zfs_dataset",
183 env: "MITOSIS_ZFS_DATASET",
184 want: "a ZFS dataset name like tank/mitosis, or empty for none",
185 example: "tank/mitosis",
186 },
187];
188
189fn invalid(msg: impl Into<String>) -> MitosisError {
190 MitosisError::new(ErrorCode::InvalidRequest, msg)
191}
192
193pub fn keys() -> impl Iterator<Item = (&'static str, &'static str)> {
195 KEYS.iter().map(|k| (k.key, k.env))
196}
197
198fn key_list() -> String {
199 KEYS.iter().map(|k| k.key).collect::<Vec<_>>().join(", ")
200}
201
202fn spec(key: &str) -> Result<&'static KeySpec> {
203 KEYS.iter().find(|k| k.key == key).ok_or_else(|| {
204 invalid(format!("unknown setting {key:?}"))
205 .with_hint(format!("settings are: {}", key_list()))
206 })
207}
208
209pub fn parse_size(s: &str) -> Result<u64> {
212 let bad = || {
213 invalid(format!("invalid size {s:?}"))
214 .with_hint("use a whole number of bytes or a number plus a unit, like 500MB or 4GiB")
215 };
216 let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
217 let (digits, unit) = s.split_at(split);
218 if digits.is_empty() {
219 return Err(bad());
220 }
221 let mult: u64 = match unit.to_ascii_lowercase().as_str() {
222 "" => 1,
223 "kb" => 1_000,
224 "mb" => 1_000_000,
225 "gb" => 1_000_000_000,
226 "tb" => 1_000_000_000_000,
227 "kib" => 1 << 10,
228 "mib" => 1 << 20,
229 "gib" => 1 << 30,
230 "tib" => 1 << 40,
231 _ => return Err(bad()),
232 };
233 digits
234 .parse::<u64>()
235 .ok()
236 .and_then(|n| n.checked_mul(mult))
237 .ok_or_else(bad)
238}
239
240fn format_size(n: u64) -> String {
242 for (unit, name) in [
243 (1_000_000_000_000, "TB"),
244 (1_000_000_000, "GB"),
245 (1_000_000, "MB"),
246 (1_000, "KB"),
247 ] {
248 if n != 0 && n.is_multiple_of(unit) {
249 return format!("{}{name}", n / unit);
250 }
251 }
252 n.to_string()
253}
254
255fn format_setting_duration(d: Duration) -> String {
259 let secs = d.as_secs();
260 for (unit, size) in [('d', 86_400), ('h', 3600), ('m', 60)] {
261 if secs != 0 && secs.is_multiple_of(size) {
262 return format!("{}{unit}", secs / size);
263 }
264 }
265 format!("{secs}s")
266}
267
268fn parse_budget(v: &str) -> Option<Option<u64>> {
269 if v.eq_ignore_ascii_case("off") {
270 Some(None)
271 } else {
272 parse_size(v).ok().filter(|n| *n > 0).map(Some)
274 }
275}
276
277fn parse_switch(v: &str) -> Option<bool> {
278 match v.to_ascii_lowercase().as_str() {
279 "on" | "true" => Some(true),
280 "off" | "false" => Some(false),
281 _ => None,
282 }
283}
284
285fn parse_idle(v: &str) -> Option<Option<Duration>> {
286 if v.eq_ignore_ascii_case("off") {
287 Some(None)
288 } else {
289 parse_duration(v).ok().map(Some)
290 }
291}
292
293fn parse_listen(v: &str) -> Option<IpAddr> {
296 let ip: IpAddr = v.parse().ok()?;
297 let ok = match ip {
298 IpAddr::V4(a) => a == Ipv4Addr::LOCALHOST || a.is_unspecified(),
299 IpAddr::V6(a) => a.is_unspecified(),
300 };
301 ok.then_some(ip)
302}
303
304fn parse_wait(v: &str) -> Option<Duration> {
305 crate::names::parse_wait(v).ok()
306}
307
308impl Config {
309 pub fn load(home: &Home) -> Result<Config> {
311 Config::default()
312 .layered(home, &|k| std::env::var(k).ok())
313 .map(|(c, _)| c)
314 }
315
316 pub fn reloaded(&self, home: &Home) -> Result<Config> {
318 let base = Config {
319 port_range: self.port_range.clone(),
320 default_max_age_secs: self.default_max_age_secs,
321 ready_timeout: self.ready_timeout,
322 stop_timeout: self.stop_timeout,
323 scheduler_tick: self.scheduler_tick,
324 ..Config::default()
325 };
326 base.layered(home, &|k| std::env::var(k).ok())
328 .map(|(c, _)| Config {
329 router: self.router,
330 router_port: self.router_port,
331 router_listen: self.router_listen,
332 backend: self.backend,
334 zfs_dataset: self.zfs_dataset.clone(),
335 ..c
336 })
337 }
338
339 pub fn describe(home: &Home) -> Result<Vec<ConfigEntry>> {
341 let (cfg, sources) = Config::default().layered(home, &|k| std::env::var(k).ok())?;
342 Ok(KEYS
343 .iter()
344 .zip(sources)
345 .map(|(k, source)| ConfigEntry {
346 key: k.key.into(),
347 value: cfg.show(k.key),
348 source,
349 })
350 .collect())
351 }
352
353 pub fn env_override(home: &Home, key: &str) -> Result<Option<(String, String)>> {
355 Config::env_override_with(home, key, &|k| std::env::var(k).ok())
356 }
357
358 fn env_override_with(
359 home: &Home,
360 key: &str,
361 var: &dyn Fn(&str) -> Option<String>,
362 ) -> Result<Option<(String, String)>> {
363 let (cfg, sources) = Config::default().layered(home, var)?;
364 Ok(KEYS
365 .iter()
366 .zip(sources)
367 .find(|(k, _)| k.key == key)
368 .and_then(|(k, source)| match source {
369 ConfigSource::Env(v) => Some((v, cfg.show(k.key))),
370 _ => None,
371 }))
372 }
373
374 pub fn set_in_file(home: &Home, key: &str, value: &str) -> Result<()> {
376 let value = value.trim();
377 let s = spec(key)?;
378 Config::default()
379 .set_field(key, value)
380 .ok_or_else(|| bad_value(&format!("setting {key}"), s, value, false))?;
381 home.ensure_dirs()?;
382 let mut table = read_table(home)?;
383 let stored = match key {
384 "max_clones" | "router_port" => value.parse::<i64>().map(toml::Value::Integer).ok(),
385 _ => None,
386 };
387 table.insert(
388 key.into(),
389 stored.unwrap_or_else(|| toml::Value::String(value.into())),
390 );
391 let text = toml::to_string(&table)
392 .map_err(|e| MitosisError::new(ErrorCode::Internal, format!("encode config: {e}")))?;
393 let tmp = home
396 .root
397 .join(format!("config.toml.tmp-{}", std::process::id()));
398 let mut f = std::fs::OpenOptions::new()
399 .write(true)
400 .create(true)
401 .truncate(true)
402 .mode(0o600)
403 .open(&tmp)?;
404 f.write_all(text.as_bytes())?;
405 f.sync_all()?;
406 if let Err(e) = std::fs::rename(&tmp, home.config_file()) {
407 let _ = std::fs::remove_file(&tmp);
408 return Err(e.into());
409 }
410 Ok(())
411 }
412
413 fn set_field(&mut self, key: &str, v: &str) -> Option<()> {
415 match key {
416 "max_clones" => self.max_clones = v.parse::<usize>().ok().filter(|n| *n >= 1)?,
417 "min_free_disk" => self.min_free_bytes = parse_size(v).ok()?,
418 "ram_budget" => self.ram_budget = parse_budget(v)?,
419 "disk_budget" => self.disk_budget = parse_budget(v)?,
420 "queue_wait" => self.queue_wait = parse_wait(v)?,
421 "clone_disk_warn" => self.clone_disk_warn = parse_size(v).ok()?,
422 "router" => self.router = parse_switch(v)?,
423 "router_port" => {
424 self.router_port = v
426 .parse::<u16>()
427 .ok()
428 .filter(|p| *p >= 1 && !self.port_range.contains(p))?
429 }
430 "router_listen" => self.router_listen = parse_listen(v)?,
431 "idle_sleep" => self.idle_sleep = parse_idle(v)?,
432 "idle_reap" => self.idle_reap = parse_idle(v)?,
433 "backend" => self.backend = v.parse().ok()?,
434 "zfs_dataset" => {
435 if v.contains(char::is_whitespace) {
436 return None;
437 }
438 self.zfs_dataset = (!v.is_empty()).then(|| v.to_string());
439 }
440 _ => return None,
441 }
442 Some(())
443 }
444
445 fn show(&self, key: &str) -> String {
446 let budget = |b: Option<u64>| b.map_or("off".to_string(), format_size);
447 match key {
448 "max_clones" => self.max_clones.to_string(),
449 "min_free_disk" => format_size(self.min_free_bytes),
450 "ram_budget" => budget(self.ram_budget),
451 "disk_budget" => budget(self.disk_budget),
452 "queue_wait" => format_setting_duration(self.queue_wait),
453 "clone_disk_warn" => format_size(self.clone_disk_warn),
454 "router" => if self.router { "on" } else { "off" }.to_string(),
455 "router_port" => self.router_port.to_string(),
456 "router_listen" => self.router_listen.to_string(),
457 "idle_sleep" => self
458 .idle_sleep
459 .map_or("off".into(), format_setting_duration),
460 "idle_reap" => self.idle_reap.map_or("off".into(), format_setting_duration),
461 "backend" => self.backend.as_str().to_string(),
462 "zfs_dataset" => self.zfs_dataset.clone().unwrap_or_default(),
463 _ => String::new(),
464 }
465 }
466
467 fn layered(
469 mut self,
470 home: &Home,
471 var: &dyn Fn(&str) -> Option<String>,
472 ) -> Result<(Config, Vec<ConfigSource>)> {
473 let mut sources = vec![ConfigSource::Default; KEYS.len()];
474 for (key, value) in &read_table(home)? {
475 let Some(i) = KEYS.iter().position(|k| k.key == key) else {
476 return Err(invalid(format!("config.toml has an unknown key {key:?}"))
477 .with_hint(format!("settings are: {}", key_list())));
478 };
479 let origin = format!("config.toml key {key}");
480 let text = match value {
481 toml::Value::Integer(n) => n.to_string(),
482 toml::Value::String(s) => s.clone(),
483 toml::Value::Boolean(b) => b.to_string(),
484 other => {
485 return Err(invalid(format!(
486 "{origin} must be {}, got a {}",
487 KEYS[i].want,
488 other.type_str()
489 ))
490 .with_hint(file_hint(&KEYS[i])))
491 }
492 };
493 self.set_field(key, text.trim())
494 .ok_or_else(|| bad_value(&origin, &KEYS[i], &text, true))?;
495 sources[i] = ConfigSource::File;
496 }
497 for (i, k) in KEYS.iter().enumerate() {
498 if let Some(v) = var(k.env) {
499 self.set_field(k.key, v.trim())
500 .ok_or_else(|| bad_value(k.env, k, &v, false))?;
501 sources[i] = ConfigSource::Env(k.env.into());
502 }
503 }
504 Ok((self, sources))
505 }
506}
507
508fn file_hint(s: &KeySpec) -> String {
509 format!("fix it with: mitosis config set {} {}", s.key, s.example)
510}
511
512fn bad_value(origin: &str, s: &KeySpec, value: &str, from_file: bool) -> MitosisError {
513 let hint = if from_file {
514 file_hint(s)
515 } else if origin == s.env {
516 format!("fix or unset {origin}, e.g. {}={}", s.env, s.example)
517 } else {
518 format!("for example: mitosis config set {} {}", s.key, s.example)
519 };
520 invalid(format!("{origin} must be {}, got {value:?}", s.want)).with_hint(hint)
521}
522
523fn read_table(home: &Home) -> Result<toml::Table> {
525 let path = home.config_file();
526 let text = match std::fs::read_to_string(&path) {
527 Ok(t) => t,
528 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(toml::Table::new()),
529 Err(e) => {
530 return Err(MitosisError::new(
531 ErrorCode::Internal,
532 format!("read {}: {e}", path.display()),
533 ))
534 }
535 };
536 toml::from_str(&text).map_err(|e| {
537 invalid(format!("{} is not valid TOML: {e}", path.display()))
538 .with_hint("fix the file, or delete it to go back to the defaults")
539 })
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 fn home() -> (tempfile::TempDir, Home) {
547 let tmp = tempfile::tempdir().unwrap();
548 let home = Home::at(tmp.path().to_path_buf());
549 (tmp, home)
550 }
551
552 fn no_env(_: &str) -> Option<String> {
553 None
554 }
555
556 fn entry(
557 home: &Home,
558 env: &dyn Fn(&str) -> Option<String>,
559 key: &str,
560 ) -> (String, ConfigSource) {
561 let (cfg, sources) = Config::default().layered(home, env).unwrap();
562 let i = KEYS.iter().position(|k| k.key == key).unwrap();
563 (cfg.show(key), sources[i].clone())
564 }
565
566 #[test]
567 fn defaults() {
568 let c = Config::default();
569 assert_eq!(c.max_clones, 8);
570 assert_eq!(c.min_free_bytes, 2_000_000_000);
571 assert_eq!((c.ram_budget, c.disk_budget), (None, None));
572 assert_eq!(c.queue_wait, Duration::from_secs(60));
573 assert_eq!(c.clone_disk_warn, 5_000_000_000);
574 assert_eq!(c.port_range, 54320..=54999);
575 assert_eq!(c.default_max_age_secs, 604_800);
576 assert_eq!(c.ready_timeout, Duration::from_secs(10));
577 assert_eq!(c.stop_timeout, Duration::from_secs(10));
578 assert_eq!(c.scheduler_tick, Duration::from_secs(60));
579 assert!(c.router);
580 assert_eq!(c.router_port, 54300);
581 assert_eq!(c.router_listen.to_string(), "127.0.0.1");
582 assert_eq!(c.idle_sleep, Some(Duration::from_secs(600)));
583 assert_eq!(c.idle_reap, None);
584 }
585
586 #[test]
587 fn backend_key_parses_all_values_and_rejects_others() {
588 let (_tmp, home) = home();
589 for v in ["auto", "apfs", "reflink", "zfs", "copy"] {
590 let env = |k: &str| (k == "MITOSIS_BACKEND").then(|| v.to_string());
591 assert_eq!(entry(&home, &env, "backend").0, v);
592 }
593 let env = |k: &str| (k == "MITOSIS_BACKEND").then(|| "btrfs".to_string());
594 let e = Config::default().layered(&home, &env).unwrap_err();
595 assert_eq!(e.code, ErrorCode::InvalidRequest);
596 assert!(e.message.starts_with("MITOSIS_BACKEND"), "{e}");
597 assert!(
598 e.message.contains("auto, apfs, reflink, zfs or copy"),
599 "{e}"
600 );
601 assert!(Config::set_in_file(&home, "backend", "btrfs").is_err());
602 Config::set_in_file(&home, "backend", "copy").unwrap();
603 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
604 assert_eq!(c.backend, BackendKind::Copy);
605 }
606
607 #[test]
608 fn zfs_dataset_defaults_empty() {
609 let (_tmp, home) = home();
610 assert_eq!(Config::default().zfs_dataset, None);
611 assert_eq!(entry(&home, &no_env, "zfs_dataset").0, "");
612 Config::set_in_file(&home, "zfs_dataset", "tank/mitosis").unwrap();
613 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
614 assert_eq!(c.zfs_dataset.as_deref(), Some("tank/mitosis"));
615 assert!(Config::set_in_file(&home, "zfs_dataset", "tank mitosis").is_err());
616 Config::set_in_file(&home, "zfs_dataset", "").unwrap();
617 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
618 assert_eq!(c.zfs_dataset, None);
619 }
620
621 #[test]
622 fn idle_keys() {
623 let (_tmp, home) = home();
624 std::fs::write(
625 home.config_file(),
626 "idle_sleep = \"off\"\nidle_reap = \"2h\"\n",
627 )
628 .unwrap();
629 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
630 assert_eq!(
631 (c.idle_sleep, c.idle_reap),
632 (None, Some(Duration::from_secs(7200)))
633 );
634 assert_eq!(entry(&home, &no_env, "idle_sleep").0, "off");
635 assert_eq!(entry(&home, &no_env, "idle_reap").0, "2h");
636 let env = |k: &str| (k == "MITOSIS_IDLE_SLEEP").then(|| "0s".to_string());
637 assert!(Config::default().layered(&home, &env).is_err());
638 assert!(Config::set_in_file(&home, "idle_sleep", "soon").is_err());
639 Config::set_in_file(&home, "idle_sleep", "5m").unwrap();
640 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
641 assert_eq!(c.idle_sleep, Some(Duration::from_secs(300)));
642 }
643
644 #[test]
645 fn router_keys() {
646 let (_tmp, home) = home();
647 std::fs::write(
648 home.config_file(),
649 "router = false\nrouter_port = 6000\nrouter_listen = \"0.0.0.0\"\n",
650 )
651 .unwrap();
652 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
653 assert_eq!((c.router, c.router_port), (false, 6000));
654 assert_eq!(c.router_listen.to_string(), "0.0.0.0");
655 let env = |k: &str| (k == "MITOSIS_ROUTER").then(|| "on".to_string());
656 assert_eq!(entry(&home, &env, "router").0, "on");
657 for (k, v) in [
658 ("MITOSIS_ROUTER", "maybe"),
659 ("MITOSIS_ROUTER_PORT", "0"),
660 ("MITOSIS_ROUTER_PORT", "70000"),
661 ("MITOSIS_ROUTER_LISTEN", "localhost"),
662 ("MITOSIS_ROUTER_LISTEN", "192.168.1.5"),
663 ("MITOSIS_ROUTER_LISTEN", "127.0.0.2"),
664 ("MITOSIS_ROUTER_LISTEN", "fe80::1"),
665 ("MITOSIS_ROUTER_LISTEN", "::1"),
666 ("MITOSIS_ROUTER_PORT", "54320"),
667 ("MITOSIS_ROUTER_PORT", "54999"),
668 ] {
669 let env = |n: &str| (n == k).then(|| v.to_string());
670 let e = Config::default().layered(&home, &env).unwrap_err();
671 assert!(e.message.starts_with(k), "{e}");
672 }
673 for ok in ["127.0.0.1", "0.0.0.0", "::"] {
674 let env = |n: &str| (n == "MITOSIS_ROUTER_LISTEN").then(|| ok.to_string());
675 let (c, _) = Config::default().layered(&home, &env).unwrap();
676 assert_eq!(c.router_listen.to_string(), ok);
677 }
678 assert!(Config::set_in_file(&home, "router_port", "54400").is_err());
679 assert!(Config::set_in_file(&home, "router_listen", "10.0.0.1").is_err());
680 Config::set_in_file(&home, "router_port", "6001").unwrap();
681 Config::set_in_file(&home, "router", "off").unwrap();
682 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
683 assert_eq!((c.router, c.router_port), (false, 6001));
684 }
685
686 #[test]
687 fn parse_size_units() {
688 assert_eq!(parse_size("500MB").unwrap(), 500_000_000);
689 assert_eq!(parse_size("4GiB").unwrap(), 4 * 1024 * 1024 * 1024);
690 assert_eq!(parse_size("123").unwrap(), 123);
691 assert_eq!(parse_size("2gb").unwrap(), 2_000_000_000);
692 assert_eq!(parse_size("1TiB").unwrap(), 1 << 40);
693 for bad in [
694 "4 gb",
695 "-1",
696 "GB",
697 "",
698 "1.5GB",
699 "5XB",
700 "99999999999999999999",
701 ] {
702 assert!(parse_size(bad).is_err(), "{bad:?}");
703 }
704 assert_eq!(
705 parse_size(&format_size(2_000_000_000)).unwrap(),
706 2_000_000_000
707 );
708 }
709
710 #[test]
711 fn config_file_then_env_precedence() {
712 let (_tmp, home) = home();
713 std::fs::write(home.config_file(), "max_clones = 3\n").unwrap();
714 let env = |k: &str| (k == "MITOSIS_MAX_CLONES").then(|| "5".to_string());
715 assert_eq!(
716 entry(&home, &env, "max_clones"),
717 ("5".into(), ConfigSource::Env("MITOSIS_MAX_CLONES".into()))
718 );
719 assert_eq!(
720 entry(&home, &no_env, "max_clones"),
721 ("3".into(), ConfigSource::File)
722 );
723 assert_eq!(
724 entry(&home, &no_env, "ram_budget"),
725 ("off".into(), ConfigSource::Default)
726 );
727 }
728
729 #[test]
730 fn config_file_errors_name_the_key() {
731 let (_tmp, home) = home();
732 let err = |text: &str| {
733 std::fs::write(home.config_file(), text).unwrap();
734 Config::default().layered(&home, &no_env).unwrap_err()
735 };
736 let e = err("ram_budget = \"lots\"\n");
737 assert_eq!(e.code, ErrorCode::InvalidRequest);
738 assert!(
739 e.message.contains("ram_budget") && e.message.contains("config.toml"),
740 "{e}"
741 );
742 assert!(e.hint.as_deref().unwrap().contains("4GB"), "{e}");
743 let e = err("colour = 1\n");
744 assert!(
745 e.message.contains("colour") && e.message.contains("config.toml"),
746 "{e}"
747 );
748 let e = err("max_clones = [\n");
749 assert!(e.message.contains("config.toml"), "{e}");
750 let e = err("max_clones = true\n");
751 assert!(e.message.contains("max_clones"), "{e}");
752 let e = err("max_clones = 0\n");
753 assert!(e.message.contains("max_clones"), "{e}");
754 }
755
756 #[test]
757 fn env_errors_name_the_variable() {
758 let (_tmp, home) = home();
759 for (k, v) in [
760 ("MITOSIS_MAX_CLONES", "0"),
761 ("MITOSIS_MAX_CLONES", "lots"),
762 ("MITOSIS_MIN_FREE_DISK", ""),
763 ("MITOSIS_RAM_BUDGET", "4 GB"),
764 ("MITOSIS_QUEUE_WAIT", "soon"),
765 ] {
766 let env = |n: &str| (n == k).then(|| v.to_string());
767 let e = Config::default().layered(&home, &env).unwrap_err();
768 assert_eq!(e.code, ErrorCode::InvalidRequest);
769 assert!(e.message.starts_with(k), "{e}");
770 }
771 }
772
773 #[test]
774 fn budgets_and_wait() {
775 let (_tmp, home) = home();
776 std::fs::write(
777 home.config_file(),
778 "ram_budget = \"2GB\"\ndisk_budget = \"off\"\nqueue_wait = \"0s\"\nmin_free_disk = 500000000\n",
779 )
780 .unwrap();
781 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
782 assert_eq!(c.ram_budget, Some(2_000_000_000));
783 assert_eq!(c.disk_budget, None);
784 assert_eq!(c.queue_wait, Duration::ZERO);
785 assert_eq!(c.min_free_bytes, 500_000_000);
786 let env = |k: &str| (k == "MITOSIS_QUEUE_WAIT").then(|| "2m".to_string());
787 let (c, _) = Config::default().layered(&home, &env).unwrap();
788 assert_eq!(c.queue_wait, Duration::from_secs(120));
789 }
790
791 #[test]
792 fn zero_budgets_are_rejected() {
793 let (_tmp, home) = home();
794 for key in ["ram_budget", "disk_budget"] {
795 std::fs::write(home.config_file(), format!("{key} = \"0\"\n")).unwrap();
796 let e = Config::default().layered(&home, &no_env).unwrap_err();
797 assert_eq!(e.code, ErrorCode::InvalidRequest);
798 assert!(e.message.contains(key), "{}", e.message);
799 assert!(Config::set_in_file(&home, key, "0").is_err());
800 let env =
801 |k: &str| (k == format!("MITOSIS_{}", key.to_uppercase())).then(|| "0".to_string());
802 std::fs::remove_file(home.config_file()).unwrap();
803 assert!(Config::default().layered(&home, &env).is_err());
804 }
805 }
806
807 #[test]
808 fn set_in_file_keeps_other_keys() {
809 let (_tmp, home) = home();
810 Config::set_in_file(&home, "max_clones", "4").unwrap();
811 Config::set_in_file(&home, "ram_budget", "2GB").unwrap();
812 let table: toml::Table =
813 toml::from_str(&std::fs::read_to_string(home.config_file()).unwrap()).unwrap();
814 assert_eq!(table["max_clones"].as_integer(), Some(4));
815 assert_eq!(table["ram_budget"].as_str(), Some("2GB"));
816 let (c, _) = Config::default().layered(&home, &no_env).unwrap();
817 assert_eq!((c.max_clones, c.ram_budget), (4, Some(2_000_000_000)));
818 assert!(Config::set_in_file(&home, "ram_budget", "lots").is_err());
820 assert!(Config::set_in_file(&home, "colour", "red").is_err());
821 let again: toml::Table =
822 toml::from_str(&std::fs::read_to_string(home.config_file()).unwrap()).unwrap();
823 assert_eq!(table, again);
824 }
825
826 #[test]
827 fn reload_keeps_fixed_settings() {
828 let (_tmp, home) = home();
829 let base = Config {
830 port_range: 60000..=60019,
831 scheduler_tick: Duration::from_millis(50),
832 max_clones: 1,
833 ..Config::default()
834 };
835 if let Ok(c) = base.reloaded(&home) {
837 assert_eq!(c.port_range, 60000..=60019);
838 assert_eq!(c.scheduler_tick, Duration::from_millis(50));
839 }
840 }
841
842 #[test]
843 fn shown_durations_parse_back() {
844 for secs in [0, 1, 45, 60, 90, 3600, 5400, 7200, 86_400, 90_000] {
845 let d = Duration::from_secs(secs);
846 let shown = format_setting_duration(d);
847 assert_eq!(parse_wait(&shown), Some(d), "{secs}s shown as {shown}");
848 }
849 assert_eq!(format_setting_duration(Duration::from_secs(90)), "90s");
850 assert_eq!(format_setting_duration(Duration::from_secs(7200)), "2h");
851 }
852
853 #[test]
854 fn set_in_file_leaves_no_temp_file() {
855 let (_tmp, home) = home();
856 Config::set_in_file(&home, "max_clones", "4").unwrap();
857 Config::set_in_file(&home, "router", "off").unwrap();
858 let names: Vec<String> = std::fs::read_dir(&home.root)
859 .unwrap()
860 .flatten()
861 .map(|e| e.file_name().to_string_lossy().into_owned())
862 .collect();
863 assert!(names.iter().all(|n| !n.contains(".tmp")), "{names:?}");
864 assert!(names.contains(&"config.toml".to_string()));
865 }
866
867 #[test]
868 fn env_override_names_the_variable_in_effect() {
869 let (_tmp, home) = home();
870 Config::set_in_file(&home, "max_clones", "4").unwrap();
871 assert_eq!(
872 Config::env_override_with(&home, "max_clones", &no_env).unwrap(),
873 None
874 );
875 let env = |k: &str| (k == "MITOSIS_MAX_CLONES").then(|| "9".to_string());
876 assert_eq!(
877 Config::env_override_with(&home, "max_clones", &env).unwrap(),
878 Some(("MITOSIS_MAX_CLONES".to_string(), "9".to_string()))
879 );
880 assert_eq!(
881 Config::env_override_with(&home, "router", &env).unwrap(),
882 None
883 );
884 }
885}