max / makenotwork
5 files changed,
+376 insertions,
-66 deletions
| @@ -2898,38 +2898,6 @@ | |||
| 2898 | 2898 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2899 | 2899 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 2900 | 2900 | ||
| 2901 | - | [[patch.unused]] | |
| 2902 | - | name = "docengine" | |
| 2903 | - | version = "0.7.0" | |
| 2904 | - | ||
| 2905 | - | [[patch.unused]] | |
| 2906 | - | name = "kberg" | |
| 2907 | - | version = "0.1.0" | |
| 2908 | - | ||
| 2909 | - | [[patch.unused]] | |
| 2910 | - | name = "painhours" | |
| 2911 | - | version = "0.1.0" | |
| 2912 | - | ||
| 2913 | - | [[patch.unused]] | |
| 2914 | - | name = "tagtree" | |
| 2915 | - | version = "0.4.0" | |
| 2916 | - | ||
| 2917 | - | [[patch.unused]] | |
| 2918 | - | name = "makeover-build" | |
| 2919 | - | version = "0.26.0" | |
| 2920 | - | ||
| 2921 | - | [[patch.unused]] | |
| 2922 | - | name = "makeover-immediate" | |
| 2923 | - | version = "0.15.0" | |
| 2924 | - | ||
| 2925 | - | [[patch.unused]] | |
| 2926 | - | name = "makeover-touch" | |
| 2927 | - | version = "0.10.0" | |
| 2928 | - | ||
| 2929 | - | [[patch.unused]] | |
| 2930 | - | name = "makeover-webview" | |
| 2931 | - | version = "0.33.0" | |
| 2932 | - | ||
| 2933 | 2901 | [[patch.unused]] | |
| 2934 | 2902 | name = "quasi-axum" | |
| 2935 | 2903 | version = "0.1.0" | |
| @@ -2954,6 +2922,22 @@ | |||
| 2954 | 2922 | name = "quasi-webview" | |
| 2955 | 2923 | version = "0.1.0" | |
| 2956 | 2924 | ||
| 2925 | + | [[patch.unused]] | |
| 2926 | + | name = "docengine" | |
| 2927 | + | version = "0.7.0" | |
| 2928 | + | ||
| 2929 | + | [[patch.unused]] | |
| 2930 | + | name = "kberg" | |
| 2931 | + | version = "0.1.0" | |
| 2932 | + | ||
| 2933 | + | [[patch.unused]] | |
| 2934 | + | name = "painhours" | |
| 2935 | + | version = "0.1.0" | |
| 2936 | + | ||
| 2937 | + | [[patch.unused]] | |
| 2938 | + | name = "tagtree" | |
| 2939 | + | version = "0.4.0" | |
| 2940 | + | ||
| 2957 | 2941 | [[patch.unused]] | |
| 2958 | 2942 | name = "synckit-client" | |
| 2959 | 2943 | version = "0.8.0" | |
| @@ -2961,3 +2945,19 @@ | |||
| 2961 | 2945 | [[patch.unused]] | |
| 2962 | 2946 | name = "synckit-config" | |
| 2963 | 2947 | version = "0.2.0" | |
| 2948 | + | ||
| 2949 | + | [[patch.unused]] | |
| 2950 | + | name = "makeover-build" | |
| 2951 | + | version = "0.26.0" | |
| 2952 | + | ||
| 2953 | + | [[patch.unused]] | |
| 2954 | + | name = "makeover-immediate" | |
| 2955 | + | version = "0.15.0" | |
| 2956 | + | ||
| 2957 | + | [[patch.unused]] | |
| 2958 | + | name = "makeover-touch" | |
| 2959 | + | version = "0.10.0" | |
| 2960 | + | ||
| 2961 | + | [[patch.unused]] | |
| 2962 | + | name = "makeover-webview" | |
| 2963 | + | version = "0.33.0" |
| @@ -19,7 +19,7 @@ | |||
| 19 | 19 | # intents-to-ratatui-colours mapping. No hex value is written in this crate. | |
| 20 | 20 | makeover = "2.5.0" | |
| 21 | 21 | makeover-tui = { version = "0.16.1", features = ["theme"] } | |
| 22 | - | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] } | |
| 22 | + | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time", "sync", "signal", "fs"] } | |
| 23 | 23 | # `rustls-no-provider` rather than `rustls`: the latter is an alias for | |
| 24 | 24 | # `__rustls-aws-lc-rs`, which links a C crypto backend. The provider is ring | |
| 25 | 25 | # (pure Rust), installed process-wide by `crate::tls` before any client is |
| @@ -5,7 +5,15 @@ | |||
| 5 | 5 | # | |
| 6 | 6 | # Adding a service is an edit to this file and nothing else. magicmirror knows | |
| 7 | 7 | # nothing about tiers, gates, apps or targets; it renders whatever the source | |
| 8 | - | # emits at GET /status.json. A new daemon gets a UI by emitting the payload. | |
| 8 | + | # emits. A new producer gets a UI by emitting the payload. | |
| 9 | + | # | |
| 10 | + | # A source sets exactly one of `url` and `path`. `url` is a daemon to poll, with | |
| 11 | + | # /status.json appended. `path` is a payload on disk, which is what a batch | |
| 12 | + | # producer leaves behind: something that runs for hours and then stops has | |
| 13 | + | # nothing to poll, and making it grow an HTTP listener would be paying a | |
| 14 | + | # daemon's cost for a producer that is not one. Either way magicmirror keeps the | |
| 15 | + | # last payload it read across a failed poll, so a producer that finished at 03:20 | |
| 16 | + | # still reads at 15:00. | |
| 9 | 17 | # | |
| 10 | 18 | # magicmirror is read-only until a source sets allow_actions = true. A source | |
| 11 | 19 | # with it off still shows the actions its nodes declare; it just refuses to | |
| @@ -78,3 +86,22 @@ | |||
| 78 | 86 | token_env = "POM_API_TOKEN" | |
| 79 | 87 | poll_secs = 30 | |
| 80 | 88 | stale_after_secs = 600 | |
| 89 | + | ||
| 90 | + | # witchbroom: the nightly repo x check sweep on astra. A batch producer, so this | |
| 91 | + | # is a file rather than a URL — it writes its payload at the end of a run and | |
| 92 | + | # stops. The path is witchbroom's own `status_path` default; change both or | |
| 93 | + | # neither. Only useful for a magicmirror on astra, since the file is local. | |
| 94 | + | # | |
| 95 | + | # A file source takes no token_env and no allow_actions: there is nothing to | |
| 96 | + | # authenticate to and no base to resolve an action's URL against. Both are | |
| 97 | + | # refused at load rather than ignored. | |
| 98 | + | # | |
| 99 | + | # stale_after_secs is a day and a half, not sixty seconds. The sweep runs | |
| 100 | + | # nightly, so anything shorter reports it degraded for twenty-three hours out of | |
| 101 | + | # every twenty-four; a day and a half is "it missed a night", which is the thing | |
| 102 | + | # actually worth seeing. | |
| 103 | + | [[source]] | |
| 104 | + | name = "witchbroom" | |
| 105 | + | path = "~/.local/state/witchbroom/status.json" | |
| 106 | + | poll_secs = 60 | |
| 107 | + | stale_after_secs = 129600 |
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | //! than merely tabbed, and it is worth defending: the moment a source needs a | |
| 6 | 6 | //! special case here, the shell has started learning domain vocabulary. | |
| 7 | 7 | ||
| 8 | - | use std::path::Path; | |
| 8 | + | use std::path::{Path, PathBuf}; | |
| 9 | 9 | use std::time::Duration; | |
| 10 | 10 | ||
| 11 | 11 | use anyhow::{Context, Result}; | |
| @@ -39,12 +39,37 @@ | |||
| 39 | 39 | pub sources: Vec<Source>, | |
| 40 | 40 | } | |
| 41 | 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 | + | ||
| 42 | 58 | #[derive(Debug, Clone, Deserialize)] | |
| 43 | 59 | pub(crate) struct Source { | |
| 44 | 60 | /// Tab label, and the `source` name the payload should carry. | |
| 45 | 61 | pub name: String, | |
| 46 | - | /// Base URL. `/status.json` is appended. | |
| 47 | - | pub url: 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>, | |
| 48 | 73 | /// Name of the environment variable holding this source's bearer token. | |
| 49 | 74 | /// | |
| 50 | 75 | /// The token is named, never inlined: this file describes topology and has | |
| @@ -70,6 +95,21 @@ | |||
| 70 | 95 | pub allow_actions: bool, | |
| 71 | 96 | } | |
| 72 | 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 | + | ||
| 73 | 113 | fn default_poll() -> u64 { | |
| 74 | 114 | DEFAULT_POLL_SECS | |
| 75 | 115 | } | |
| @@ -79,8 +119,17 @@ | |||
| 79 | 119 | } | |
| 80 | 120 | ||
| 81 | 121 | impl Source { | |
| 82 | - | pub(crate) fn status_url(&self) -> String { | |
| 83 | - | format!("{}/status.json", self.url.trim_end_matches('/')) | |
| 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 | + | } | |
| 84 | 133 | } | |
| 85 | 134 | ||
| 86 | 135 | pub(crate) fn poll_interval(&self) -> Duration { | |
| @@ -92,13 +141,20 @@ | |||
| 92 | 141 | /// An action carries a path (`/rollback/b`); a producer that instead gives a | |
| 93 | 142 | /// full URL is honored as-is, so a daemon can point an action at somewhere | |
| 94 | 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. | |
| 95 | 148 | pub(crate) fn action_url(&self, path: &str) -> String { | |
| 96 | 149 | if path.starts_with("http://") || path.starts_with("https://") { | |
| 97 | 150 | return path.to_string(); | |
| 98 | 151 | } | |
| 152 | + | let Some(base) = self.url.as_deref() else { | |
| 153 | + | return path.to_string(); | |
| 154 | + | }; | |
| 99 | 155 | format!( | |
| 100 | 156 | "{}/{}", | |
| 101 | - | self.url.trim_end_matches('/'), | |
| 157 | + | base.trim_end_matches('/'), | |
| 102 | 158 | path.trim_start_matches('/') | |
| 103 | 159 | ) | |
| 104 | 160 | } | |
| @@ -116,8 +172,13 @@ | |||
| 116 | 172 | pub(crate) fn load(path: &Path) -> Result<Self> { | |
| 117 | 173 | let raw = std::fs::read_to_string(path) | |
| 118 | 174 | .with_context(|| format!("reading magicmirror config at {}", path.display()))?; | |
| 119 | - | let cfg: Config = toml::from_str(&raw) | |
| 175 | + | let mut cfg: Config = toml::from_str(&raw) | |
| 120 | 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 | + | } | |
| 121 | 182 | cfg.validate()?; | |
| 122 | 183 | Ok(cfg) | |
| 123 | 184 | } | |
| @@ -134,11 +195,39 @@ | |||
| 134 | 195 | "duplicate source name {:?}: tabs would be ambiguous", | |
| 135 | 196 | source.name | |
| 136 | 197 | ); | |
| 137 | - | anyhow::ensure!( | |
| 138 | - | source.url.starts_with("http://") || source.url.starts_with("https://"), | |
| 139 | - | "source {:?} url must start with http:// or https://", | |
| 140 | - | source.name | |
| 141 | - | ); | |
| 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 | + | } | |
| 142 | 231 | } | |
| 143 | 232 | Ok(()) | |
| 144 | 233 | } | |
| @@ -178,7 +267,10 @@ | |||
| 178 | 267 | "#, | |
| 179 | 268 | ); | |
| 180 | 269 | let cfg = Config::load(&path).unwrap(); | |
| 181 | - | assert_eq!(cfg.sources[0].status_url(), "http://fw13:8080/status.json"); | |
| 270 | + | assert_eq!( | |
| 271 | + | cfg.sources[0].origin(), | |
| 272 | + | Origin::Http("http://fw13:8080/status.json".into()) | |
| 273 | + | ); | |
| 182 | 274 | assert_eq!(cfg.sources[0].poll_interval().as_secs(), DEFAULT_POLL_SECS); | |
| 183 | 275 | assert_eq!( | |
| 184 | 276 | cfg.stale_after(&cfg.sources[0]), | |
| @@ -219,7 +311,122 @@ | |||
| 219 | 311 | "#, | |
| 220 | 312 | ); | |
| 221 | 313 | let cfg = Config::load(&path).unwrap(); | |
| 222 | - | assert_eq!(cfg.sources[0].status_url(), "http://fw13:8090/status.json"); | |
| 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 | + | ); | |
| 223 | 430 | } | |
| 224 | 431 | ||
| 225 | 432 | #[test] |
| @@ -1,17 +1,24 @@ | |||
| 1 | - | //! Fetching each source on its own interval. | |
| 1 | + | //! Reading each source on its own interval. | |
| 2 | 2 | //! | |
| 3 | 3 | //! One task per source, each reporting into a channel the UI drains. Sources | |
| 4 | 4 | //! are independent on purpose: a daemon that hangs must not stop the others | |
| 5 | 5 | //! from updating, because the tab that stops updating is exactly the one you | |
| 6 | 6 | //! need to see. | |
| 7 | + | //! | |
| 8 | + | //! Two origins, one loop. An HTTP source is fetched and a file source is read, | |
| 9 | + | //! and past that nothing downstream can tell them apart: both produce a | |
| 10 | + | //! `Payload` or a short reason, on the same tick, into the same channel. A batch | |
| 11 | + | //! producer is legible here because the UI already keeps the last payload across | |
| 12 | + | //! a failed poll, so a sweep that finished at 03:20 still reads at 15:00. | |
| 7 | 13 | ||
| 14 | + | use std::path::Path; | |
| 8 | 15 | use std::time::Duration; | |
| 9 | 16 | ||
| 10 | 17 | use chrono::Utc; | |
| 11 | 18 | use ops_status::Payload; | |
| 12 | 19 | use tokio::sync::mpsc; | |
| 13 | 20 | ||
| 14 | - | use crate::config::Source; | |
| 21 | + | use crate::config::{Origin, Source}; | |
| 15 | 22 | ||
| 16 | 23 | /// A poll result, tagged with which source it came from. | |
| 17 | 24 | pub(crate) struct Update { | |
| @@ -39,21 +46,28 @@ | |||
| 39 | 46 | } | |
| 40 | 47 | ||
| 41 | 48 | async fn poll_forever(index: usize, source: Source, tx: mpsc::Sender<Update>) { | |
| 42 | - | let client = match crate::tls::client(REQUEST_TIMEOUT) { | |
| 43 | - | Ok(client) => client, | |
| 44 | - | Err(e) => { | |
| 45 | - | let _ = tx | |
| 46 | - | .send(Update { | |
| 47 | - | index, | |
| 48 | - | at: Utc::now(), | |
| 49 | - | result: Err(format!("client: {e}")), | |
| 50 | - | }) | |
| 51 | - | .await; | |
| 52 | - | return; | |
| 49 | + | let origin = source.origin(); | |
| 50 | + | // Built once per task, and only for an HTTP source: a file source has no | |
| 51 | + | // transport to fail, so a broken TLS setup must not take its tab down with | |
| 52 | + | // the others. | |
| 53 | + | let client = if matches!(origin, Origin::Http(_)) { | |
| 54 | + | match crate::tls::client(REQUEST_TIMEOUT) { | |
| 55 | + | Ok(client) => Some(client), | |
| 56 | + | Err(e) => { | |
| 57 | + | let _ = tx | |
| 58 | + | .send(Update { | |
| 59 | + | index, | |
| 60 | + | at: Utc::now(), | |
| 61 | + | result: Err(format!("client: {e}")), | |
| 62 | + | }) | |
| 63 | + | .await; | |
| 64 | + | return; | |
| 65 | + | } | |
| 53 | 66 | } | |
| 67 | + | } else { | |
| 68 | + | None | |
| 54 | 69 | }; | |
| 55 | 70 | ||
| 56 | - | let url = source.status_url(); | |
| 57 | 71 | let mut ticker = tokio::time::interval(source.poll_interval()); | |
| 58 | 72 | // A poll that overruns its interval must not cause a burst of catch-up | |
| 59 | 73 | // requests at the next tick. | |
| @@ -61,9 +75,15 @@ | |||
| 61 | 75 | ||
| 62 | 76 | loop { | |
| 63 | 77 | ticker.tick().await; | |
| 64 | - | let result = fetch(&client, &url, source.token().as_deref()).await; | |
| 65 | - | // The token is read per request rather than captured once, so rotating | |
| 66 | - | // it does not require restarting magicmirror. | |
| 78 | + | let result = match &origin { | |
| 79 | + | // The token is read per request rather than captured once, so | |
| 80 | + | // rotating it does not require restarting magicmirror. | |
| 81 | + | Origin::Http(url) => match &client { | |
| 82 | + | Some(client) => fetch(client, url, source.token().as_deref()).await, | |
| 83 | + | None => Err("client: not built".into()), | |
| 84 | + | }, | |
| 85 | + | Origin::File(path) => read(path).await, | |
| 86 | + | }; | |
| 67 | 87 | if tx | |
| 68 | 88 | .send(Update { | |
| 69 | 89 | index, | |
| @@ -103,6 +123,29 @@ | |||
| 103 | 123 | serde_json::from_slice(&body).map_err(|e| format!("bad payload: {e}")) | |
| 104 | 124 | } | |
| 105 | 125 | ||
| 126 | + | /// Read a payload a batch producer left on disk. | |
| 127 | + | /// | |
| 128 | + | /// Every way this fails is the same condition an unreachable daemon is in -- | |
| 129 | + | /// nothing current to show -- so it reports the same shape, and the UI keeps the | |
| 130 | + | /// last good payload either way. A missing file is the ordinary case on a fresh | |
| 131 | + | /// machine: the producer has not run yet. | |
| 132 | + | async fn read(path: &Path) -> Result<Payload, String> { | |
| 133 | + | let bytes = tokio::fs::read(path).await.map_err(short_io_error)?; | |
| 134 | + | serde_json::from_slice(&bytes).map_err(|e| format!("bad payload: {e}")) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// The rollup has one column for this, and an io error stringifies with the | |
| 138 | + | /// whole path in it, which is already the tab's own configuration. | |
| 139 | + | #[allow(clippy::needless_pass_by_value)] | |
| 140 | + | fn short_io_error(e: std::io::Error) -> String { | |
| 141 | + | match e.kind() { | |
| 142 | + | std::io::ErrorKind::NotFound => "no payload written yet".into(), | |
| 143 | + | std::io::ErrorKind::PermissionDenied => "permission denied".into(), | |
| 144 | + | std::io::ErrorKind::IsADirectory => "path is a directory".into(), | |
| 145 | + | kind => kind.to_string(), | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 106 | 149 | /// reqwest errors stringify into a paragraph with a full URL chain. The rollup | |
| 107 | 150 | /// has one column for this, so keep the part that says what went wrong. | |
| 108 | 151 | // e is consumed into the short string. | |
| @@ -138,7 +181,40 @@ | |||
| 138 | 181 | "#, | |
| 139 | 182 | ) | |
| 140 | 183 | .unwrap(); | |
| 141 | - | assert_eq!(source.status_url(), "http://fw13:8080/status.json"); | |
| 184 | + | assert_eq!( | |
| 185 | + | source.origin(), | |
| 186 | + | Origin::Http("http://fw13:8080/status.json".into()) | |
| 187 | + | ); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// The batch producer's ordinary state on a fresh machine: configured, | |
| 191 | + | /// pointed at the right place, and nothing has run yet. It has to read as a | |
| 192 | + | /// source with nothing current rather than as a broken config. | |
| 193 | + | #[tokio::test] | |
| 194 | + | async fn a_payload_that_has_not_been_written_yet_is_a_short_reason() { | |
| 195 | + | let dir = tempfile::tempdir().unwrap(); | |
| 196 | + | let err = read(&dir.path().join("status.json")).await.unwrap_err(); | |
| 197 | + | assert_eq!(err, "no payload written yet"); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[tokio::test] | |
| 201 | + | async fn a_payload_on_disk_reads_the_same_as_one_off_the_wire() { | |
| 202 | + | let dir = tempfile::tempdir().unwrap(); | |
| 203 | + | let path = dir.path().join("status.json"); | |
| 204 | + | let written = Payload::new("witchbroom", Utc::now()); | |
| 205 | + | std::fs::write(&path, serde_json::to_string(&written).unwrap()).unwrap(); | |
| 206 | + | ||
| 207 | + | let got = read(&path).await.unwrap(); | |
| 208 | + | assert_eq!(got.source, "witchbroom"); | |
| 209 | + | assert_eq!(got, written); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[tokio::test] | |
| 213 | + | async fn a_file_that_is_not_a_payload_says_so_rather_than_panicking() { | |
| 214 | + | let dir = tempfile::tempdir().unwrap(); | |
| 215 | + | let path = dir.path().join("status.json"); | |
| 216 | + | std::fs::write(&path, b"half a write").unwrap(); | |
| 217 | + | assert!(read(&path).await.unwrap_err().starts_with("bad payload")); | |
| 142 | 218 | } | |
| 143 | 219 | ||
| 144 | 220 | // Opens a socket, which miri has no shim for. See the note in `exec`. |