//! 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; #[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, } /// 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 } 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 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 ), } } Ok(()) } /// 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()) ); } /// 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") }; } }