OXYGENHUB
◆ v0.9.23 oxygenhub / crates / hub / src / runner_updates.rs
24.5 KiB · Rust · last changed A phone's runner follows the app's releases: never offered a runner build (refs andymccall/oxygenflow#84) 7 days ago source blame history raw
1
//! Runner updates (oxygenhub#322, OxygenFlow docs/jetstream.md §8.2):
2
//! which build each runner should run, under the policy the site, its
3
//! owner, its test groups and the runner itself set, the nearest setting
4
//! winning. Off unless some level turns updates on.
5
6
use crate::db::runner_updates::{self as rdb, Group, Policy, Soak};
7
use crate::db::runners::Runner;
8
use crate::db::{self, Pool};
9
use flow_core::api::{UpdateOffer, UpdatePolicy};
10
use std::collections::{BTreeSet, HashMap};
11
12
/// The site setting naming the repository whose releases carry runner
13
/// builds (`owner/name`), each an asset `flow-OS-ARCH`.
14
pub const SETTING_REPO: &str = "runner_updates_repo";
15
/// The site settings for proving a version in a wave (oxygenflow#84): the
16
/// jobs each runner of the wave that took it must finish, or the hours
17
/// since its first job there after which one job is enough.
18
pub const SETTING_SOAK_JOBS: &str = "runner_updates_soak_jobs";
19
pub const SETTING_SOAK_HOURS: &str = "runner_updates_soak_hours";
20
pub const SOAK_JOBS: i64 = 5;
21
pub const SOAK_HOURS: i64 = 24;
22
23
/// Share an offered build with the swarm (oxygenflow#84): cut the release
24
/// asset into the hub's image chunks and keep its manifest, named, as an
25
/// image is, by the build's BLAKE3, so a runner in a swarm fetches it from
26
/// its site's peers and the hub together, as it fetches images, and the
27
/// hub serves it about once per site. Done once, in the background, the
28
/// first time the build is offered; a runner asking before it is done
29
/// fetches the build over HTTP.
30
pub fn share(app: &crate::Shared, offer: &UpdateOffer) {
31
    static SHARING: std::sync::Mutex<BTreeSet<String>> = std::sync::Mutex::new(BTreeSet::new());
32
    let store = app.images();
33
    let hash = offer.blake3.clone();
34
    if !jet::images::is_hash(&hash) || store.manifest(&hash).is_some() {
35
        return;
36
    }
37
    if !SHARING.lock().unwrap_or_else(|p| p.into_inner()).insert(hash.clone()) {
38
        return;
39
    }
40
    let (app, offer) = (app.clone(), offer.clone());
41
    tokio::spawn(async move {
42
        let path = crate::web::releases::blob_path(&app, &hash);
43
        let arch = offer.asset.rsplit('-').next().map(|a| a.trim_end_matches(".exe").to_string());
44
        let want = hash.clone();
45
        let made = tokio::task::spawn_blocking(move || -> Result<jet::images::Manifest, String> {
46
            let m = jet::images::scan_file(&path, arch, |c, bytes| Ok(store.put_chunk(c.hash(), bytes)?))
47
                .map_err(|e| format!("reading {}: {e}", path.display()))?;
48
            if m.hash != want {
49
                return Err(format!("the asset hashes to {}, not {}", &m.hash[..12], &want[..12]));
50
            }
51
            store.verify_and_keep(&m)?;
52
            Ok(m)
53
        })
54
        .await
55
        .unwrap_or_else(|e| Err(e.to_string()));
56
        match made {
57
            Ok(m) => match crate::images::record(&app.db, &m, &format!("runner build flow {}", offer.version)).await {
58
                Ok(()) => tracing::info!(
59
                    "runner updates: flow {} ({}) shared with the swarm: {} chunks",
60
                    offer.version,
61
                    offer.asset,
62
                    m.chunks.len()
63
                ),
64
                Err(e) => tracing::warn!("runner updates: recording flow {} for the swarm: {e}", offer.version),
65
            },
66
            Err(e) => tracing::warn!("runner updates: sharing flow {} ({}) with the swarm: {e}", offer.version, offer.asset),
67
        }
68
        SHARING.lock().unwrap_or_else(|p| p.into_inner()).remove(&hash);
69
    });
70
}
71
72
/// A runner's place, 0 to 99, for percentage groups: a stable hash of its
73
/// id, so a group's share is the same machines every rollout, and a
74
/// smaller share sits inside a larger one.
75
pub fn bucket(runner_id: i64) -> i64 {
76
    let h = blake3::hash(format!("runner {runner_id}").as_bytes());
77
    let mut first = [0u8; 8];
78
    first.copy_from_slice(&h.as_bytes()[..8]);
79
    (u64::from_le_bytes(first) % 100) as i64
80
}
81
82
/// Whether a group may hold the runner at all: a site group holds any, an
83
/// owner's group only that owner's runners.
84
pub fn in_reach(g: &Group, r: &Runner) -> bool {
85
    g.owner.is_empty() || g.owner == r.scope_owner
86
}
87
88
/// Every level's settings and the groups, read once.
89
#[derive(Clone, Debug, Default)]
90
pub struct Levels {
91
    pub policies: HashMap<(String, String), Policy>,
92
    pub groups: Vec<Group>,
93
    /// Chosen members, as (group, runner).
94
    pub chosen: BTreeSet<(i64, i64)>,
95
}
96
97
impl Levels {
98
    pub async fn load(pool: &Pool) -> sqlx::Result<Levels> {
99
        let policies = rdb::policies(pool).await?.into_iter().map(|p| ((p.scope.clone(), p.key.clone()), p)).collect();
100
        Ok(Levels { policies, groups: rdb::groups(pool).await?, chosen: rdb::members(pool).await?.into_iter().collect() })
101
    }
102
103
    pub fn member(&self, g: &Group, r: &Runner) -> bool {
104
        in_reach(g, r) && (self.chosen.contains(&(g.id, r.id)) || g.percent.is_some_and(|p| bucket(r.id) < p))
105
    }
106
107
    /// The runner's groups, nearest first: its owner's, then the site's.
108
    pub fn groups_of(&self, r: &Runner) -> Vec<&Group> {
109
        let mut gs: Vec<&Group> = self.groups.iter().filter(|g| self.member(g, r)).collect();
110
        gs.sort_by_key(|g| (g.owner.is_empty(), g.name.clone()));
111
        gs
112
    }
113
114
    fn get(&self, scope: &str, key: &str) -> Option<&Policy> {
115
        self.policies.get(&(scope.to_string(), key.to_string()))
116
    }
117
118
    /// The wave `r` takes a version in: the first of its groups' waves;
119
    /// `None` after every wave.
120
    pub fn wave_of(&self, r: &Runner) -> Option<i64> {
121
        self.groups_of(r).iter().filter_map(|g| g.wave).min()
122
    }
123
124
    /// The waves before `r`'s, in order, each with its runners among `all`:
125
    /// the waves of the groups that may hold `r` (the site's, and its
126
    /// owner's), a runner counted in its own first wave.
127
    pub fn waves_before<'a>(&self, r: &Runner, all: &'a [Runner]) -> Vec<(i64, Vec<&'a Runner>)> {
128
        let mine = self.wave_of(r);
129
        let mut waves: Vec<i64> =
130
            self.groups.iter().filter(|g| in_reach(g, r)).filter_map(|g| g.wave).filter(|w| mine.is_none_or(|m| *w < m)).collect();
131
        waves.sort();
132
        waves.dedup();
133
        waves
134
            .into_iter()
135
            .map(|w| {
136
                let members = all
137
                    .iter()
138
                    .filter(|o| self.wave_of(o) == Some(w) && self.groups_of(o).iter().any(|g| g.wave == Some(w) && in_reach(g, r)))
139
                    .collect();
140
                (w, members)
141
            })
142
            .collect()
143
    }
144
145
    /// The levels that apply to `r`, nearest first, each with its name.
146
    fn chain(&self, r: &Runner) -> Vec<(String, &Policy)> {
147
        let mut out = vec![];
148
        if let Some(p) = self.get("runner", &r.id.to_string()) {
149
            out.push(("runner".to_string(), p));
150
        }
151
        for g in self.groups_of(r) {
152
            if let Some(p) = self.get("group", &g.id.to_string()) {
153
                out.push((format!("group {}", g.name), p));
154
            }
155
        }
156
        if !r.scope_owner.is_empty() {
157
            if let Some(p) = self.get("owner", &r.scope_owner) {
158
                out.push((format!("owner {}", r.scope_owner), p));
159
            }
160
        }
161
        if let Some(p) = self.get("site", "") {
162
            out.push(("site".to_string(), p));
163
        }
164
        out
165
    }
166
167
    /// The policy for `r`: each setting from the nearest level that sets it.
168
    pub fn resolve(&self, r: &Runner) -> UpdatePolicy {
169
        let chain = self.chain(r);
170
        fn pick<T>(chain: &[(String, &Policy)], f: impl Fn(&Policy) -> Option<T>) -> (Option<T>, String) {
171
            chain.iter().find_map(|(name, p)| f(p).map(|v| (Some(v), name.clone()))).unwrap_or((None, "default".into()))
172
        }
173
        let (updates, u_from) = pick(&chain, |p| p.updates);
174
        let (channel, c_from) = pick(&chain, |p| p.channel.clone().filter(|c| !c.is_empty()));
175
        let (window, w_from) = pick(&chain, |p| Some((p.window_from? as u8, p.window_to? as u8)));
176
        let (dry_run, d_from) = pick(&chain, |p| p.dry_run);
177
        UpdatePolicy {
178
            updates: updates.unwrap_or(false),
179
            channel: channel.unwrap_or_else(|| "stable".into()),
180
            window,
181
            dry_run: dry_run.unwrap_or(false),
182
            from: vec![u_from, c_from, w_from, d_from],
183
        }
184
    }
185
}
186
187
/// Whether hour `h` (UTC) falls in `[from, to)`, which may wrap past midnight.
188
pub fn in_window((from, to): (u8, u8), h: u8) -> bool {
189
    if from <= to {
190
        from <= h && h < to
191
    } else {
192
        h >= from || h < to
193
    }
194
}
195
196
/// Parse `02-05` (hours, UTC) as a window; blank is none.
197
pub fn parse_window(s: &str) -> Result<Option<(u8, u8)>, String> {
198
    let s = s.trim();
199
    if s.is_empty() {
200
        return Ok(None);
201
    }
202
    let (a, b) = s.split_once('-').ok_or("a window is two hours, from-to, e.g. 02-05")?;
203
    let hour = |x: &str| x.trim().trim_end_matches(":00").parse::<u8>().ok().filter(|h| *h < 24);
204
    match (hour(a), hour(b)) {
205
        (Some(a), Some(b)) if a != b => Ok(Some((a, b))),
206
        _ => Err("a window is two different hours from 0 to 23, from-to, e.g. 02-05".into()),
207
    }
208
}
209
210
/// A channel as written: `stable`, `canary`, or a version.
211
pub fn valid_channel(c: &str) -> bool {
212
    matches!(c, "stable" | "canary") || (!c.is_empty() && c.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())))
213
}
214
215
/// The version in a runner's or a tag's words: `0.3.1 (build)` → `0.3.1`,
216
/// `v0.3.1` → `0.3.1`.
217
pub fn version_of(s: &str) -> String {
218
    s.split_whitespace().next().unwrap_or("").trim_start_matches('v').to_string()
219
}
220
221
fn older(a: &str, b: &str) -> bool {
222
    let parts = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
223
    parts(a) < parts(b)
224
}
225
226
/// The version a runner's report says rolled back: `rolled back from flow
227
/// 0.4.0 to 0.3.1: …` (the runner's words, oxygenflow spec §11.5.3) → `0.4.0`.
228
pub fn rolled_back_version(note: &str) -> Option<String> {
229
    let v = note.strip_prefix("rolled back from flow ")?.split_whitespace().next()?;
230
    v.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).then(|| v.to_string())
231
}
232
233
/// The release asset a runner on `os` and `arch` runs: `flow-linux-x86_64`,
234
/// `flow-windows-x86_64.exe`.
235
pub fn asset_for(os: &str, arch: &str) -> String {
236
    format!("flow-{os}-{arch}{}", if os == "windows" { ".exe" } else { "" })
237
}
238
239
/// Where a wave stands with a version (oxygenflow#84).
240
#[derive(Clone, Debug, PartialEq, Eq)]
241
pub enum WaveState {
242
    /// Every runner of the wave that took it has finished its soak jobs on
243
    /// it, or the soak time has passed since its first job there; or the
244
    /// wave has no runners.
245
    Proved,
246
    /// Not yet: `on` of `runners` run it, with `jobs` jobs between them.
247
    Soaking { runners: usize, on: usize, jobs: i64 },
248
    /// A job on it ended in error on this runner: the version stops here.
249
    Stopped { runner_id: i64 },
250
}
251
252
/// Where a wave of `members` stands with a version, from each runner's
253
/// [`Soak`] on it, at `now` (ms).
254
pub fn wave_state(members: &[i64], soaks: &[Soak], soak_jobs: i64, soak_ms: i64, now: i64) -> WaveState {
255
    let mine: Vec<&Soak> = soaks.iter().filter(|s| members.contains(&s.runner_id) && s.jobs > 0).collect();
256
    if let Some(s) = mine.iter().find(|s| s.errors > 0) {
257
        return WaveState::Stopped { runner_id: s.runner_id };
258
    }
259
    if members.is_empty() {
260
        return WaveState::Proved;
261
    }
262
    let jobs = mine.iter().map(|s| s.jobs).sum();
263
    let soaked = mine.iter().filter_map(|s| s.first_ms).min().is_some_and(|first| now - first >= soak_ms);
264
    if !mine.is_empty() && (soaked || mine.iter().all(|s| s.jobs >= soak_jobs)) {
265
        WaveState::Proved
266
    } else {
267
        WaveState::Soaking { runners: members.len(), on: mine.len(), jobs }
268
    }
269
}
270
271
/// What the waves before `r`'s say about `version`: `Ok(None)` go ahead;
272
/// `Ok(Some(why))` wait; `Err((runner, why))` it stopped on that runner.
273
async fn wave_gate(
274
    pool: &Pool,
275
    levels: &Levels,
276
    r: &Runner,
277
    version: &str,
278
    now: i64,
279
) -> sqlx::Result<Result<Option<String>, (String, String)>> {
280
    let all = db::runners::list(pool).await?;
281
    let waves = levels.waves_before(r, &all);
282
    if waves.is_empty() {
283
        return Ok(Ok(None));
284
    }
285
    let setting = |v: Option<String>, d: i64| v.and_then(|s| s.trim().parse::<i64>().ok()).filter(|n| *n > 0).unwrap_or(d);
286
    let soak_jobs = setting(db::accounts::setting(pool, SETTING_SOAK_JOBS).await?, SOAK_JOBS);
287
    let soak_hours = setting(db::accounts::setting(pool, SETTING_SOAK_HOURS).await?, SOAK_HOURS);
288
    let soaks = rdb::soaks(pool, version).await?;
289
    for (w, members) in waves {
290
        let ids: Vec<i64> = members.iter().map(|m| m.id).collect();
291
        match wave_state(&ids, &soaks, soak_jobs, soak_hours * 3_600_000, now) {
292
            WaveState::Proved => continue,
293
            WaveState::Stopped { runner_id } => {
294
                let name = members.iter().find(|m| m.id == runner_id).map(|m| m.name.clone()).unwrap_or_default();
295
                let job = rdb::first_error(pool, version, runner_id).await?.unwrap_or_else(|| "a job".into());
296
                return Ok(Err((name.clone(), format!("wave {w} stopped it on {name}: {job} ended in error"))));
297
            }
298
            WaveState::Soaking { runners, on, jobs } => {
299
                return Ok(Ok(Some(format!(
300
                    "flow {version} waits for wave {w} to prove it: {on} of {runners} runner{} on it, {jobs} job{} (each needs {soak_jobs} without an error, or {soak_hours} h)",
301
                    if runners == 1 { "" } else { "s" },
302
                    if jobs == 1 { "" } else { "s" },
303
                ))));
304
            }
305
        }
306
    }
307
    Ok(Ok(None))
308
}
309
310
/// The build `r` should install under `policy` at hour `hour` (UTC), or
311
/// why there is none. A version a wave stopped is held back from every
312
/// runner when `act` (a runner asking; a page only says so).
313
pub async fn offer(
314
    pool: &Pool,
315
    levels: &Levels,
316
    r: &Runner,
317
    policy: &UpdatePolicy,
318
    hour: u8,
319
    now: i64,
320
    act: bool,
321
) -> sqlx::Result<(Option<UpdateOffer>, String)> {
322
    if !policy.updates {
323
        return Ok((None, format!("updates are off ({})", policy.from[0])));
324
    }
325
    // A phone's runner is part of the OxygenHub app (oxygenflow#84,
326
    // oxygenhub-android#7): it updates when the app does, never from a
327
    // runner build.
328
    if r.os == "android" {
329
        return Ok((
330
            None,
331
            format!("follows the OxygenHub app's releases: runs flow {} (update the app to update it)", version_of(&r.version)),
332
        ));
333
    }
334
    let Some(spec) = db::accounts::setting(pool, SETTING_REPO).await?.filter(|s| !s.trim().is_empty()) else {
335
        return Ok((None, "no runner releases repository is set (Admin › Runners › Updates)".into()));
336
    };
337
    let Some((owner, name)) = spec.trim().split_once('/') else {
338
        return Ok((None, format!("the runner releases repository {spec:?} is not owner/name")));
339
    };
340
    let Some(repo) = db::repos::get(pool, owner, name).await? else {
341
        return Ok((None, format!("the runner releases repository {spec} does not exist")));
342
    };
343
    let releases = db::releases::list(pool, repo.id).await?;
344
    let release = match policy.channel.as_str() {
345
        "stable" => releases.iter().find(|x| !x.prerelease),
346
        "canary" => releases.first(),
347
        v => releases.iter().find(|x| version_of(&x.tag) == v),
348
    };
349
    let Some(release) = release else {
350
        return Ok((None, format!("{spec} has no release for the {} channel", policy.channel)));
351
    };
352
    let (want, have) = (version_of(&release.tag), version_of(&r.version));
353
    if want == have {
354
        return Ok((None, format!("up to date: flow {have} ({} channel)", policy.channel)));
355
    }
356
    if let Some(h) = rdb::held(pool, &want).await? {
357
        return Ok((None, format!("flow {want} is held: {}; lift the hold on Admin › Runners › Updates", h.why)));
358
    }
359
    let going_back = older(&want, &have);
360
    if going_back && matches!(policy.channel.as_str(), "stable" | "canary") {
361
        return Ok((None, format!("runs flow {have}, newer than the {} channel's {want}", policy.channel)));
362
    }
363
    // Waves (oxygenflow#84): a later wave takes a version once those before it proved it.
364
    if !going_back {
365
        match wave_gate(pool, levels, r, &want, now).await? {
366
            Ok(None) => {}
367
            Ok(Some(why)) => return Ok((None, why)),
368
            Err((runner, why)) => {
369
                if act {
370
                    rdb::hold(pool, &want, &runner, &why).await?;
371
                    db::accounts::audit(pool, None, "runner.update.held", &want, &why).await.ok();
372
                }
373
                return Ok((None, format!("flow {want} is held: {why}; lift the hold on Admin › Runners › Updates")));
374
            }
375
        }
376
    }
377
    let file = asset_for(&r.os, &r.arch);
378
    let assets = db::releases::assets(pool, release.id).await?;
379
    let Some(a) = assets.iter().find(|a| a.name == file) else {
380
        return Ok((None, format!("{spec} {} has no {file}", release.tag)));
381
    };
382
    if let Some(w) = policy.window.filter(|w| !in_window(*w, hour)) {
383
        return Ok((None, format!("flow {want} waits for its window, {:02}:00–{:02}:00 UTC", w.0, w.1)));
384
    }
385
    let note = format!(
386
        "flow {want} ({} channel){}",
387
        policy.channel,
388
        if policy.dry_run { ": dry run, check it and say what would happen" } else { "" }
389
    );
390
    Ok((
391
        Some(UpdateOffer {
392
            version: want,
393
            tag: release.tag.clone(),
394
            asset: a.name.clone(),
395
            url: format!("/{owner}/{name}/asset/{}/{}", a.id, a.name),
396
            blake3: a.hash.clone(),
397
            size: a.size as u64,
398
            older: going_back,
399
        }),
400
        note,
401
    ))
402
}
403
404
#[cfg(test)]
405
mod tests {
406
    use super::*;
407
408
    fn runner(id: i64, owner: &str) -> Runner {
409
        Runner { id, scope_owner: owner.into(), version: "0.3.1 (build)".into(), ..Runner::default() }
410
    }
411
412
    #[test]
413
    fn a_smaller_share_sits_inside_a_larger_one_and_is_about_its_size() {
414
        let ten: Vec<i64> = (1..=1000).filter(|id| bucket(*id) < 10).collect();
415
        let fifty: Vec<i64> = (1..=1000).filter(|id| bucket(*id) < 50).collect();
416
        assert!(ten.iter().all(|id| fifty.contains(id)));
417
        assert!((60..=140).contains(&ten.len()), "{}", ten.len());
418
        assert_eq!(bucket(7), bucket(7), "stable");
419
    }
420
421
    #[test]
422
    fn the_nearest_level_wins_each_setting() {
423
        let mut l = Levels::default();
424
        let pol = |scope: &str, key: &str, f: &dyn Fn(&mut Policy)| {
425
            let mut p = Policy { scope: scope.into(), key: key.into(), ..Policy::default() };
426
            f(&mut p);
427
            ((scope.to_string(), key.to_string()), p)
428
        };
429
        l.policies.extend([
430
            pol("site", "", &|p| {
431
                p.updates = Some(false);
432
                p.channel = Some("stable".into());
433
            }),
434
            pol("owner", "acme", &|p| p.updates = Some(true)),
435
            pol("group", "1", &|p| p.channel = Some("canary".into())),
436
            pol("runner", "3", &|p| p.updates = Some(false)),
437
        ]);
438
        l.groups.push(Group { id: 1, owner: "acme".into(), name: "canary".into(), percent: None, created_ms: 0, wave: None });
439
        l.chosen.insert((1, 2));
440
        l.chosen.insert((1, 9));
441
442
        let site_runner = l.resolve(&runner(1, ""));
443
        assert!(!site_runner.updates, "off at the site");
444
        assert_eq!(site_runner.from, ["site", "site", "default", "default"]);
445
446
        let canary = l.resolve(&runner(2, "acme"));
447
        assert!(canary.updates && canary.channel == "canary");
448
        assert_eq!(canary.from[..2], ["owner acme".to_string(), "group canary".to_string()]);
449
450
        let held = l.resolve(&runner(3, "acme"));
451
        assert!(!held.updates, "the runner's own setting wins");
452
        assert_eq!(held.channel, "stable");
453
454
        assert!(l.groups_of(&runner(9, "other")).is_empty(), "an owner's group holds only its own runners");
455
        assert!(!Levels::default().resolve(&runner(4, "")).updates, "off unless chosen");
456
    }
457
458
    #[test]
459
    fn a_percentage_group_takes_its_share_of_the_runners_in_reach() {
460
        let mut l = Levels::default();
461
        l.groups.push(Group { id: 5, owner: String::new(), name: "farm 20%".into(), percent: Some(20), created_ms: 0, wave: None });
462
        let n = (1..=500).filter(|id| !l.groups_of(&runner(*id, "")).is_empty()).count();
463
        assert!((60..=140).contains(&n), "{n}");
464
    }
465
466
    #[test]
467
    fn a_wave_proves_a_version_by_its_jobs_or_its_time_and_an_error_stops_it() {
468
        let soak = |runner_id, jobs, errors, first_ms| Soak { runner_id, jobs, errors, first_ms: Some(first_ms) };
469
        let hour = 3_600_000;
470
        // Nobody has taken it yet: not proved.
471
        assert_eq!(wave_state(&[1, 2], &[], 5, 24 * hour, 0), WaveState::Soaking { runners: 2, on: 0, jobs: 0 });
472
        // One runner took it and ran 3 of its 5; the other has not updated.
473
        let s = [soak(1, 3, 0, 0), soak(9, 50, 0, 0)];
474
        assert_eq!(wave_state(&[1, 2], &s, 5, 24 * hour, hour), WaveState::Soaking { runners: 2, on: 1, jobs: 3 });
475
        // Five each on every runner that took it proves it.
476
        assert_eq!(wave_state(&[1, 2], &[soak(1, 5, 0, 0)], 5, 24 * hour, hour), WaveState::Proved);
477
        // Or a day since the first job there, with no error.
478
        assert_eq!(wave_state(&[1, 2], &[soak(1, 1, 0, 0)], 5, 24 * hour, 24 * hour), WaveState::Proved);
479
        // An error anywhere in the wave stops it, however many passed.
480
        assert_eq!(
481
            wave_state(&[1, 2], &[soak(1, 40, 0, 0), soak(2, 2, 1, 0)], 5, 24 * hour, 48 * hour),
482
            WaveState::Stopped { runner_id: 2 }
483
        );
484
        // Another wave's errors are not this one's.
485
        assert_eq!(wave_state(&[1], &[soak(1, 5, 0, 0), soak(2, 2, 1, 0)], 5, 24 * hour, hour), WaveState::Proved);
486
        // An empty wave holds nothing up.
487
        assert_eq!(wave_state(&[], &[], 5, 24 * hour, 0), WaveState::Proved);
488
    }
489
490
    #[test]
491
    fn a_runner_waits_for_the_waves_before_its_own() {
492
        let mut l = Levels::default();
493
        let group = |id, name: &str, wave| Group { id, owner: String::new(), name: name.into(), percent: None, created_ms: 0, wave };
494
        l.groups.extend([group(1, "first", Some(1)), group(2, "second", Some(2)), group(3, "watchers", None)]);
495
        l.chosen.extend([(1, 1), (2, 2), (2, 1), (3, 3)]);
496
        let all: Vec<Runner> = (1..=4).map(|id| runner(id, "")).collect();
497
        let ids = |ws: Vec<(i64, Vec<&Runner>)>| {
498
            ws.into_iter().map(|(w, rs)| (w, rs.iter().map(|r| r.id).collect::<Vec<_>>())).collect::<Vec<_>>()
499
        };
500
        assert_eq!(l.wave_of(&all[0]), Some(1), "its first wave");
501
        assert!(l.waves_before(&all[0], &all).is_empty(), "wave 1 goes first");
502
        assert_eq!(ids(l.waves_before(&all[1], &all)), vec![(1, vec![1])]);
503
        assert_eq!(l.wave_of(&all[2]), None, "a group with no wave");
504
        assert_eq!(ids(l.waves_before(&all[2], &all)), vec![(1, vec![1]), (2, vec![2])], "in no wave: after every wave");
505
        assert_eq!(ids(l.waves_before(&all[3], &all)), vec![(1, vec![1]), (2, vec![2])]);
506
        // An owner's waves gate only that owner's runners.
507
        l.groups.push(Group { id: 4, owner: "acme".into(), name: "acme first".into(), percent: None, created_ms: 0, wave: Some(1) });
508
        l.chosen.insert((4, 5));
509
        let mut all = all;
510
        all.push(runner(5, "acme"));
511
        all.push(runner(6, "acme"));
512
        assert_eq!(ids(l.waves_before(&all[5], &all)), vec![(1, vec![1, 5]), (2, vec![2])]);
513
        assert_eq!(ids(l.waves_before(&all[3], &all)), vec![(1, vec![1]), (2, vec![2])], "not another owner's");
514
    }
515
516
    #[test]
517
    fn windows_and_channels_read_as_written() {
518
        assert_eq!(parse_window("02-05"), Ok(Some((2, 5))));
519
        assert_eq!(parse_window(" 22:00-04:00 "), Ok(Some((22, 4))));
520
        assert_eq!(parse_window(""), Ok(None));
521
        assert!(parse_window("5").is_err() && parse_window("3-3").is_err() && parse_window("1-24").is_err());
522
        assert!(in_window((2, 5), 2) && in_window((2, 5), 4) && !in_window((2, 5), 5));
523
        assert!(in_window((22, 4), 23) && in_window((22, 4), 1) && !in_window((22, 4), 12));
524
        assert!(valid_channel("stable") && valid_channel("canary") && valid_channel("0.3.4"));
525
        assert!(!valid_channel("latest") && !valid_channel("0.3.") && !valid_channel(""));
526
        assert_eq!(version_of("v0.3.2"), "0.3.2");
527
        assert_eq!(version_of("0.3.1 (local build)"), "0.3.1");
528
        assert_eq!(asset_for("linux", "riscv64"), "flow-linux-riscv64");
529
        assert_eq!(asset_for("windows", "x86_64"), "flow-windows-x86_64.exe");
530
        assert_eq!(
531
            rolled_back_version("rolled back from flow 0.4.0 to 0.3.1: 3 starts without reaching the hub").as_deref(),
532
            Some("0.4.0")
533
        );
534
        assert_eq!(rolled_back_version("runs flow 0.4.0 (was 0.3.1); proven"), None);
535
    }
536
}