max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+448 insertions,
-8 deletions
| @@ -33,13 +33,16 @@ | |||
| 33 | 33 | # Staging dir for a service deploy: the binary lands here on the daemon between | |
| 34 | 34 | # the pull off the build host and the push onto the service host. | |
| 35 | 35 | tempfile = "3.20" | |
| 36 | + | # Reads MNW's public OTA endpoint to answer whether a built version is actually | |
| 37 | + | # downloadable. rustls rather than native-tls to keep the TLS stack the same one | |
| 38 | + | # every other crate here links. | |
| 39 | + | reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } | |
| 36 | 40 | ||
| 37 | 41 | [dev-dependencies] | |
| 38 | 42 | async-trait = "0.1" | |
| 39 | 43 | tempfile = "3.20" | |
| 40 | 44 | tower = { version = "0.5", features = ["util"] } | |
| 41 | 45 | http-body-util = "0.1" | |
| 42 | - | reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } | |
| 43 | 46 | # WS client for the /events end-to-end test. No TLS backend (the test dials | |
| 44 | 47 | # plaintext ws://127.0.0.1), matching the transitive copy axum's `ws` feature | |
| 45 | 48 | # already compiles. |
| @@ -18,6 +18,9 @@ | |||
| 18 | 18 | pub(crate) fn status_style(status: Status) -> Style { | |
| 19 | 19 | match status { | |
| 20 | 20 | Status::Ok => Style::default().fg(Color::Green), | |
| 21 | + | // Cyan: adjacent to green rather than to the alarm colors, because a | |
| 22 | + | // build waiting on its upload is finished work, not broken work. | |
| 23 | + | Status::Undistributed => Style::default().fg(Color::Cyan), | |
| 21 | 24 | Status::Degraded => Style::default().fg(Color::Yellow), | |
| 22 | 25 | Status::Failed => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), | |
| 23 | 26 | Status::Pending => Style::default().fg(Color::Blue), | |
| @@ -31,6 +34,7 @@ | |||
| 31 | 34 | pub(crate) fn status_mark(status: Status) -> &'static str { | |
| 32 | 35 | match status { | |
| 33 | 36 | Status::Ok => "ok", | |
| 37 | + | Status::Undistributed => "dist", | |
| 34 | 38 | Status::Degraded => "degr", | |
| 35 | 39 | Status::Failed => "FAIL", | |
| 36 | 40 | Status::Pending => "pend", |
| @@ -85,18 +85,27 @@ | |||
| 85 | 85 | let executors = Arc::new(state::build_executors(&topo)); | |
| 86 | 86 | let syncs = Arc::new(state::build_syncs(&topo)); | |
| 87 | 87 | let host_locks = state::build_host_locks(&topo); | |
| 88 | + | let mnw_base = mnw_base_url(); | |
| 88 | 89 | let app_state = state::AppState { | |
| 89 | 90 | pool, | |
| 90 | 91 | topo, | |
| 91 | 92 | cfg, | |
| 92 | 93 | prom, | |
| 93 | 94 | events: events::channel(), | |
| 94 | - | ota: Arc::new(ota::OtaRegistry::standard(mnw_base_url())), | |
| 95 | + | ota: Arc::new(ota::OtaRegistry::standard(mnw_base.clone())), | |
| 95 | 96 | executors, | |
| 96 | 97 | syncs, | |
| 97 | 98 | active: Arc::new(tokio::sync::Mutex::new(HashMap::new())), | |
| 98 | 99 | api_token, | |
| 99 | 100 | host_locks, | |
| 101 | + | distribution: Arc::new(tokio::sync::Mutex::new(HashMap::new())), | |
| 102 | + | // Short timeout: an unreachable MNW must make the board say "unknown" | |
| 103 | + | // promptly, not stall the whole poll behind a hanging connect. | |
| 104 | + | http: reqwest::Client::builder() | |
| 105 | + | .timeout(std::time::Duration::from_secs(5)) | |
| 106 | + | .build() | |
| 107 | + | .expect("build http client"), | |
| 108 | + | mnw_base_url: mnw_base.into(), | |
| 100 | 109 | }; | |
| 101 | 110 | // Disk retention: prune logs_root + dist_root to the newest N versions per | |
| 102 | 111 | // app, at startup and every 6h, so neither root grows without bound. Skips |
| @@ -87,6 +87,131 @@ | |||
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | // --------------------------------------------------------------------------- | |
| 91 | + | // Distribution probe | |
| 92 | + | // --------------------------------------------------------------------------- | |
| 93 | + | ||
| 94 | + | /// The MNW slug an app distributes under. | |
| 95 | + | /// | |
| 96 | + | /// MNW slugs are lowercase alphanumeric plus hyphens (`server/routes/ota.rs` | |
| 97 | + | /// `validate_slug`), and Bento app ids use underscores, so the mapping is a | |
| 98 | + | /// substitution rather than an identity. Deriving it beats carrying a second | |
| 99 | + | /// name in the topology that can drift from the first. | |
| 100 | + | pub fn mnw_slug(app: &str) -> String { | |
| 101 | + | app.replace('_', "-") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Bento's `(platform, arch)` as MNW's OTA path segments, or `None` for a | |
| 105 | + | /// target MNW's updater does not serve. | |
| 106 | + | /// | |
| 107 | + | /// MNW says `darwin` where Bento says `macos` — Tauri's updater vocabulary, | |
| 108 | + | /// which the endpoint matches. iOS and Android ride TestFlight and Play, so | |
| 109 | + | /// they have no MNW manifest and are not evidence of anything either way. | |
| 110 | + | pub fn mnw_path_segments(target: Target) -> Option<(&'static str, &'static str)> { | |
| 111 | + | use crate::domain::Platform::{Android, Ios, Linux, Macos, Windows}; | |
| 112 | + | let platform = match target.platform { | |
| 113 | + | Macos => "darwin", | |
| 114 | + | Linux => "linux", | |
| 115 | + | Windows => "windows", | |
| 116 | + | Ios | Android => return None, | |
| 117 | + | }; | |
| 118 | + | Some((platform, target.arch.as_str())) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// What the world can actually download for one app at one version. | |
| 122 | + | #[derive(Debug, Default, Clone)] | |
| 123 | + | pub struct Distribution { | |
| 124 | + | /// Targets MNW serves a manifest for, at the expected version. | |
| 125 | + | pub fetchable: Vec<String>, | |
| 126 | + | /// Targets that have an MNW manifest but at some other version, or none. | |
| 127 | + | pub missing: Vec<String>, | |
| 128 | + | /// The probe could not reach MNW. Distinct from "nothing is published": | |
| 129 | + | /// not knowing is not the same as knowing the answer is no. | |
| 130 | + | pub error: Option<String>, | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | impl Distribution { | |
| 134 | + | /// True only when every target that MNW *can* serve is being served. | |
| 135 | + | /// | |
| 136 | + | /// An app whose declared targets are all mobile has nothing to check and is | |
| 137 | + | /// vacuously complete; the caller decides whether that is meaningful. | |
| 138 | + | pub fn complete(&self) -> bool { | |
| 139 | + | self.error.is_none() && self.missing.is_empty() | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Ask MNW's public updater endpoint whether each target is downloadable at | |
| 144 | + | /// `version`. | |
| 145 | + | /// | |
| 146 | + | /// This is a read of the same endpoint a user's Tauri app polls, which is the | |
| 147 | + | /// point: it answers "is the release actually out" from the outside, rather | |
| 148 | + | /// than from Bento's own record of what it believes it did. Bento's record is | |
| 149 | + | /// exactly what cannot be trusted here — [`TauriMnwBackend::publish`] does not | |
| 150 | + | /// transfer bytes yet, and the artifacts are uploaded by hand. | |
| 151 | + | /// | |
| 152 | + | /// `0.0.0` is passed as the updater's `current_version` so the endpoint always | |
| 153 | + | /// considers the published release an upgrade and answers with the manifest. | |
| 154 | + | pub async fn probe_distribution( | |
| 155 | + | client: &reqwest::Client, | |
| 156 | + | base_url: &str, | |
| 157 | + | slug: &str, | |
| 158 | + | version: &str, | |
| 159 | + | targets: &[Target], | |
| 160 | + | ) -> Distribution { | |
| 161 | + | let mut dist = Distribution::default(); | |
| 162 | + | ||
| 163 | + | for target in targets { | |
| 164 | + | let Some((platform, arch)) = mnw_path_segments(*target) else { | |
| 165 | + | continue; | |
| 166 | + | }; | |
| 167 | + | let url = format!( | |
| 168 | + | "{}/api/v1/sync/ota/{slug}/{platform}/{arch}/0.0.0", | |
| 169 | + | base_url.trim_end_matches('/') | |
| 170 | + | ); | |
| 171 | + | ||
| 172 | + | match client.get(&url).send().await { | |
| 173 | + | // 204 is the endpoint's "no update available", which for a probe | |
| 174 | + | // anchored at 0.0.0 means nothing is published at all. | |
| 175 | + | Ok(resp) if resp.status() == reqwest::StatusCode::NO_CONTENT => { | |
| 176 | + | dist.missing.push(target.to_string()); | |
| 177 | + | } | |
| 178 | + | Ok(resp) if resp.status().is_success() => { | |
| 179 | + | match resp.json::<serde_json::Value>().await { | |
| 180 | + | Ok(body) => { | |
| 181 | + | let served = body.get("version").and_then(|v| v.as_str()); | |
| 182 | + | let has_url = body | |
| 183 | + | .get("url") | |
| 184 | + | .and_then(|v| v.as_str()) | |
| 185 | + | .is_some_and(|u| !u.is_empty()); | |
| 186 | + | // Both halves matter: a manifest naming the right | |
| 187 | + | // version with no download URL is not distributable, | |
| 188 | + | // and one with a URL at the wrong version is a stale | |
| 189 | + | // release rather than this one. | |
| 190 | + | if served == Some(version) && has_url { | |
| 191 | + | dist.fetchable.push(target.to_string()); | |
| 192 | + | } else { | |
| 193 | + | dist.missing.push(target.to_string()); | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | Err(e) => dist.error = Some(format!("{target}: malformed manifest: {e}")), | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | Ok(resp) => { | |
| 200 | + | // 404 is a slug MNW has never heard of, which is a real "not | |
| 201 | + | // distributed" rather than a transport problem. | |
| 202 | + | if resp.status() == reqwest::StatusCode::NOT_FOUND { | |
| 203 | + | dist.missing.push(target.to_string()); | |
| 204 | + | } else { | |
| 205 | + | dist.error = Some(format!("{target}: MNW answered {}", resp.status())); | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | Err(e) => dist.error = Some(format!("{target}: {e}")), | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | dist | |
| 213 | + | } | |
| 214 | + | ||
| 90 | 215 | /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`, | |
| 91 | 216 | /// `github-releases`, …) is a single `impl` plus registering its id; recipes | |
| 92 | 217 | /// don't change. |
| @@ -103,6 +103,12 @@ | |||
| 103 | 103 | /// Targets with a `releases` row at the build's version. Empty when | |
| 104 | 104 | /// publishing is not in play for this app. | |
| 105 | 105 | pub(crate) published_targets: Vec<String>, | |
| 106 | + | /// What MNW will actually serve for this app at the build's version. | |
| 107 | + | /// | |
| 108 | + | /// `None` for anything that does not reach users through MNW: libraries go | |
| 109 | + | /// to crates.io and services are installed onto hosts, so neither has an | |
| 110 | + | /// OTA manifest and neither should be judged against one. | |
| 111 | + | pub(crate) distribution: Option<crate::ota::Distribution>, | |
| 106 | 112 | } | |
| 107 | 113 | ||
| 108 | 114 | #[derive(Serialize)] | |
| @@ -340,17 +346,61 @@ | |||
| 340 | 346 | None => Vec::new(), | |
| 341 | 347 | }; | |
| 342 | 348 | ||
| 349 | + | let distribution = match (&build, cfg.kind) { | |
| 350 | + | // Only apps reach users through MNW's OTA endpoint. A library's | |
| 351 | + | // distribution question is answered by crates.io and a service's by | |
| 352 | + | // whether it is installed, so probing MNW for either would invent a | |
| 353 | + | // red that means nothing. | |
| 354 | + | (Some(b), crate::topology::Kind::App) => { | |
| 355 | + | Some(distribution_for(s, name, &b.version, &cfg.targets).await) | |
| 356 | + | } | |
| 357 | + | _ => None, | |
| 358 | + | }; | |
| 359 | + | ||
| 343 | 360 | apps.push(AppStatusView { | |
| 344 | 361 | app: name.clone(), | |
| 345 | 362 | kind: cfg.kind, | |
| 346 | 363 | declared_targets: cfg.targets.iter().map(ToString::to_string).collect(), | |
| 347 | 364 | build, | |
| 348 | 365 | published_targets, | |
| 366 | + | distribution, | |
| 349 | 367 | }); | |
| 350 | 368 | } | |
| 351 | 369 | Ok(apps) | |
| 352 | 370 | } | |
| 353 | 371 | ||
| 372 | + | /// The cached distribution answer for one `(app, version)`, probing MNW only | |
| 373 | + | /// when there is no fresh one. | |
| 374 | + | async fn distribution_for( | |
| 375 | + | s: &AppState, | |
| 376 | + | app: &str, | |
| 377 | + | version: &str, | |
| 378 | + | targets: &[crate::domain::Target], | |
| 379 | + | ) -> crate::ota::Distribution { | |
| 380 | + | let key = (app.to_string(), version.to_string()); | |
| 381 | + | ||
| 382 | + | { | |
| 383 | + | let cache = s.distribution.lock().await; | |
| 384 | + | if let Some((taken, dist)) = cache.get(&key) | |
| 385 | + | && taken.elapsed() < crate::state::DISTRIBUTION_TTL | |
| 386 | + | { | |
| 387 | + | return dist.clone(); | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | // Deliberately not holding the lock across the probe: a slow MNW would | |
| 392 | + | // otherwise serialize every poll behind one request. | |
| 393 | + | let slug = crate::ota::mnw_slug(app); | |
| 394 | + | let dist = | |
| 395 | + | crate::ota::probe_distribution(&s.http, &s.mnw_base_url, &slug, version, targets).await; | |
| 396 | + | ||
| 397 | + | s.distribution | |
| 398 | + | .lock() | |
| 399 | + | .await | |
| 400 | + | .insert(key, (std::time::Instant::now(), dist.clone())); | |
| 401 | + | dist | |
| 402 | + | } | |
| 403 | + | ||
| 354 | 404 | #[derive(Deserialize, Default)] | |
| 355 | 405 | struct BuildBody { | |
| 356 | 406 | app: String, | |
| @@ -608,6 +658,11 @@ | |||
| 608 | 658 | active: Arc::new(Mutex::new(HashMap::new())), | |
| 609 | 659 | api_token: None, | |
| 610 | 660 | host_locks, | |
| 661 | + | distribution: Arc::new(Mutex::new(HashMap::new())), | |
| 662 | + | http: reqwest::Client::new(), | |
| 663 | + | // Port 1 refuses instantly. A test must never probe production, and | |
| 664 | + | // a refusal is also faster than any timeout would be. | |
| 665 | + | mnw_base_url: "http://127.0.0.1:1".into(), | |
| 611 | 666 | } | |
| 612 | 667 | } | |
| 613 | 668 | ||
| @@ -938,6 +993,11 @@ | |||
| 938 | 993 | active: Arc::new(Mutex::new(HashMap::new())), | |
| 939 | 994 | api_token: None, | |
| 940 | 995 | host_locks, | |
| 996 | + | distribution: Arc::new(Mutex::new(HashMap::new())), | |
| 997 | + | http: reqwest::Client::new(), | |
| 998 | + | // Port 1 refuses instantly. A test must never probe production, and | |
| 999 | + | // a refusal is also faster than any timeout would be. | |
| 1000 | + | mnw_base_url: "http://127.0.0.1:1".into(), | |
| 941 | 1001 | } | |
| 942 | 1002 | } | |
| 943 | 1003 |
| @@ -1069,6 +1069,11 @@ | |||
| 1069 | 1069 | active: Arc::new(Mutex::new(HashMap::new())), | |
| 1070 | 1070 | api_token: None, | |
| 1071 | 1071 | host_locks, | |
| 1072 | + | distribution: Arc::new(Mutex::new(HashMap::new())), | |
| 1073 | + | http: reqwest::Client::new(), | |
| 1074 | + | // Port 1 refuses instantly. A test must never probe production, and | |
| 1075 | + | // a refusal is also faster than any timeout would be. | |
| 1076 | + | mnw_base_url: "http://127.0.0.1:1".into(), | |
| 1072 | 1077 | } | |
| 1073 | 1078 | } | |
| 1074 | 1079 |
| @@ -84,8 +84,33 @@ | |||
| 84 | 84 | /// the same host serialize instead of corrupting one shared checkout + | |
| 85 | 85 | /// keychain. See [`HostLocks`]. | |
| 86 | 86 | pub host_locks: HostLocks, | |
| 87 | + | /// Cached answers to "is this app's version downloadable from MNW". | |
| 88 | + | /// | |
| 89 | + | /// `/status.json` is polled every few seconds and the probe talks to | |
| 90 | + | /// production, so the answer is cached per `(app, version)`: a viewer | |
| 91 | + | /// refreshing its board must not turn into sustained traffic against the | |
| 92 | + | /// endpoint real users' updaters poll. | |
| 93 | + | pub distribution: DistributionCache, | |
| 94 | + | /// HTTP client for the distribution probe. One client, so the connection | |
| 95 | + | /// pool is reused across polls rather than rebuilt per request. | |
| 96 | + | pub http: reqwest::Client, | |
| 97 | + | /// MNW base URL the probe reads. Same value the `tauri-mnw` backend | |
| 98 | + | /// publishes to, so the check and the publish can never disagree about | |
| 99 | + | /// which host they mean. | |
| 100 | + | pub mnw_base_url: Arc<str>, | |
| 87 | 101 | } | |
| 88 | 102 | ||
| 103 | + | /// `(app, version)` to its last probe result and when that was taken. | |
| 104 | + | pub type DistributionCache = | |
| 105 | + | Arc<Mutex<HashMap<(String, String), (std::time::Instant, crate::ota::Distribution)>>>; | |
| 106 | + | ||
| 107 | + | /// How long a distribution answer stays good. | |
| 108 | + | /// | |
| 109 | + | /// Generous on purpose. The thing being watched is a manual upload, which | |
| 110 | + | /// happens on human timescales, so a minute of staleness costs nothing and | |
| 111 | + | /// keeps the poll off MNW's back. | |
| 112 | + | pub const DISTRIBUTION_TTL: std::time::Duration = std::time::Duration::from_secs(60); | |
| 113 | + | ||
| 89 | 114 | /// Build one host's EXEC executor: `LocalExec` for `ssh = "local"`, `AgentRpc` | |
| 90 | 115 | /// for an agent-transport host (macOS in-session signing), `SshExec` otherwise — | |
| 91 | 116 | /// each granted exactly the host's declared capabilities. |
| @@ -102,6 +102,9 @@ | |||
| 102 | 102 | if let Some(condition) = publish_completeness(app, build) { | |
| 103 | 103 | conditions.push(condition); | |
| 104 | 104 | } | |
| 105 | + | if let Some(condition) = distributable(app, build) { | |
| 106 | + | conditions.push(condition); | |
| 107 | + | } | |
| 105 | 108 | } | |
| 106 | 109 | None => conditions.push(Condition { | |
| 107 | 110 | condition_type: "build".into(), | |
| @@ -148,8 +151,14 @@ | |||
| 148 | 151 | Status::Pending => Status::Pending, | |
| 149 | 152 | // A build the runner called finished, that did not cover every declared | |
| 150 | 153 | // target, is a partial release wearing a green build status. | |
| 151 | - | Status::Ok if missing_targets(app, build).is_empty() => Status::Ok, | |
| 152 | - | Status::Ok => Status::Degraded, | |
| 154 | + | Status::Ok if !missing_targets(app, build).is_empty() => Status::Degraded, | |
| 155 | + | // Every target built — but a release nobody can download is not a | |
| 156 | + | // release. Green is reserved for what has actually reached the world. | |
| 157 | + | Status::Ok => match &app.distribution { | |
| 158 | + | Some(dist) if dist.error.is_some() => Status::Unknown, | |
| 159 | + | Some(dist) if !dist.complete() => Status::Undistributed, | |
| 160 | + | _ => Status::Ok, | |
| 161 | + | }, | |
| 153 | 162 | other => other, | |
| 154 | 163 | } | |
| 155 | 164 | } | |
| @@ -231,6 +240,55 @@ | |||
| 231 | 240 | }) | |
| 232 | 241 | } | |
| 233 | 242 | ||
| 243 | + | /// "downloadable from makenot.work at 1.4.0", or what is not. | |
| 244 | + | /// | |
| 245 | + | /// Silent for anything with no MNW distribution question to answer — the | |
| 246 | + | /// libraries and services, which [`crate::routes::status_view`] leaves as | |
| 247 | + | /// `None`. Silent too when every declared target is mobile, since TestFlight | |
| 248 | + | /// and Play are not MNW's to report on and a permanently unanswerable condition | |
| 249 | + | /// is the kind that teaches an operator to stop reading the board. | |
| 250 | + | fn distributable(app: &AppStatusView, build: &BuildView) -> Option<Condition> { | |
| 251 | + | let dist = app.distribution.as_ref()?; | |
| 252 | + | if matches!(run_status(&build.status), Status::Pending) { | |
| 253 | + | return None; | |
| 254 | + | } | |
| 255 | + | if let Some(error) = &dist.error { | |
| 256 | + | return Some(Condition { | |
| 257 | + | condition_type: "distributable".into(), | |
| 258 | + | status: Status::Unknown, | |
| 259 | + | since: None, | |
| 260 | + | detail: Some(format!("could not reach MNW: {error}")), | |
| 261 | + | }); | |
| 262 | + | } | |
| 263 | + | let checked = dist.fetchable.len() + dist.missing.len(); | |
| 264 | + | if checked == 0 { | |
| 265 | + | return None; | |
| 266 | + | } | |
| 267 | + | Some(Condition { | |
| 268 | + | condition_type: "distributable".into(), | |
| 269 | + | status: if dist.missing.is_empty() { | |
| 270 | + | Status::Ok | |
| 271 | + | } else { | |
| 272 | + | Status::Undistributed | |
| 273 | + | }, | |
| 274 | + | since: None, | |
| 275 | + | detail: Some(if dist.missing.is_empty() { | |
| 276 | + | format!( | |
| 277 | + | "{} of {checked} fetchable from MNW at {}", | |
| 278 | + | dist.fetchable.len(), | |
| 279 | + | build.version | |
| 280 | + | ) | |
| 281 | + | } else { | |
| 282 | + | format!( | |
| 283 | + | "{} of {checked} fetchable from MNW at {}; not published: {}", | |
| 284 | + | dist.fetchable.len(), | |
| 285 | + | build.version, | |
| 286 | + | dist.missing.join(", ") | |
| 287 | + | ) | |
| 288 | + | }), | |
| 289 | + | }) | |
| 290 | + | } | |
| 291 | + | ||
| 234 | 292 | // --------------------------------------------------------------------------- | |
| 235 | 293 | // Targets | |
| 236 | 294 | // --------------------------------------------------------------------------- | |
| @@ -452,6 +510,7 @@ | |||
| 452 | 510 | declared_targets: declared.iter().map(|s| (*s).to_string()).collect(), | |
| 453 | 511 | build, | |
| 454 | 512 | published_targets: Vec::new(), | |
| 513 | + | distribution: None, | |
| 455 | 514 | } | |
| 456 | 515 | } | |
| 457 | 516 | ||
| @@ -755,6 +814,139 @@ | |||
| 755 | 814 | ); | |
| 756 | 815 | } | |
| 757 | 816 | ||
| 817 | + | fn dist(fetchable: &[&str], missing: &[&str]) -> crate::ota::Distribution { | |
| 818 | + | crate::ota::Distribution { | |
| 819 | + | fetchable: fetchable.iter().map(|s| (*s).to_string()).collect(), | |
| 820 | + | missing: missing.iter().map(|s| (*s).to_string()).collect(), | |
| 821 | + | error: None, | |
| 822 | + | } | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | #[test] | |
| 826 | + | fn a_green_build_nobody_can_download_is_not_ok() { | |
| 827 | + | // The whole point: every target built and published, but MNW serves | |
| 828 | + | // nothing, so the release has not actually reached anyone. | |
| 829 | + | let mut a = app( | |
| 830 | + | &["linux/x86_64", "macos/aarch64"], | |
| 831 | + | Some(build( | |
| 832 | + | "1.4.0", | |
| 833 | + | "ok", | |
| 834 | + | vec![ | |
| 835 | + | target_run("linux/x86_64", "ok"), | |
| 836 | + | target_run("macos/aarch64", "ok"), | |
| 837 | + | ], | |
| 838 | + | )), | |
| 839 | + | ); | |
| 840 | + | a.distribution = Some(dist(&["linux/x86_64"], &["macos/aarch64"])); | |
| 841 | + | let p = payload(&[a], now()); | |
| 842 | + | ||
| 843 | + | assert_eq!(node(&p, "app:goingson").status, Status::Undistributed); | |
| 844 | + | let c = node(&p, "app:goingson") | |
| 845 | + | .conditions | |
| 846 | + | .iter() | |
| 847 | + | .find(|c| c.condition_type == "distributable") | |
| 848 | + | .expect("an undistributed release must say so"); | |
| 849 | + | assert_eq!(c.status, Status::Undistributed); | |
| 850 | + | let detail = c.detail.as_deref().unwrap(); | |
| 851 | + | assert!(detail.contains("1 of 2"), "{detail}"); | |
| 852 | + | assert!(detail.contains("macos/aarch64"), "{detail}"); | |
| 853 | + | assert_eq!(p.validate(), Ok(())); | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | #[test] | |
| 857 | + | fn everything_fetchable_is_finally_ok() { | |
| 858 | + | let mut a = app( | |
| 859 | + | &["linux/x86_64"], | |
| 860 | + | Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), | |
| 861 | + | ); | |
| 862 | + | a.distribution = Some(dist(&["linux/x86_64"], &[])); | |
| 863 | + | let p = payload(&[a], now()); | |
| 864 | + | ||
| 865 | + | assert_eq!(node(&p, "app:goingson").status, Status::Ok); | |
| 866 | + | assert_eq!(p.worst_status(), Status::Ok); | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | #[test] | |
| 870 | + | fn an_unreachable_mnw_is_unknown_rather_than_green_or_red() { | |
| 871 | + | // Not knowing whether a release is downloadable is its own state. The | |
| 872 | + | // silent gap is the failure mode this contract exists to close, so it | |
| 873 | + | // must not be smoothed into either "fine" or "not published". | |
| 874 | + | let mut a = app( | |
| 875 | + | &["linux/x86_64"], | |
| 876 | + | Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), | |
| 877 | + | ); | |
| 878 | + | a.distribution = Some(crate::ota::Distribution { | |
| 879 | + | error: Some("connection refused".into()), | |
| 880 | + | ..Default::default() | |
| 881 | + | }); | |
| 882 | + | let p = payload(&[a], now()); | |
| 883 | + | ||
| 884 | + | assert_eq!(node(&p, "app:goingson").status, Status::Unknown); | |
| 885 | + | let c = node(&p, "app:goingson") | |
| 886 | + | .conditions | |
| 887 | + | .iter() | |
| 888 | + | .find(|c| c.condition_type == "distributable") | |
| 889 | + | .unwrap(); | |
| 890 | + | assert_eq!(c.status, Status::Unknown); | |
| 891 | + | } | |
| 892 | + | ||
| 893 | + | #[test] | |
| 894 | + | fn a_library_is_never_judged_against_mnw() { | |
| 895 | + | // Libraries go to crates.io; probing MNW for one would invent a red | |
| 896 | + | // that means nothing. status_view leaves their distribution None. | |
| 897 | + | let mut a = app( | |
| 898 | + | &["linux/x86_64"], | |
| 899 | + | Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), | |
| 900 | + | ); | |
| 901 | + | a.kind = Kind::Library; | |
| 902 | + | a.app = "pter".into(); | |
| 903 | + | let p = payload(&[a], now()); | |
| 904 | + | ||
| 905 | + | assert_eq!(node(&p, "app:pter").status, Status::Ok); | |
| 906 | + | assert!( | |
| 907 | + | !node(&p, "app:pter") | |
| 908 | + | .conditions | |
| 909 | + | .iter() | |
| 910 | + | .any(|c| c.condition_type == "distributable") | |
| 911 | + | ); | |
| 912 | + | } | |
| 913 | + | ||
| 914 | + | #[test] | |
| 915 | + | fn a_partial_build_outranks_its_distribution_gap() { | |
| 916 | + | // A release that never finished building is degraded on that ground; | |
| 917 | + | // reporting it as merely undistributed would understate it. | |
| 918 | + | let mut a = app( | |
| 919 | + | &["linux/x86_64", "windows/x86_64"], | |
| 920 | + | Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), | |
| 921 | + | ); | |
| 922 | + | a.distribution = Some(dist(&[], &["linux/x86_64"])); | |
| 923 | + | let p = payload(&[a], now()); | |
| 924 | + | assert_eq!(node(&p, "app:goingson").status, Status::Degraded); | |
| 925 | + | } | |
| 926 | + | ||
| 927 | + | #[test] | |
| 928 | + | fn mobile_only_targets_raise_no_distribution_question() { | |
| 929 | + | // iOS rides TestFlight, which MNW does not host and cannot report on. | |
| 930 | + | let mut a = app( | |
| 931 | + | &["ios/universal"], | |
| 932 | + | Some(build( | |
| 933 | + | "1.4.0", | |
| 934 | + | "ok", | |
| 935 | + | vec![target_run("ios/universal", "ok")], | |
| 936 | + | )), | |
| 937 | + | ); | |
| 938 | + | a.distribution = Some(crate::ota::Distribution::default()); | |
| 939 | + | let p = payload(&[a], now()); | |
| 940 | + | ||
| 941 | + | assert_eq!(node(&p, "app:goingson").status, Status::Ok); | |
| 942 | + | assert!( | |
| 943 | + | !node(&p, "app:goingson") | |
| 944 | + | .conditions | |
| 945 | + | .iter() | |
| 946 | + | .any(|c| c.condition_type == "distributable") | |
| 947 | + | ); | |
| 948 | + | } | |
| 949 | + | ||
| 758 | 950 | #[test] | |
| 759 | 951 | fn render_is_a_pure_function_of_state_and_clock() { | |
| 760 | 952 | let make = || { |
| @@ -85,6 +85,16 @@ | |||
| 85 | 85 | #[serde(rename_all = "snake_case")] | |
| 86 | 86 | pub enum Status { | |
| 87 | 87 | Ok, | |
| 88 | + | /// Built, green, and complete — but the artifact is not yet fetchable by | |
| 89 | + | /// the people it is for. | |
| 90 | + | /// | |
| 91 | + | /// Distinct from [`Status::Ok`] because a release nobody can download is | |
| 92 | + | /// not a release, and distinct from [`Status::Pending`] because nothing is | |
| 93 | + | /// in flight: the pipeline is finished and the gap is the last hop. That | |
| 94 | + | /// hop is deliberately manual for the apps (artifacts are uploaded to | |
| 95 | + | /// makenot.work by hand), so this is the state that says "your turn" | |
| 96 | + | /// rather than one that says something broke. | |
| 97 | + | Undistributed, | |
| 88 | 98 | Degraded, | |
| 89 | 99 | Failed, | |
| 90 | 100 | Pending, | |
| @@ -99,13 +109,18 @@ | |||
| 99 | 109 | /// [`Status::Unknown`] outranks [`Status::Degraded`] deliberately: a source | |
| 100 | 110 | /// that cannot be reached is as important as one reporting a failure. A | |
| 101 | 111 | /// silent gap is the failure mode this whole contract exists to close. | |
| 112 | + | /// [`Status::Undistributed`] sits just above [`Status::Ok`] and below | |
| 113 | + | /// [`Status::Pending`]: it is the quietest thing that is not actually done, | |
| 114 | + | /// and a finished build awaiting an upload is further along than one that | |
| 115 | + | /// never started. | |
| 102 | 116 | pub fn severity(self) -> u8 { | |
| 103 | 117 | match self { | |
| 104 | 118 | Status::Ok => 0, | |
| 105 | - | Status::Pending => 1, | |
| 106 | - | Status::Degraded => 2, | |
| 107 | - | Status::Unknown => 3, | |
| 108 | - | Status::Failed => 4, | |
| 119 | + | Status::Undistributed => 1, | |
| 120 | + | Status::Pending => 2, | |
| 121 | + | Status::Degraded => 3, | |
| 122 | + | Status::Unknown => 4, | |
| 123 | + | Status::Failed => 5, | |
| 109 | 124 | } | |
| 110 | 125 | } | |
| 111 | 126 | ||
| @@ -113,6 +128,7 @@ | |||
| 113 | 128 | pub fn as_str(self) -> &'static str { | |
| 114 | 129 | match self { | |
| 115 | 130 | Status::Ok => "ok", | |
| 131 | + | Status::Undistributed => "undistributed", | |
| 116 | 132 | Status::Degraded => "degraded", | |
| 117 | 133 | Status::Failed => "failed", | |
| 118 | 134 | Status::Pending => "pending", | |
| @@ -141,6 +157,7 @@ | |||
| 141 | 157 | // point. | |
| 142 | 158 | Ok(match String::deserialize(d)?.as_str() { | |
| 143 | 159 | "ok" | "pass" => Status::Ok, | |
| 160 | + | "undistributed" => Status::Undistributed, | |
| 144 | 161 | "degraded" => Status::Degraded, | |
| 145 | 162 | "failed" | "fail" => Status::Failed, | |
| 146 | 163 | "pending" => Status::Pending, |