//! What magicmirror is pointed at. //! //! Adding a service is a config edit and nothing else — no code change, no new //! tab type, no widget. That property is what makes magicmirror modular rather //! than merely tabbed, and it is worth defending: the moment a source needs a //! special case here, the shell has started learning domain vocabulary. use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; use serde::Deserialize; /// Default poll interval. Matches what the Sando TUI already did. const DEFAULT_POLL_SECS: u64 = 5; /// Default age past which a source's answer stops counting as current. /// /// Deliberately a small multiple of the poll interval rather than something /// generous: the whole point is that a source which quietly stopped updating /// looks different from one that is fine. const DEFAULT_STALE_SECS: u64 = 60; /// Default interval between store reads. /// /// Far slower than a source poll, because a store is written by a batch /// producer: witchbroom's sweep runs for hours and writes once. Re-reading it /// every five seconds would be spending a query to learn nothing. const DEFAULT_STORE_POLL_SECS: u64 = 60; #[derive(Debug, Clone, Deserialize)] pub(crate) struct Config { /// Fallback for any source that does not set its own. #[serde(default = "default_stale")] pub stale_after_secs: u64, /// The theme to render in: `"system"` to follow the terminal, or a theme /// id to pin. /// /// Unprefixed `theme`, which is the family convention's key name (wiki /// `makeover-app-convention`) — this file is already scoped to magicmirror, /// so a `magicmirror-theme` would be saying it twice. Absent reads as "follow /// the terminal" rather than as a pin on whatever the first run guessed. #[serde(default)] pub theme: Option, #[serde(default, rename = "source")] pub sources: Vec, /// Observation stores to render on the store tab. /// /// Optional, and normally absent: a store is a producer's own database, /// read directly, which is the one thing here that is not the `ops-status` /// contract. Nothing is shown unless it is named below. #[serde(default, rename = "store")] pub stores: Vec, } /// One observation store, and which of its series are worth rendering. /// /// **Meaning lives here, not in the data.** witchbroom's store is deliberately /// `(series, labels, value, at)` with an `f64`, so the schema cannot say what a /// series is or what unit it is in. Inferring it would mean guessing, and /// rendering every series unlabelled would make this a table browser, which is /// what the ruling rejected. So a series is named here with what it means, and /// a series nobody named is not shown. Silence over noise, on purpose. #[derive(Debug, Clone, Deserialize)] pub(crate) struct Store { /// What to call this store on screen. pub name: String, /// The SQLite file. A leading `~` expands at load, same as a file source. pub path: PathBuf, #[serde(default = "default_store_poll")] pub poll_secs: u64, #[serde(default, rename = "series")] pub series: Vec, } /// One series worth rendering, and what it means. #[derive(Debug, Clone, Deserialize)] pub(crate) struct Series { /// The `series` value in the store, verbatim. Spelled `series` in the file, /// where `[[store.series]]` has already said what kind of thing it is. #[serde(rename = "series")] pub name: String, /// What to call it on screen. pub label: String, /// What the number is in. Optional because some numbers are counts of /// nothing in particular. #[serde(default)] pub unit: Option, } /// Where one source's payload comes from. /// /// Two shapes, because two shapes of producer exist. A daemon is polled over /// HTTP. A batch producer runs, writes, and stops, so it has nothing to poll: /// witchbroom's sweep takes hours and then the machine is idle until tomorrow. /// Making it grow an HTTP listener to be visible would be paying a daemon's /// cost for a producer that is not one, and the panel already keeps the last /// payload across a failed poll, which is exactly the property a batch producer /// needs. So a source can be a file, read on the same tick. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Origin { /// The base URL with `/status.json` appended. Http(String), File(PathBuf), } #[derive(Debug, Clone, Deserialize)] pub(crate) struct Source { /// Tab label, and the `source` name the payload should carry. pub name: String, /// Base URL of a daemon to poll. `/status.json` is appended. Exactly one of /// this and `path` is set; `validate` refuses anything else. #[serde(default)] pub url: Option, /// A payload on disk, rewritten by a batch producer at the end of its run. /// /// A leading `~` expands at load. Nothing else about it is interpreted: the /// full filename is given, not a base to append `status.json` to, because a /// producer writing one file has no reason to own a directory. #[serde(default)] pub path: Option, /// Name of the environment variable holding this source's bearer token. /// /// The token is named, never inlined: this file describes topology and has /// every reason to be readable, while the daemons already take their tokens /// from the environment (`SANDO_API_TOKEN`, `BENTO_API_TOKEN`). A config /// format that invites pasting a prod token into a file is one that ends /// with a prod token in a file. #[serde(default)] pub token_env: Option, #[serde(default = "default_poll")] pub poll_secs: u64, #[serde(default)] pub stale_after_secs: Option, /// Whether this source's declared actions may be fired from magicmirror. /// /// Defaults off. magicmirror is a display first, and some declared actions /// (sando's `promote-b`, `rollback-b`) move production. Firing is opt-in per /// source so that pointing magicmirror at a daemon can never move it by /// accident: the operator turns a source's actions on deliberately, the same /// place they already name its token. A source with this off still renders /// its actions; it just refuses to issue them. #[serde(default)] pub allow_actions: bool, } /// `~` in a configured path, against `$HOME`. /// /// A file source's path is written by hand and the obvious one to write is /// `~/.local/state/witchbroom/status.json`. Without this it resolves to a /// literal `./~` and the tab reads as a producer that never ran. fn expand_tilde(p: &Path) -> PathBuf { let Ok(rest) = p.strip_prefix("~") else { return p.to_path_buf(); }; match std::env::var_os("HOME") { Some(home) => PathBuf::from(home).join(rest), None => p.to_path_buf(), } } fn default_poll() -> u64 { DEFAULT_POLL_SECS } fn default_stale() -> u64 { DEFAULT_STALE_SECS } fn default_store_poll() -> u64 { DEFAULT_STORE_POLL_SECS } impl Source { /// Where this source's payload comes from. /// /// Total, because `validate` has already refused every config that would /// make it partial. A source with neither is rejected at load rather than /// becoming a tab that can never say anything. pub(crate) fn origin(&self) -> Origin { match (&self.url, &self.path) { (Some(url), _) => Origin::Http(format!("{}/status.json", url.trim_end_matches('/'))), (None, Some(path)) => Origin::File(path.clone()), (None, None) => unreachable!("validate rejects a source with neither url nor path"), } } pub(crate) fn poll_interval(&self) -> Duration { Duration::from_secs(self.poll_secs.max(1)) } /// Resolve a declared action's `url` against this source's base. /// /// An action carries a path (`/rollback/b`); a producer that instead gives a /// full URL is honored as-is, so a daemon can point an action at somewhere /// other than itself without magicmirror second-guessing it. /// /// A file source is refused `allow_actions` at load and the UI refuses to /// fire without it, so the no-base arm is unreachable rather than a policy: /// a path is not something an action's URL can be resolved against. pub(crate) fn action_url(&self, path: &str) -> String { if path.starts_with("http://") || path.starts_with("https://") { return path.to_string(); } let Some(base) = self.url.as_deref() else { return path.to_string(); }; format!( "{}/{}", base.trim_end_matches('/'), path.trim_start_matches('/') ) } /// Resolve the bearer token from the environment, if one is named. pub(crate) fn token(&self) -> Option { self.token_env .as_deref() .and_then(|name| std::env::var(name).ok()) .filter(|t| !t.is_empty()) } } impl Config { pub(crate) fn load(path: &Path) -> Result { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading magicmirror config at {}", path.display()))?; let mut cfg: Config = toml::from_str(&raw) .with_context(|| format!("parsing magicmirror config at {}", path.display()))?; for store in &mut cfg.stores { store.path = expand_tilde(&store.path); } for source in &mut cfg.sources { if let Some(p) = &source.path { source.path = Some(expand_tilde(p)); } } cfg.validate()?; Ok(cfg) } fn validate(&self) -> Result<()> { anyhow::ensure!( !self.sources.is_empty(), "no [[source]] entries: magicmirror would have nothing to show" ); let mut seen = std::collections::HashSet::new(); for source in &self.sources { anyhow::ensure!( seen.insert(source.name.as_str()), "duplicate source name {:?}: tabs would be ambiguous", source.name ); match (&source.url, &source.path) { (Some(url), None) => anyhow::ensure!( url.starts_with("http://") || url.starts_with("https://"), "source {:?} url must start with http:// or https://", source.name ), (None, Some(_)) => { // Both are things a URL source has and a file has no // equivalent of, and both are quiet when ignored. A token // silently unused reads as a source that is authenticated; // `allow_actions = true` silently unused reads as a source // that can be driven. Refusing says which it is. anyhow::ensure!( source.token_env.is_none(), "source {:?} is a file and cannot present a bearer token", source.name ); anyhow::ensure!( !source.allow_actions, "source {:?} is a file: an action is a URL and there is no base to \ resolve one against", source.name ); } (Some(_), Some(_)) => anyhow::bail!( "source {:?} sets both url and path: one payload, one origin", source.name ), (None, None) => anyhow::bail!( "source {:?} sets neither url nor path: it could never say anything", source.name ), } } let mut seen_stores = std::collections::HashSet::new(); for store in &self.stores { anyhow::ensure!( seen_stores.insert(store.name.as_str()), "duplicate store name {:?}: its rows would be ambiguous", store.name ); // A store with no series is not a store showing nothing, it is a // config that can never say anything -- the same refusal a source // with neither url nor path gets, for the same reason. anyhow::ensure!( !store.series.is_empty(), "store {:?} declares no [[store.series]]: nothing would be rendered, \ and an unnamed series is not shown", store.name ); let mut seen_series = std::collections::HashSet::new(); for series in &store.series { anyhow::ensure!( seen_series.insert(series.name.as_str()), "store {:?} names series {:?} twice", store.name, series.name ); } } Ok(()) } /// Interval between reads of one store. pub(crate) fn store_interval(store: &Store) -> Duration { Duration::from_secs(store.poll_secs.max(1)) } /// What the operator chose, which is not the same as what is rendered: a /// standing "follow the terminal" resolves differently as the terminal /// changes, and a pinned id does not. pub(crate) fn theme_selection(&self) -> makeover::ThemeSelection { makeover::ThemeSelection::parse(self.theme.as_deref()) } /// Staleness limit for one source: its own, else the global default. pub(crate) fn stale_after(&self, source: &Source) -> chrono::TimeDelta { let secs = source.stale_after_secs.unwrap_or(self.stale_after_secs); chrono::TimeDelta::seconds(secs as i64) } } #[cfg(test)] mod tests { use super::*; fn write(body: &str) -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("magicmirror.toml"); std::fs::write(&path, body).unwrap(); (dir, path) } #[test] fn a_minimal_source_gets_sensible_defaults() { let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://fw13:8080" "#, ); let cfg = Config::load(&path).unwrap(); assert_eq!( cfg.sources[0].origin(), Origin::Http("http://fw13:8080/status.json".into()) ); assert_eq!(cfg.sources[0].poll_interval().as_secs(), DEFAULT_POLL_SECS); assert_eq!( cfg.stale_after(&cfg.sources[0]), chrono::TimeDelta::seconds(DEFAULT_STALE_SECS as i64) ); } #[test] fn an_action_path_resolves_against_the_base_and_a_full_url_is_left_alone() { let source: Source = toml::from_str( r#" name = "sando" url = "http://fw13:8080/" "#, ) .unwrap(); assert_eq!( source.action_url("/rollback/b"), "http://fw13:8080/rollback/b" ); assert_eq!( source.action_url("rollback/b"), "http://fw13:8080/rollback/b" ); assert_eq!( source.action_url("https://elsewhere/x"), "https://elsewhere/x" ); } #[test] fn a_trailing_slash_does_not_double_up() { let (_dir, path) = write( r#" [[source]] name = "bento" url = "http://fw13:8090/" "#, ); let cfg = Config::load(&path).unwrap(); assert_eq!( cfg.sources[0].origin(), Origin::Http("http://fw13:8090/status.json".into()) ); } #[test] fn the_shipped_example_config_parses() { // The example is the documentation, and it is the file people copy. A // key renamed in code and not there is a config that fails on first // run, which is the worst place to find out. let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("deploy/magicmirror.toml.example"); let cfg = Config::load(&path).unwrap_or_else(|e| panic!("{e:?}")); assert!(!cfg.sources.is_empty()); } #[test] fn the_store_block_in_the_shipped_example_parses_when_uncommented() { // The store half of the example is commented out because a store is // optional, which puts it outside the test above. Uncomment it here so // it cannot drift either. let raw = std::fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("deploy/magicmirror.toml.example"), ) .unwrap(); let uncommented: String = raw .lines() .skip_while(|l| !l.starts_with("# [[store]]")) .map(|l| { l.strip_prefix("# ") .or_else(|| l.strip_prefix('#')) .unwrap_or(l) }) .collect::>() .join("\n"); let cfg: Config = toml::from_str(&uncommented).unwrap_or_else(|e| panic!("{e:?}")); let store = &cfg.stores[0]; assert_eq!(store.name, "witchbroom"); assert_eq!(store.series.len(), 2); assert_eq!(store.series[0].name, "soak.coverage_edges"); assert_eq!(store.series[0].label, "Coverage reached"); } #[test] fn a_store_declares_its_series_and_expands_a_tilde_path() { let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://fw13:8080" [[store]] name = "witchbroom" path = "~/.local/state/witchbroom/observations.db" [[store.series]] series = "soak.coverage_edges" label = "Coverage reached" unit = "edges" [[store.series]] series = "cache.size_bytes" label = "Cache size" "#, ); let cfg = Config::load(&path).unwrap(); let store = &cfg.stores[0]; assert!( !store.path.starts_with("~"), "a store path expands like a source path: {}", store.path.display() ); assert_eq!(store.series.len(), 2); assert_eq!(store.series[0].unit.as_deref(), Some("edges")); // The unit is genuinely optional: some numbers count nothing in // particular. assert_eq!(store.series[1].unit, None); assert_eq!( Config::store_interval(store).as_secs(), DEFAULT_STORE_POLL_SECS ); } #[test] fn a_config_with_no_store_block_is_fine() { let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://fw13:8080" "#, ); assert!(Config::load(&path).unwrap().stores.is_empty()); } #[test] fn a_store_with_no_series_is_refused() { // Not a store showing nothing: a config that can never say anything, // because an unnamed series is not rendered. let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://fw13:8080" [[store]] name = "witchbroom" path = "/tmp/x.db" "#, ); let err = Config::load(&path).unwrap_err().to_string(); assert!(err.contains("store.series"), "{err}"); } #[test] fn a_duplicate_store_or_series_name_is_refused() { let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://fw13:8080" [[store]] name = "witchbroom" path = "/tmp/x.db" [[store.series]] series = "a" label = "A" [[store.series]] series = "a" label = "Also A" "#, ); let err = Config::load(&path).unwrap_err().to_string(); assert!(err.contains("twice"), "{err}"); let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://fw13:8080" [[store]] name = "witchbroom" path = "/tmp/x.db" [[store.series]] series = "a" label = "A" [[store]] name = "witchbroom" path = "/tmp/y.db" [[store.series]] series = "b" label = "B" "#, ); let err = Config::load(&path).unwrap_err().to_string(); assert!(err.contains("duplicate store"), "{err}"); } /// The batch-producer shape. witchbroom runs for hours and stops, so it has /// nothing to poll; the file it leaves behind is the source. #[test] fn a_source_can_be_a_file_on_disk() { let (_dir, path) = write( r#" [[source]] name = "witchbroom" path = "/var/lib/witchbroom/status.json" poll_secs = 60 stale_after_secs = 129600 "#, ); let cfg = Config::load(&path).unwrap(); assert_eq!( cfg.sources[0].origin(), Origin::File("/var/lib/witchbroom/status.json".into()) ); // A nightly producer is not stale at 60 seconds, and the per-source // override is what makes one legible next to a daemon polled at 5. assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 129_600); } /// The path an operator actually writes. Left literal it resolves to `./~` /// and the tab reads as a producer that never ran. #[test] fn a_leading_tilde_in_a_path_expands() { let home = std::env::var("HOME").unwrap(); let (_dir, path) = write( r#" [[source]] name = "witchbroom" path = "~/.local/state/witchbroom/status.json" "#, ); let cfg = Config::load(&path).unwrap(); assert_eq!( cfg.sources[0].origin(), Origin::File( std::path::PathBuf::from(home).join(".local/state/witchbroom/status.json") ) ); } /// One payload, one origin. Both set is a config whose meaning has to be /// guessed, and either guess makes half of it dead text. #[test] fn a_source_sets_exactly_one_of_url_and_path() { let (_dir, path) = write( r#" [[source]] name = "witchbroom" url = "http://astra:9000" path = "/var/lib/witchbroom/status.json" "#, ); assert!( Config::load(&path) .unwrap_err() .to_string() .contains("one payload, one origin") ); let (_dir, path) = write( r#" [[source]] name = "witchbroom" "#, ); assert!( Config::load(&path) .unwrap_err() .to_string() .contains("neither url nor path") ); } /// Both of these are quiet when ignored, and both read as a promise the /// file source cannot keep: authenticated, or drivable. #[test] fn a_file_source_refuses_a_token_and_refuses_actions() { let (_dir, path) = write( r#" [[source]] name = "witchbroom" path = "/var/lib/witchbroom/status.json" token_env = "WITCHBROOM_TOKEN" "#, ); assert!( Config::load(&path) .unwrap_err() .to_string() .contains("bearer token") ); let (_dir, path) = write( r#" [[source]] name = "witchbroom" path = "/var/lib/witchbroom/status.json" allow_actions = true "#, ); assert!( Config::load(&path) .unwrap_err() .to_string() .contains("no base to resolve") ); } #[test] fn a_per_source_staleness_overrides_the_default() { let (_dir, path) = write( r#" stale_after_secs = 60 [[source]] name = "sando" url = "http://fw13:8080" [[source]] name = "pom" url = "http://pom:9000" stale_after_secs = 600 "#, ); let cfg = Config::load(&path).unwrap(); assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 60); assert_eq!(cfg.stale_after(&cfg.sources[1]).num_seconds(), 600); } #[test] fn an_absent_theme_key_follows_the_terminal_and_an_id_pins_one() { let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://fw13:8080" "#, ); let cfg = Config::load(&path).unwrap(); assert_eq!( cfg.theme_selection(), makeover::ThemeSelection::Follow, "a config that says nothing about theming must track the terminal, \ not pin whatever the first run guessed", ); let (_dir, path) = write( r#" theme = "carbonfox" [[source]] name = "sando" url = "http://fw13:8080" "#, ); assert_eq!( Config::load(&path).unwrap().theme_selection(), makeover::ThemeSelection::Fixed("carbonfox".into()), ); } #[test] fn an_empty_config_is_rejected_rather_than_showing_an_empty_screen() { let (_dir, path) = write("stale_after_secs = 60\n"); assert!( Config::load(&path) .unwrap_err() .to_string() .contains("no [[source]]") ); } #[test] fn duplicate_source_names_are_rejected() { let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://a" [[source]] name = "sando" url = "http://b" "#, ); assert!( Config::load(&path) .unwrap_err() .to_string() .contains("duplicate") ); } #[test] fn a_url_without_a_scheme_is_rejected() { let (_dir, path) = write( r#" [[source]] name = "sando" url = "fw13:8080" "#, ); assert!( Config::load(&path) .unwrap_err() .to_string() .contains("http://") ); } #[test] fn actions_are_off_unless_a_source_opts_in() { let (_dir, path) = write( r#" [[source]] name = "pom" url = "http://pom:9000" [[source]] name = "sando" url = "http://fw13:8080" allow_actions = true "#, ); let cfg = Config::load(&path).unwrap(); assert!( !cfg.sources[0].allow_actions, "a source must not be able to move production by default" ); assert!(cfg.sources[1].allow_actions); } #[test] fn a_token_is_read_from_the_environment_never_the_file() { let (_dir, path) = write( r#" [[source]] name = "sando" url = "http://fw13:8080" token_env = "OPS_VIEWER_TEST_TOKEN" "#, ); let cfg = Config::load(&path).unwrap(); // Unset -> no token rather than an empty string masquerading as one. unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") }; assert_eq!(cfg.sources[0].token(), None); unsafe { std::env::set_var("OPS_VIEWER_TEST_TOKEN", "s3cr3t") }; assert_eq!(cfg.sources[0].token().as_deref(), Some("s3cr3t")); unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") }; } }