//! What the viewer 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 the viewer 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; 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, #[serde(default, rename = "source")] pub sources: Vec, } #[derive(Debug, Clone, Deserialize)] pub(crate) struct Source { /// Tab label, and the `source` name the payload should carry. pub name: String, /// Base URL. `/status.json` is appended. pub url: String, /// 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 the viewer. /// /// Defaults off. A viewer 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 the viewer 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, } fn default_poll() -> u64 { DEFAULT_POLL_SECS } fn default_stale() -> u64 { DEFAULT_STALE_SECS } impl Source { pub(crate) fn status_url(&self) -> String { format!("{}/status.json", self.url.trim_end_matches('/')) } 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 the viewer second-guessing it. pub(crate) fn action_url(&self, path: &str) -> String { if path.starts_with("http://") || path.starts_with("https://") { return path.to_string(); } format!( "{}/{}", self.url.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 viewer config at {}", path.display()))?; let cfg: Config = toml::from_str(&raw) .with_context(|| format!("parsing viewer config at {}", path.display()))?; cfg.validate()?; Ok(cfg) } fn validate(&self) -> Result<()> { anyhow::ensure!( !self.sources.is_empty(), "no [[source]] entries: the viewer 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 ); anyhow::ensure!( source.url.starts_with("http://") || source.url.starts_with("https://"), "source {:?} url must start with http:// or https://", source.name ); } Ok(()) } /// 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("viewer.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].status_url(), "http://fw13:8080/status.json"); 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].status_url(), "http://fw13:8090/status.json"); } #[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_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") }; } }