Skip to main content

max / makenotwork

18.3 KB · 574 lines History Blame Raw
1 //! What magicmirror 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 magicmirror 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, PathBuf};
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 /// The theme to render in: `"system"` to follow the terminal, or a theme
30 /// id to pin.
31 ///
32 /// Unprefixed `theme`, which is the family convention's key name (wiki
33 /// `makeover-app-convention`) — this file is already scoped to magicmirror,
34 /// so a `magicmirror-theme` would be saying it twice. Absent reads as "follow
35 /// the terminal" rather than as a pin on whatever the first run guessed.
36 #[serde(default)]
37 pub theme: Option<String>,
38 #[serde(default, rename = "source")]
39 pub sources: Vec<Source>,
40 }
41
42 /// Where one source's payload comes from.
43 ///
44 /// Two shapes, because two shapes of producer exist. A daemon is polled over
45 /// HTTP. A batch producer runs, writes, and stops, so it has nothing to poll:
46 /// witchbroom's sweep takes hours and then the machine is idle until tomorrow.
47 /// Making it grow an HTTP listener to be visible would be paying a daemon's
48 /// cost for a producer that is not one, and the panel already keeps the last
49 /// payload across a failed poll, which is exactly the property a batch producer
50 /// needs. So a source can be a file, read on the same tick.
51 #[derive(Debug, Clone, PartialEq, Eq)]
52 pub(crate) enum Origin {
53 /// The base URL with `/status.json` appended.
54 Http(String),
55 File(PathBuf),
56 }
57
58 #[derive(Debug, Clone, Deserialize)]
59 pub(crate) struct Source {
60 /// Tab label, and the `source` name the payload should carry.
61 pub name: String,
62 /// Base URL of a daemon to poll. `/status.json` is appended. Exactly one of
63 /// this and `path` is set; `validate` refuses anything else.
64 #[serde(default)]
65 pub url: Option<String>,
66 /// A payload on disk, rewritten by a batch producer at the end of its run.
67 ///
68 /// A leading `~` expands at load. Nothing else about it is interpreted: the
69 /// full filename is given, not a base to append `status.json` to, because a
70 /// producer writing one file has no reason to own a directory.
71 #[serde(default)]
72 pub path: Option<PathBuf>,
73 /// Name of the environment variable holding this source's bearer token.
74 ///
75 /// The token is named, never inlined: this file describes topology and has
76 /// every reason to be readable, while the daemons already take their tokens
77 /// from the environment (`SANDO_API_TOKEN`, `BENTO_API_TOKEN`). A config
78 /// format that invites pasting a prod token into a file is one that ends
79 /// with a prod token in a file.
80 #[serde(default)]
81 pub token_env: Option<String>,
82 #[serde(default = "default_poll")]
83 pub poll_secs: u64,
84 #[serde(default)]
85 pub stale_after_secs: Option<u64>,
86 /// Whether this source's declared actions may be fired from magicmirror.
87 ///
88 /// Defaults off. magicmirror is a display first, and some declared actions
89 /// (sando's `promote-b`, `rollback-b`) move production. Firing is opt-in per
90 /// source so that pointing magicmirror at a daemon can never move it by
91 /// accident: the operator turns a source's actions on deliberately, the same
92 /// place they already name its token. A source with this off still renders
93 /// its actions; it just refuses to issue them.
94 #[serde(default)]
95 pub allow_actions: bool,
96 }
97
98 /// `~` in a configured path, against `$HOME`.
99 ///
100 /// A file source's path is written by hand and the obvious one to write is
101 /// `~/.local/state/witchbroom/status.json`. Without this it resolves to a
102 /// literal `./~` and the tab reads as a producer that never ran.
103 fn expand_tilde(p: &Path) -> PathBuf {
104 let Ok(rest) = p.strip_prefix("~") else {
105 return p.to_path_buf();
106 };
107 match std::env::var_os("HOME") {
108 Some(home) => PathBuf::from(home).join(rest),
109 None => p.to_path_buf(),
110 }
111 }
112
113 fn default_poll() -> u64 {
114 DEFAULT_POLL_SECS
115 }
116
117 fn default_stale() -> u64 {
118 DEFAULT_STALE_SECS
119 }
120
121 impl Source {
122 /// Where this source's payload comes from.
123 ///
124 /// Total, because `validate` has already refused every config that would
125 /// make it partial. A source with neither is rejected at load rather than
126 /// becoming a tab that can never say anything.
127 pub(crate) fn origin(&self) -> Origin {
128 match (&self.url, &self.path) {
129 (Some(url), _) => Origin::Http(format!("{}/status.json", url.trim_end_matches('/'))),
130 (None, Some(path)) => Origin::File(path.clone()),
131 (None, None) => unreachable!("validate rejects a source with neither url nor path"),
132 }
133 }
134
135 pub(crate) fn poll_interval(&self) -> Duration {
136 Duration::from_secs(self.poll_secs.max(1))
137 }
138
139 /// Resolve a declared action's `url` against this source's base.
140 ///
141 /// An action carries a path (`/rollback/b`); a producer that instead gives a
142 /// full URL is honored as-is, so a daemon can point an action at somewhere
143 /// other than itself without magicmirror second-guessing it.
144 ///
145 /// A file source is refused `allow_actions` at load and the UI refuses to
146 /// fire without it, so the no-base arm is unreachable rather than a policy:
147 /// a path is not something an action's URL can be resolved against.
148 pub(crate) fn action_url(&self, path: &str) -> String {
149 if path.starts_with("http://") || path.starts_with("https://") {
150 return path.to_string();
151 }
152 let Some(base) = self.url.as_deref() else {
153 return path.to_string();
154 };
155 format!(
156 "{}/{}",
157 base.trim_end_matches('/'),
158 path.trim_start_matches('/')
159 )
160 }
161
162 /// Resolve the bearer token from the environment, if one is named.
163 pub(crate) fn token(&self) -> Option<String> {
164 self.token_env
165 .as_deref()
166 .and_then(|name| std::env::var(name).ok())
167 .filter(|t| !t.is_empty())
168 }
169 }
170
171 impl Config {
172 pub(crate) fn load(path: &Path) -> Result<Self> {
173 let raw = std::fs::read_to_string(path)
174 .with_context(|| format!("reading magicmirror config at {}", path.display()))?;
175 let mut cfg: Config = toml::from_str(&raw)
176 .with_context(|| format!("parsing magicmirror config at {}", path.display()))?;
177 for source in &mut cfg.sources {
178 if let Some(p) = &source.path {
179 source.path = Some(expand_tilde(p));
180 }
181 }
182 cfg.validate()?;
183 Ok(cfg)
184 }
185
186 fn validate(&self) -> Result<()> {
187 anyhow::ensure!(
188 !self.sources.is_empty(),
189 "no [[source]] entries: magicmirror would have nothing to show"
190 );
191 let mut seen = std::collections::HashSet::new();
192 for source in &self.sources {
193 anyhow::ensure!(
194 seen.insert(source.name.as_str()),
195 "duplicate source name {:?}: tabs would be ambiguous",
196 source.name
197 );
198 match (&source.url, &source.path) {
199 (Some(url), None) => anyhow::ensure!(
200 url.starts_with("http://") || url.starts_with("https://"),
201 "source {:?} url must start with http:// or https://",
202 source.name
203 ),
204 (None, Some(_)) => {
205 // Both are things a URL source has and a file has no
206 // equivalent of, and both are quiet when ignored. A token
207 // silently unused reads as a source that is authenticated;
208 // `allow_actions = true` silently unused reads as a source
209 // that can be driven. Refusing says which it is.
210 anyhow::ensure!(
211 source.token_env.is_none(),
212 "source {:?} is a file and cannot present a bearer token",
213 source.name
214 );
215 anyhow::ensure!(
216 !source.allow_actions,
217 "source {:?} is a file: an action is a URL and there is no base to \
218 resolve one against",
219 source.name
220 );
221 }
222 (Some(_), Some(_)) => anyhow::bail!(
223 "source {:?} sets both url and path: one payload, one origin",
224 source.name
225 ),
226 (None, None) => anyhow::bail!(
227 "source {:?} sets neither url nor path: it could never say anything",
228 source.name
229 ),
230 }
231 }
232 Ok(())
233 }
234
235 /// What the operator chose, which is not the same as what is rendered: a
236 /// standing "follow the terminal" resolves differently as the terminal
237 /// changes, and a pinned id does not.
238 pub(crate) fn theme_selection(&self) -> makeover::ThemeSelection {
239 makeover::ThemeSelection::parse(self.theme.as_deref())
240 }
241
242 /// Staleness limit for one source: its own, else the global default.
243 pub(crate) fn stale_after(&self, source: &Source) -> chrono::TimeDelta {
244 let secs = source.stale_after_secs.unwrap_or(self.stale_after_secs);
245 chrono::TimeDelta::seconds(secs as i64)
246 }
247 }
248
249 #[cfg(test)]
250 mod tests {
251 use super::*;
252
253 fn write(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
254 let dir = tempfile::tempdir().unwrap();
255 let path = dir.path().join("magicmirror.toml");
256 std::fs::write(&path, body).unwrap();
257 (dir, path)
258 }
259
260 #[test]
261 fn a_minimal_source_gets_sensible_defaults() {
262 let (_dir, path) = write(
263 r#"
264 [[source]]
265 name = "sando"
266 url = "http://fw13:8080"
267 "#,
268 );
269 let cfg = Config::load(&path).unwrap();
270 assert_eq!(
271 cfg.sources[0].origin(),
272 Origin::Http("http://fw13:8080/status.json".into())
273 );
274 assert_eq!(cfg.sources[0].poll_interval().as_secs(), DEFAULT_POLL_SECS);
275 assert_eq!(
276 cfg.stale_after(&cfg.sources[0]),
277 chrono::TimeDelta::seconds(DEFAULT_STALE_SECS as i64)
278 );
279 }
280
281 #[test]
282 fn an_action_path_resolves_against_the_base_and_a_full_url_is_left_alone() {
283 let source: Source = toml::from_str(
284 r#"
285 name = "sando"
286 url = "http://fw13:8080/"
287 "#,
288 )
289 .unwrap();
290 assert_eq!(
291 source.action_url("/rollback/b"),
292 "http://fw13:8080/rollback/b"
293 );
294 assert_eq!(
295 source.action_url("rollback/b"),
296 "http://fw13:8080/rollback/b"
297 );
298 assert_eq!(
299 source.action_url("https://elsewhere/x"),
300 "https://elsewhere/x"
301 );
302 }
303
304 #[test]
305 fn a_trailing_slash_does_not_double_up() {
306 let (_dir, path) = write(
307 r#"
308 [[source]]
309 name = "bento"
310 url = "http://fw13:8090/"
311 "#,
312 );
313 let cfg = Config::load(&path).unwrap();
314 assert_eq!(
315 cfg.sources[0].origin(),
316 Origin::Http("http://fw13:8090/status.json".into())
317 );
318 }
319
320 /// The batch-producer shape. witchbroom runs for hours and stops, so it has
321 /// nothing to poll; the file it leaves behind is the source.
322 #[test]
323 fn a_source_can_be_a_file_on_disk() {
324 let (_dir, path) = write(
325 r#"
326 [[source]]
327 name = "witchbroom"
328 path = "/var/lib/witchbroom/status.json"
329 poll_secs = 60
330 stale_after_secs = 129600
331 "#,
332 );
333 let cfg = Config::load(&path).unwrap();
334 assert_eq!(
335 cfg.sources[0].origin(),
336 Origin::File("/var/lib/witchbroom/status.json".into())
337 );
338 // A nightly producer is not stale at 60 seconds, and the per-source
339 // override is what makes one legible next to a daemon polled at 5.
340 assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 129_600);
341 }
342
343 /// The path an operator actually writes. Left literal it resolves to `./~`
344 /// and the tab reads as a producer that never ran.
345 #[test]
346 fn a_leading_tilde_in_a_path_expands() {
347 let home = std::env::var("HOME").unwrap();
348 let (_dir, path) = write(
349 r#"
350 [[source]]
351 name = "witchbroom"
352 path = "~/.local/state/witchbroom/status.json"
353 "#,
354 );
355 let cfg = Config::load(&path).unwrap();
356 assert_eq!(
357 cfg.sources[0].origin(),
358 Origin::File(
359 std::path::PathBuf::from(home).join(".local/state/witchbroom/status.json")
360 )
361 );
362 }
363
364 /// One payload, one origin. Both set is a config whose meaning has to be
365 /// guessed, and either guess makes half of it dead text.
366 #[test]
367 fn a_source_sets_exactly_one_of_url_and_path() {
368 let (_dir, path) = write(
369 r#"
370 [[source]]
371 name = "witchbroom"
372 url = "http://astra:9000"
373 path = "/var/lib/witchbroom/status.json"
374 "#,
375 );
376 assert!(
377 Config::load(&path)
378 .unwrap_err()
379 .to_string()
380 .contains("one payload, one origin")
381 );
382
383 let (_dir, path) = write(
384 r#"
385 [[source]]
386 name = "witchbroom"
387 "#,
388 );
389 assert!(
390 Config::load(&path)
391 .unwrap_err()
392 .to_string()
393 .contains("neither url nor path")
394 );
395 }
396
397 /// Both of these are quiet when ignored, and both read as a promise the
398 /// file source cannot keep: authenticated, or drivable.
399 #[test]
400 fn a_file_source_refuses_a_token_and_refuses_actions() {
401 let (_dir, path) = write(
402 r#"
403 [[source]]
404 name = "witchbroom"
405 path = "/var/lib/witchbroom/status.json"
406 token_env = "WITCHBROOM_TOKEN"
407 "#,
408 );
409 assert!(
410 Config::load(&path)
411 .unwrap_err()
412 .to_string()
413 .contains("bearer token")
414 );
415
416 let (_dir, path) = write(
417 r#"
418 [[source]]
419 name = "witchbroom"
420 path = "/var/lib/witchbroom/status.json"
421 allow_actions = true
422 "#,
423 );
424 assert!(
425 Config::load(&path)
426 .unwrap_err()
427 .to_string()
428 .contains("no base to resolve")
429 );
430 }
431
432 #[test]
433 fn a_per_source_staleness_overrides_the_default() {
434 let (_dir, path) = write(
435 r#"
436 stale_after_secs = 60
437
438 [[source]]
439 name = "sando"
440 url = "http://fw13:8080"
441
442 [[source]]
443 name = "pom"
444 url = "http://pom:9000"
445 stale_after_secs = 600
446 "#,
447 );
448 let cfg = Config::load(&path).unwrap();
449 assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 60);
450 assert_eq!(cfg.stale_after(&cfg.sources[1]).num_seconds(), 600);
451 }
452
453 #[test]
454 fn an_absent_theme_key_follows_the_terminal_and_an_id_pins_one() {
455 let (_dir, path) = write(
456 r#"
457 [[source]]
458 name = "sando"
459 url = "http://fw13:8080"
460 "#,
461 );
462 let cfg = Config::load(&path).unwrap();
463 assert_eq!(
464 cfg.theme_selection(),
465 makeover::ThemeSelection::Follow,
466 "a config that says nothing about theming must track the terminal, \
467 not pin whatever the first run guessed",
468 );
469
470 let (_dir, path) = write(
471 r#"
472 theme = "carbonfox"
473
474 [[source]]
475 name = "sando"
476 url = "http://fw13:8080"
477 "#,
478 );
479 assert_eq!(
480 Config::load(&path).unwrap().theme_selection(),
481 makeover::ThemeSelection::Fixed("carbonfox".into()),
482 );
483 }
484
485 #[test]
486 fn an_empty_config_is_rejected_rather_than_showing_an_empty_screen() {
487 let (_dir, path) = write("stale_after_secs = 60\n");
488 assert!(
489 Config::load(&path)
490 .unwrap_err()
491 .to_string()
492 .contains("no [[source]]")
493 );
494 }
495
496 #[test]
497 fn duplicate_source_names_are_rejected() {
498 let (_dir, path) = write(
499 r#"
500 [[source]]
501 name = "sando"
502 url = "http://a"
503 [[source]]
504 name = "sando"
505 url = "http://b"
506 "#,
507 );
508 assert!(
509 Config::load(&path)
510 .unwrap_err()
511 .to_string()
512 .contains("duplicate")
513 );
514 }
515
516 #[test]
517 fn a_url_without_a_scheme_is_rejected() {
518 let (_dir, path) = write(
519 r#"
520 [[source]]
521 name = "sando"
522 url = "fw13:8080"
523 "#,
524 );
525 assert!(
526 Config::load(&path)
527 .unwrap_err()
528 .to_string()
529 .contains("http://")
530 );
531 }
532
533 #[test]
534 fn actions_are_off_unless_a_source_opts_in() {
535 let (_dir, path) = write(
536 r#"
537 [[source]]
538 name = "pom"
539 url = "http://pom:9000"
540
541 [[source]]
542 name = "sando"
543 url = "http://fw13:8080"
544 allow_actions = true
545 "#,
546 );
547 let cfg = Config::load(&path).unwrap();
548 assert!(
549 !cfg.sources[0].allow_actions,
550 "a source must not be able to move production by default"
551 );
552 assert!(cfg.sources[1].allow_actions);
553 }
554
555 #[test]
556 fn a_token_is_read_from_the_environment_never_the_file() {
557 let (_dir, path) = write(
558 r#"
559 [[source]]
560 name = "sando"
561 url = "http://fw13:8080"
562 token_env = "OPS_VIEWER_TEST_TOKEN"
563 "#,
564 );
565 let cfg = Config::load(&path).unwrap();
566 // Unset -> no token rather than an empty string masquerading as one.
567 unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") };
568 assert_eq!(cfg.sources[0].token(), None);
569 unsafe { std::env::set_var("OPS_VIEWER_TEST_TOKEN", "s3cr3t") };
570 assert_eq!(cfg.sources[0].token().as_deref(), Some("s3cr3t"));
571 unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") };
572 }
573 }
574