Skip to main content

max / makenotwork

9.4 KB · 319 lines History Blame Raw
1 //! What the viewer is pointed at.
2 //!
3 //! Adding a service is a config edit and nothing else — no code change, no new
4 //! tab type, no widget. That property is what makes the viewer modular rather
5 //! than merely tabbed, and it is worth defending: the moment a source needs a
6 //! special case here, the shell has started learning domain vocabulary.
7
8 use std::path::Path;
9 use std::time::Duration;
10
11 use anyhow::{Context, Result};
12 use serde::Deserialize;
13
14 /// Default poll interval. Matches what the Sando TUI already did.
15 const DEFAULT_POLL_SECS: u64 = 5;
16
17 /// Default age past which a source's answer stops counting as current.
18 ///
19 /// Deliberately a small multiple of the poll interval rather than something
20 /// generous: the whole point is that a source which quietly stopped updating
21 /// looks different from one that is fine.
22 const DEFAULT_STALE_SECS: u64 = 60;
23
24 #[derive(Debug, Clone, Deserialize)]
25 pub(crate) struct Config {
26 /// Fallback for any source that does not set its own.
27 #[serde(default = "default_stale")]
28 pub stale_after_secs: u64,
29 #[serde(default, rename = "source")]
30 pub sources: Vec<Source>,
31 }
32
33 #[derive(Debug, Clone, Deserialize)]
34 pub(crate) struct Source {
35 /// Tab label, and the `source` name the payload should carry.
36 pub name: String,
37 /// Base URL. `/status.json` is appended.
38 pub url: String,
39 /// Name of the environment variable holding this source's bearer token.
40 ///
41 /// The token is named, never inlined: this file describes topology and has
42 /// every reason to be readable, while the daemons already take their tokens
43 /// from the environment (`SANDO_API_TOKEN`, `BENTO_API_TOKEN`). A config
44 /// format that invites pasting a prod token into a file is one that ends
45 /// with a prod token in a file.
46 #[serde(default)]
47 pub token_env: Option<String>,
48 #[serde(default = "default_poll")]
49 pub poll_secs: u64,
50 #[serde(default)]
51 pub stale_after_secs: Option<u64>,
52 /// Whether this source's declared actions may be fired from the viewer.
53 ///
54 /// Defaults off. A viewer is a display first, and some declared actions
55 /// (sando's `promote-b`, `rollback-b`) move production. Firing is opt-in per
56 /// source so that pointing the viewer at a daemon can never move it by
57 /// accident: the operator turns a source's actions on deliberately, the same
58 /// place they already name its token. A source with this off still renders
59 /// its actions; it just refuses to issue them.
60 #[serde(default)]
61 pub allow_actions: bool,
62 }
63
64 fn default_poll() -> u64 {
65 DEFAULT_POLL_SECS
66 }
67
68 fn default_stale() -> u64 {
69 DEFAULT_STALE_SECS
70 }
71
72 impl Source {
73 pub(crate) fn status_url(&self) -> String {
74 format!("{}/status.json", self.url.trim_end_matches('/'))
75 }
76
77 pub(crate) fn poll_interval(&self) -> Duration {
78 Duration::from_secs(self.poll_secs.max(1))
79 }
80
81 /// Resolve a declared action's `url` against this source's base.
82 ///
83 /// An action carries a path (`/rollback/b`); a producer that instead gives a
84 /// full URL is honored as-is, so a daemon can point an action at somewhere
85 /// other than itself without the viewer second-guessing it.
86 pub(crate) fn action_url(&self, path: &str) -> String {
87 if path.starts_with("http://") || path.starts_with("https://") {
88 return path.to_string();
89 }
90 format!(
91 "{}/{}",
92 self.url.trim_end_matches('/'),
93 path.trim_start_matches('/')
94 )
95 }
96
97 /// Resolve the bearer token from the environment, if one is named.
98 pub(crate) fn token(&self) -> Option<String> {
99 self.token_env
100 .as_deref()
101 .and_then(|name| std::env::var(name).ok())
102 .filter(|t| !t.is_empty())
103 }
104 }
105
106 impl Config {
107 pub(crate) fn load(path: &Path) -> Result<Self> {
108 let raw = std::fs::read_to_string(path)
109 .with_context(|| format!("reading viewer config at {}", path.display()))?;
110 let cfg: Config = toml::from_str(&raw)
111 .with_context(|| format!("parsing viewer config at {}", path.display()))?;
112 cfg.validate()?;
113 Ok(cfg)
114 }
115
116 fn validate(&self) -> Result<()> {
117 anyhow::ensure!(
118 !self.sources.is_empty(),
119 "no [[source]] entries: the viewer would have nothing to show"
120 );
121 let mut seen = std::collections::HashSet::new();
122 for source in &self.sources {
123 anyhow::ensure!(
124 seen.insert(source.name.as_str()),
125 "duplicate source name {:?}: tabs would be ambiguous",
126 source.name
127 );
128 anyhow::ensure!(
129 source.url.starts_with("http://") || source.url.starts_with("https://"),
130 "source {:?} url must start with http:// or https://",
131 source.name
132 );
133 }
134 Ok(())
135 }
136
137 /// Staleness limit for one source: its own, else the global default.
138 pub(crate) fn stale_after(&self, source: &Source) -> chrono::TimeDelta {
139 let secs = source.stale_after_secs.unwrap_or(self.stale_after_secs);
140 chrono::TimeDelta::seconds(secs as i64)
141 }
142 }
143
144 #[cfg(test)]
145 mod tests {
146 use super::*;
147
148 fn write(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
149 let dir = tempfile::tempdir().unwrap();
150 let path = dir.path().join("viewer.toml");
151 std::fs::write(&path, body).unwrap();
152 (dir, path)
153 }
154
155 #[test]
156 fn a_minimal_source_gets_sensible_defaults() {
157 let (_dir, path) = write(
158 r#"
159 [[source]]
160 name = "sando"
161 url = "http://fw13:8080"
162 "#,
163 );
164 let cfg = Config::load(&path).unwrap();
165 assert_eq!(cfg.sources[0].status_url(), "http://fw13:8080/status.json");
166 assert_eq!(cfg.sources[0].poll_interval().as_secs(), DEFAULT_POLL_SECS);
167 assert_eq!(
168 cfg.stale_after(&cfg.sources[0]),
169 chrono::TimeDelta::seconds(DEFAULT_STALE_SECS as i64)
170 );
171 }
172
173 #[test]
174 fn an_action_path_resolves_against_the_base_and_a_full_url_is_left_alone() {
175 let source: Source = toml::from_str(
176 r#"
177 name = "sando"
178 url = "http://fw13:8080/"
179 "#,
180 )
181 .unwrap();
182 assert_eq!(
183 source.action_url("/rollback/b"),
184 "http://fw13:8080/rollback/b"
185 );
186 assert_eq!(
187 source.action_url("rollback/b"),
188 "http://fw13:8080/rollback/b"
189 );
190 assert_eq!(
191 source.action_url("https://elsewhere/x"),
192 "https://elsewhere/x"
193 );
194 }
195
196 #[test]
197 fn a_trailing_slash_does_not_double_up() {
198 let (_dir, path) = write(
199 r#"
200 [[source]]
201 name = "bento"
202 url = "http://fw13:8090/"
203 "#,
204 );
205 let cfg = Config::load(&path).unwrap();
206 assert_eq!(cfg.sources[0].status_url(), "http://fw13:8090/status.json");
207 }
208
209 #[test]
210 fn a_per_source_staleness_overrides_the_default() {
211 let (_dir, path) = write(
212 r#"
213 stale_after_secs = 60
214
215 [[source]]
216 name = "sando"
217 url = "http://fw13:8080"
218
219 [[source]]
220 name = "pom"
221 url = "http://pom:9000"
222 stale_after_secs = 600
223 "#,
224 );
225 let cfg = Config::load(&path).unwrap();
226 assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 60);
227 assert_eq!(cfg.stale_after(&cfg.sources[1]).num_seconds(), 600);
228 }
229
230 #[test]
231 fn an_empty_config_is_rejected_rather_than_showing_an_empty_screen() {
232 let (_dir, path) = write("stale_after_secs = 60\n");
233 assert!(
234 Config::load(&path)
235 .unwrap_err()
236 .to_string()
237 .contains("no [[source]]")
238 );
239 }
240
241 #[test]
242 fn duplicate_source_names_are_rejected() {
243 let (_dir, path) = write(
244 r#"
245 [[source]]
246 name = "sando"
247 url = "http://a"
248 [[source]]
249 name = "sando"
250 url = "http://b"
251 "#,
252 );
253 assert!(
254 Config::load(&path)
255 .unwrap_err()
256 .to_string()
257 .contains("duplicate")
258 );
259 }
260
261 #[test]
262 fn a_url_without_a_scheme_is_rejected() {
263 let (_dir, path) = write(
264 r#"
265 [[source]]
266 name = "sando"
267 url = "fw13:8080"
268 "#,
269 );
270 assert!(
271 Config::load(&path)
272 .unwrap_err()
273 .to_string()
274 .contains("http://")
275 );
276 }
277
278 #[test]
279 fn actions_are_off_unless_a_source_opts_in() {
280 let (_dir, path) = write(
281 r#"
282 [[source]]
283 name = "pom"
284 url = "http://pom:9000"
285
286 [[source]]
287 name = "sando"
288 url = "http://fw13:8080"
289 allow_actions = true
290 "#,
291 );
292 let cfg = Config::load(&path).unwrap();
293 assert!(
294 !cfg.sources[0].allow_actions,
295 "a source must not be able to move production by default"
296 );
297 assert!(cfg.sources[1].allow_actions);
298 }
299
300 #[test]
301 fn a_token_is_read_from_the_environment_never_the_file() {
302 let (_dir, path) = write(
303 r#"
304 [[source]]
305 name = "sando"
306 url = "http://fw13:8080"
307 token_env = "OPS_VIEWER_TEST_TOKEN"
308 "#,
309 );
310 let cfg = Config::load(&path).unwrap();
311 // Unset -> no token rather than an empty string masquerading as one.
312 unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") };
313 assert_eq!(cfg.sources[0].token(), None);
314 unsafe { std::env::set_var("OPS_VIEWER_TEST_TOKEN", "s3cr3t") };
315 assert_eq!(cfg.sources[0].token().as_deref(), Some("s3cr3t"));
316 unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") };
317 }
318 }
319