OXYGENHUB

Coverage / crates/hub/src/runner_updates.rs 98.2%

373 of 380 lines run, 143 of 167 functions · run #521 on main · run missed

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
/// A runner's place, 0 to 99, for percentage groups: a stable hash of its
24
/// id, so a group's share is the same machines every rollout, and a
25
/// smaller share sits inside a larger one.
262520×
pub fn bucket(runner_id: i64) -> i64 {
272520×
    let h = blake3::hash(format!("runner {runner_id}").as_bytes());
282520×
    let mut first = [0u8; 8];
292520×
    first.copy_from_slice(&h.as_bytes()[..8]);
302520×
    (u64::from_le_bytes(first) % 100) as i64
312520×
}
32
33
/// Whether a group may hold the runner at all: a site group holds any, an
34
/// owner's group only that owner's runners.
351114×
pub fn in_reach(g: &Group, r: &Runner) -> bool {
361114×
    g.owner.is_empty() || g.owner == r.scope_owner
371114×
}
38
39
/// Every level's settings and the groups, read once.
40
#[derive(Clone, Debug, Default)]
41
pub struct Levels {
42
    pub policies: HashMap<(String, String), Policy>,
43
    pub groups: Vec<Group>,
44
    /// Chosen members, as (group, runner).
45
    pub chosen: BTreeSet<(i64, i64)>,
46
}
47
48
impl Levels {
4978×
    pub async fn load(pool: &Pool) -> sqlx::Result<Levels> {
5097×
        let policies = rdb::policies(pool).await?.into_iter().map(|p| ((p.scope.clone(), p.key.clone()), p)).collect();
5178×
        Ok(Levels { policies, groups: rdb::groups(pool).await?, chosen: rdb::members(pool).await?.into_iter().collect() })
5278×
    }
53
541032×
    pub fn member(&self, g: &Group, r: &Runner) -> bool {
551032×
        in_reach(g, r) && (self.chosen.contains(&(g.id, r.id)) || g.percent.is_some_and(|p| bucket(r.id) < p))
561032×
    }
57
58
    /// The runner's groups, nearest first: its owner's, then the site's.
59794×
    pub fn groups_of(&self, r: &Runner) -> Vec<&Group> {
60967×
        let mut gs: Vec<&Group> = self.groups.iter().filter(|g| self.member(g, r)).collect();
61794×
        gs.sort_by_key(|g| (g.owner.is_empty(), g.name.clone()));
62794×
        gs
63794×
    }
64
65267×
    fn get(&self, scope: &str, key: &str) -> Option<&Policy> {
66267×
        self.policies.get(&(scope.to_string(), key.to_string()))
67267×
    }
68
69
    /// The wave `r` takes a version in: the first of its groups' waves;
70
    /// `None` after every wave.
71103×
    pub fn wave_of(&self, r: &Runner) -> Option<i64> {
72103×
        self.groups_of(r).iter().filter_map(|g| g.wave).min()
73103×
    }
74
75
    /// The waves before `r`'s, in order, each with its runners among `all`:
76
    /// the waves of the groups that may hold `r` (the site's, and its
77
    /// owner's), a runner counted in its own first wave.
7839×
    pub fn waves_before<'a>(&self, r: &Runner, all: &'a [Runner]) -> Vec<(i64, Vec<&'a Runner>)> {
7939×
        let mine = self.wave_of(r);
8039×
        let mut waves: Vec<i64> =
8162×
            self.groups.iter().filter(|g| in_reach(g, r)).filter_map(|g| g.wave).filter(|w| mine.is_none_or(|m| *w < m)).collect();
8239×
        waves.sort();
8339×
        waves.dedup();
8439×
        waves
8539×
            .into_iter()
8639×
            .map(|w| {
8718×
                let members = all
8818×
                    .iter()
8962×
                    .filter(|o| self.wave_of(o) == Some(w) && self.groups_of(o).iter().any(|g| g.wave == Some(w) && in_reach(g, r)))
9018×
                    .collect();
9118×
                (w, members)
9218×
            })
9339×
            .collect()
9439×
    }
95
96
    /// The levels that apply to `r`, nearest first, each with its name.
9796×
    fn chain(&self, r: &Runner) -> Vec<(String, &Policy)> {
9896×
        let mut out = vec![];
9996×
        if let Some(p) = self.get("runner", &r.id.to_string()) {
10020×
            out.push(("runner".to_string(), p));
10176×
        }
10296×
        for g in self.groups_of(r) {
10351×
            if let Some(p) = self.get("group", &g.id.to_string()) {
10427×
                out.push((format!("group {}", g.name), p));
10527×
            }
106
        }
10796×
        if !r.scope_owner.is_empty() {
10824×
            if let Some(p) = self.get("owner", &r.scope_owner) {
10922×
                out.push((format!("owner {}", r.scope_owner), p));
11022×
            }
11172×
        }
11296×
        if let Some(p) = self.get("site", "") {
11358×
            out.push(("site".to_string(), p));
11458×
        }
11596×
        out
11696×
    }
117
118
    /// The policy for `r`: each setting from the nearest level that sets it.
11996×
    pub fn resolve(&self, r: &Runner) -> UpdatePolicy {
12096×
        let chain = self.chain(r);
121384×
        fn pick<T>(chain: &[(String, &Policy)], f: impl Fn(&Policy) -> Option<T>) -> (Option<T>, String) {
122418×
            chain.iter().find_map(|(name, p)| f(p).map(|v| (Some(v), name.clone()))).unwrap_or((None, "default".into()))
123384×
        }
12496×
        let (updates, u_from) = pick(&chain, |p| p.updates);
125103×
        let (channel, c_from) = pick(&chain, |p| p.channel.clone().filter(|c| !c.is_empty()));
126111×
        let (window, w_from) = pick(&chain, |p| Some((p.window_from? as u8, p.window_to? as u8)));
12796×
        let (dry_run, d_from) = pick(&chain, |p| p.dry_run);
128
        UpdatePolicy {
12996×
            updates: updates.unwrap_or(false),
13096×
            channel: channel.unwrap_or_else(|| "stable".into()),
13196×
            window,
13296×
            dry_run: dry_run.unwrap_or(false),
13396×
            from: vec![u_from, c_from, w_from, d_from],
134
        }
13596×
    }
136
}
137
138
/// Whether hour `h` (UTC) falls in `[from, to)`, which may wrap past midnight.
13917×
pub fn in_window((from, to): (u8, u8), h: u8) -> bool {
14017×
    if from <= to {
1413×
        from <= h && h < to
142
    } else {
14314×
        h >= from || h < to
144
    }
14517×
}
146
147
/// Parse `02-05` (hours, UTC) as a window; blank is none.
14816×
pub fn parse_window(s: &str) -> Result<Option<(u8, u8)>, String> {
14916×
    let s = s.trim();
15016×
    if s.is_empty() {
1519×
        return Ok(None);
1527×
    }
1537×
    let (a, b) = s.split_once('-').ok_or("a window is two hours, from-to, e.g. 02-05")?;
15410×
    let hour = |x: &str| x.trim().trim_end_matches(":00").parse::<u8>().ok().filter(|h| *h < 24);
1555×
    match (hour(a), hour(b)) {
1564×
        (Some(a), Some(b)) if a != b => Ok(Some((a, b))),
1572×
        _ => Err("a window is two different hours from 0 to 23, from-to, e.g. 02-05".into()),
158
    }
15916×
}
160
161
/// A channel as written: `stable`, `canary`, or a version.
16211×
pub fn valid_channel(c: &str) -> bool {
16315×
    matches!(c, "stable" | "canary") || (!c.is_empty() && c.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())))
16411×
}
165
166
/// The version in a runner's or a tag's words: `0.3.1 (build)` → `0.3.1`,
167
/// `v0.3.1` → `0.3.1`.
168254×
pub fn version_of(s: &str) -> String {
169254×
    s.split_whitespace().next().unwrap_or("").trim_start_matches('v').to_string()
170254×
}
171
17233×
fn older(a: &str, b: &str) -> bool {
173198×
    let parts = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
17433×
    parts(a) < parts(b)
17533×
}
176
177
/// The version a runner's report says rolled back: `rolled back from flow
178
/// 0.4.0 to 0.3.1: …` (the runner's words, oxygenflow spec §11.5.3) → `0.4.0`.
1794×
pub fn rolled_back_version(note: &str) -> Option<String> {
1804×
    let v = note.strip_prefix("rolled back from flow ")?.split_whitespace().next()?;
1816×
    v.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).then(|| v.to_string())
1824×
}
183
184
/// The release asset a runner on `os` and `arch` runs: `flow-linux-x86_64`,
185
/// `flow-windows-x86_64.exe`.
18628×
pub fn asset_for(os: &str, arch: &str) -> String {
18728×
    format!("flow-{os}-{arch}{}", if os == "windows" { ".exe" } else { "" })
18828×
}
189
190
/// Where a wave stands with a version (oxygenflow#84).
191
#[derive(Clone, Debug, PartialEq, Eq)]
192
pub enum WaveState {
193
    /// Every runner of the wave that took it has finished its soak jobs on
194
    /// it, or the soak time has passed since its first job there; or the
195
    /// wave has no runners.
196
    Proved,
197
    /// Not yet: `on` of `runners` run it, with `jobs` jobs between them.
198
    Soaking { runners: usize, on: usize, jobs: i64 },
199
    /// A job on it ended in error on this runner: the version stops here.
200
    Stopped { runner_id: i64 },
201
}
202
203
/// Where a wave of `members` stands with a version, from each runner's
204
/// [`Soak`] on it, at `now` (ms).
20516×
pub fn wave_state(members: &[i64], soaks: &[Soak], soak_jobs: i64, soak_ms: i64, now: i64) -> WaveState {
20616×
    let mine: Vec<&Soak> = soaks.iter().filter(|s| members.contains(&s.runner_id) && s.jobs > 0).collect();
20716×
    if let Some(s) = mine.iter().find(|s| s.errors > 0) {
2083×
        return WaveState::Stopped { runner_id: s.runner_id };
20913×
    }
21013×
    if members.is_empty() {
2111×
        return WaveState::Proved;
21212×
    }
21312×
    let jobs = mine.iter().map(|s| s.jobs).sum();
21412×
    let soaked = mine.iter().filter_map(|s| s.first_ms).min().is_some_and(|first| now - first >= soak_ms);
21512×
    if !mine.is_empty() && (soaked || mine.iter().all(|s| s.jobs >= soak_jobs)) {
2165×
        WaveState::Proved
217
    } else {
2187×
        WaveState::Soaking { runners: members.len(), on: mine.len(), jobs }
219
    }
22016×
}
221
222
/// What the waves before `r`'s say about `version`: `Ok(None)` go ahead;
223
/// `Ok(Some(why))` wait; `Err((runner, why))` it stopped on that runner.
22433×
async fn wave_gate(
22533×
    pool: &Pool,
22633×
    levels: &Levels,
22733×
    r: &Runner,
22833×
    version: &str,
22933×
    now: i64,
23033×
) -> sqlx::Result<Result<Option<String>, (String, String)>> {
23133×
    let all = db::runners::list(pool).await?;
23233×
    let waves = levels.waves_before(r, &all);
23333×
    if waves.is_empty() {
23424×
        return Ok(Ok(None));
2359×
    }
23618×
    let setting = |v: Option<String>, d: i64| v.and_then(|s| s.trim().parse::<i64>().ok()).filter(|n| *n > 0).unwrap_or(d);
2379×
    let soak_jobs = setting(db::accounts::setting(pool, SETTING_SOAK_JOBS).await?, SOAK_JOBS);
2389×
    let soak_hours = setting(db::accounts::setting(pool, SETTING_SOAK_HOURS).await?, SOAK_HOURS);
2399×
    let soaks = rdb::soaks(pool, version).await?;
2409×
    for (w, members) in waves {
2419×
        let ids: Vec<i64> = members.iter().map(|m| m.id).collect();
2429×
        match wave_state(&ids, &soaks, soak_jobs, soak_hours * 3_600_000, now) {
2432×
            WaveState::Proved => continue,
2442×
            WaveState::Stopped { runner_id } => {
2452×
                let name = members.iter().find(|m| m.id == runner_id).map(|m| m.name.clone()).unwrap_or_default();
2462×
                let job = rdb::first_error(pool, version, runner_id).await?.unwrap_or_else(|| "a job".into());
2472×
                return Ok(Err((name.clone(), format!("wave {w} stopped it on {name}: {job} ended in error"))));
248
            }
2495×
            WaveState::Soaking { runners, on, jobs } => {
2505×
                return Ok(Ok(Some(format!(
251
                    "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)",
2525×
                    if runners == 1 { "" } else { "s" },
2535×
                    if jobs == 1 { "" } else { "s" },
254
                ))));
255
            }
256
        }
257
    }
2582×
    Ok(Ok(None))
25933×
}
260
261
/// The build `r` should install under `policy` at hour `hour` (UTC), or
262
/// why there is none. A version a wave stopped is held back from every
263
/// runner when `act` (a runner asking; a page only says so).
26492×
pub async fn offer(
26592×
    pool: &Pool,
26692×
    levels: &Levels,
26792×
    r: &Runner,
26892×
    policy: &UpdatePolicy,
26992×
    hour: u8,
27092×
    now: i64,
27192×
    act: bool,
27292×
) -> sqlx::Result<(Option<UpdateOffer>, String)> {
27392×
    if !policy.updates {
27422×
        return Ok((None, format!("updates are off ({})", policy.from[0])));
27570×
    }
27670×
    let Some(spec) = db::accounts::setting(pool, SETTING_REPO).await?.filter(|s| !s.trim().is_empty()) else {
27716×
        return Ok((None, "no runner releases repository is set (Admin › Runners › Updates)".into()));
278
    };
27954×
    let Some((owner, name)) = spec.trim().split_once('/') else {
2800×
        return Ok((None, format!("the runner releases repository {spec:?} is not owner/name")));
281
    };
28254×
    let Some(repo) = db::repos::get(pool, owner, name).await? else {
2830×
        return Ok((None, format!("the runner releases repository {spec} does not exist")));
284
    };
28554×
    let releases = db::releases::list(pool, repo.id).await?;
28654×
    let release = match policy.channel.as_str() {
28754×
        "stable" => releases.iter().find(|x| !x.prerelease),
28815×
        "canary" => releases.first(),
28915×
        v => releases.iter().find(|x| version_of(&x.tag) == v),
290
    };
29154×
    let Some(release) = release else {
29215×
        return Ok((None, format!("{spec} has no release for the {} channel", policy.channel)));
293
    };
29439×
    let (want, have) = (version_of(&release.tag), version_of(&r.version));
29539×
    if want == have {
2960×
        return Ok((None, format!("up to date: flow {have} ({} channel)", policy.channel)));
29739×
    }
29839×
    if let Some(h) = rdb::held(pool, &want).await? {
2996×
        return Ok((None, format!("flow {want} is held: {}; lift the hold on Admin › Runners › Updates", h.why)));
30033×
    }
30133×
    let going_back = older(&want, &have);
30233×
    if going_back && matches!(policy.channel.as_str(), "stable" | "canary") {
3030×
        return Ok((None, format!("runs flow {have}, newer than the {} channel's {want}", policy.channel)));
30433×
    }
305
    // Waves (oxygenflow#84): a later wave takes a version once those before it proved it.
30633×
    if !going_back {
30733×
        match wave_gate(pool, levels, r, &want, now).await? {
30826×
            Ok(None) => {}
3095×
            Ok(Some(why)) => return Ok((None, why)),
3102×
            Err((runner, why)) => {
3112×
                if act {
3121×
                    rdb::hold(pool, &want, &runner, &why).await?;
3131×
                    db::accounts::audit(pool, None, "runner.update.held", &want, &why).await.ok();
3141×
                }
3152×
                return Ok((None, format!("flow {want} is held: {why}; lift the hold on Admin › Runners › Updates")));
316
            }
317
        }
3180×
    }
31926×
    let file = asset_for(&r.os, &r.arch);
32026×
    let assets = db::releases::assets(pool, release.id).await?;
32126×
    let Some(a) = assets.iter().find(|a| a.name == file) else {
3220×
        return Ok((None, format!("{spec} {} has no {file}", release.tag)));
323
    };
32426×
    if let Some(w) = policy.window.filter(|w| !in_window(*w, hour)) {
32511×
        return Ok((None, format!("flow {want} waits for its window, {:02}:00–{:02}:00 UTC", w.0, w.1)));
32615×
    }
32715×
    let note = format!(
328
        "flow {want} ({} channel){}",
329
        policy.channel,
33015×
        if policy.dry_run { ": dry run, check it and say what would happen" } else { "" }
331
    );
33215×
    Ok((
33315×
        Some(UpdateOffer {
33415×
            version: want,
33515×
            tag: release.tag.clone(),
33615×
            asset: a.name.clone(),
33715×
            url: format!("/{owner}/{name}/asset/{}/{}", a.id, a.name),
33815×
            blake3: a.hash.clone(),
33915×
            size: a.size as u64,
34015×
            older: going_back,
34115×
        }),
34215×
        note,
34315×
    ))
34492×
}
345
346
#[cfg(test)]
347
mod tests {
348
    use super::*;
349
350511×
    fn runner(id: i64, owner: &str) -> Runner {
351511×
        Runner { id, scope_owner: owner.into(), version: "0.3.1 (build)".into(), ..Runner::default() }
352511×
    }
353
354
    #[test]
3551×
    fn a_smaller_share_sits_inside_a_larger_one_and_is_about_its_size() {
3561000×
        let ten: Vec<i64> = (1..=1000).filter(|id| bucket(*id) < 10).collect();
3571000×
        let fifty: Vec<i64> = (1..=1000).filter(|id| bucket(*id) < 50).collect();
35896×
        assert!(ten.iter().all(|id| fifty.contains(id)));
3591×
        assert!((60..=140).contains(&ten.len()), "{}", ten.len());
3601×
        assert_eq!(bucket(7), bucket(7), "stable");
3611×
    }
362
363
    #[test]
3641×
    fn the_nearest_level_wins_each_setting() {
3651×
        let mut l = Levels::default();
3664×
        let pol = |scope: &str, key: &str, f: &dyn Fn(&mut Policy)| {
3674×
            let mut p = Policy { scope: scope.into(), key: key.into(), ..Policy::default() };
3684×
            f(&mut p);
3694×
            ((scope.to_string(), key.to_string()), p)
3704×
        };
3711×
        l.policies.extend([
3721×
            pol("site", "", &|p| {
3731×
                p.updates = Some(false);
3741×
                p.channel = Some("stable".into());
3751×
            }),
3761×
            pol("owner", "acme", &|p| p.updates = Some(true)),
3771×
            pol("group", "1", &|p| p.channel = Some("canary".into())),
3781×
            pol("runner", "3", &|p| p.updates = Some(false)),
379
        ]);
3801×
        l.groups.push(Group { id: 1, owner: "acme".into(), name: "canary".into(), percent: None, created_ms: 0, wave: None });
3811×
        l.chosen.insert((1, 2));
3821×
        l.chosen.insert((1, 9));
383
3841×
        let site_runner = l.resolve(&runner(1, ""));
3851×
        assert!(!site_runner.updates, "off at the site");
3861×
        assert_eq!(site_runner.from, ["site", "site", "default", "default"]);
387
3881×
        let canary = l.resolve(&runner(2, "acme"));
3891×
        assert!(canary.updates && canary.channel == "canary");
3901×
        assert_eq!(canary.from[..2], ["owner acme".to_string(), "group canary".to_string()]);
391
3921×
        let held = l.resolve(&runner(3, "acme"));
3931×
        assert!(!held.updates, "the runner's own setting wins");
3941×
        assert_eq!(held.channel, "stable");
395
3961×
        assert!(l.groups_of(&runner(9, "other")).is_empty(), "an owner's group holds only its own runners");
3971×
        assert!(!Levels::default().resolve(&runner(4, "")).updates, "off unless chosen");
3981×
    }
399
400
    #[test]
4011×
    fn a_percentage_group_takes_its_share_of_the_runners_in_reach() {
4021×
        let mut l = Levels::default();
4031×
        l.groups.push(Group { id: 5, owner: String::new(), name: "farm 20%".into(), percent: Some(20), created_ms: 0, wave: None });
404500×
        let n = (1..=500).filter(|id| !l.groups_of(&runner(*id, "")).is_empty()).count();
4051×
        assert!((60..=140).contains(&n), "{n}");
4061×
    }
407
408
    #[test]
4091×
    fn a_wave_proves_a_version_by_its_jobs_or_its_time_and_an_error_stops_it() {
4108×
        let soak = |runner_id, jobs, errors, first_ms| Soak { runner_id, jobs, errors, first_ms: Some(first_ms) };
4111×
        let hour = 3_600_000;
412
        // Nobody has taken it yet: not proved.
4131×
        assert_eq!(wave_state(&[1, 2], &[], 5, 24 * hour, 0), WaveState::Soaking { runners: 2, on: 0, jobs: 0 });
414
        // One runner took it and ran 3 of its 5; the other has not updated.
4151×
        let s = [soak(1, 3, 0, 0), soak(9, 50, 0, 0)];
4161×
        assert_eq!(wave_state(&[1, 2], &s, 5, 24 * hour, hour), WaveState::Soaking { runners: 2, on: 1, jobs: 3 });
417
        // Five each on every runner that took it proves it.
4181×
        assert_eq!(wave_state(&[1, 2], &[soak(1, 5, 0, 0)], 5, 24 * hour, hour), WaveState::Proved);
419
        // Or a day since the first job there, with no error.
4201×
        assert_eq!(wave_state(&[1, 2], &[soak(1, 1, 0, 0)], 5, 24 * hour, 24 * hour), WaveState::Proved);
421
        // An error anywhere in the wave stops it, however many passed.
4221×
        assert_eq!(
4231×
            wave_state(&[1, 2], &[soak(1, 40, 0, 0), soak(2, 2, 1, 0)], 5, 24 * hour, 48 * hour),
424
            WaveState::Stopped { runner_id: 2 }
425
        );
426
        // Another wave's errors are not this one's.
4271×
        assert_eq!(wave_state(&[1], &[soak(1, 5, 0, 0), soak(2, 2, 1, 0)], 5, 24 * hour, hour), WaveState::Proved);
428
        // An empty wave holds nothing up.
4291×
        assert_eq!(wave_state(&[], &[], 5, 24 * hour, 0), WaveState::Proved);
4301×
    }
431
432
    #[test]
4331×
    fn a_runner_waits_for_the_waves_before_its_own() {
4341×
        let mut l = Levels::default();
4353×
        let group = |id, name: &str, wave| Group { id, owner: String::new(), name: name.into(), percent: None, created_ms: 0, wave };
4361×
        l.groups.extend([group(1, "first", Some(1)), group(2, "second", Some(2)), group(3, "watchers", None)]);
4371×
        l.chosen.extend([(1, 1), (2, 2), (2, 1), (3, 3)]);
4384×
        let all: Vec<Runner> = (1..=4).map(|id| runner(id, "")).collect();
4395×
        let ids = |ws: Vec<(i64, Vec<&Runner>)>| {
4409×
            ws.into_iter().map(|(w, rs)| (w, rs.iter().map(|r| r.id).collect::<Vec<_>>())).collect::<Vec<_>>()
4415×
        };
4421×
        assert_eq!(l.wave_of(&all[0]), Some(1), "its first wave");
4431×
        assert!(l.waves_before(&all[0], &all).is_empty(), "wave 1 goes first");
4441×
        assert_eq!(ids(l.waves_before(&all[1], &all)), vec![(1, vec![1])]);
4451×
        assert_eq!(l.wave_of(&all[2]), None, "a group with no wave");
4461×
        assert_eq!(ids(l.waves_before(&all[2], &all)), vec![(1, vec![1]), (2, vec![2])], "in no wave: after every wave");
4471×
        assert_eq!(ids(l.waves_before(&all[3], &all)), vec![(1, vec![1]), (2, vec![2])]);
448
        // An owner's waves gate only that owner's runners.
4491×
        l.groups.push(Group { id: 4, owner: "acme".into(), name: "acme first".into(), percent: None, created_ms: 0, wave: Some(1) });
4501×
        l.chosen.insert((4, 5));
4511×
        let mut all = all;
4521×
        all.push(runner(5, "acme"));
4531×
        all.push(runner(6, "acme"));
4541×
        assert_eq!(ids(l.waves_before(&all[5], &all)), vec![(1, vec![1, 5]), (2, vec![2])]);
4551×
        assert_eq!(ids(l.waves_before(&all[3], &all)), vec![(1, vec![1]), (2, vec![2])], "not another owner's");
4561×
    }
457
458
    #[test]
4591×
    fn windows_and_channels_read_as_written() {
4601×
        assert_eq!(parse_window("02-05"), Ok(Some((2, 5))));
4611×
        assert_eq!(parse_window(" 22:00-04:00 "), Ok(Some((22, 4))));
4621×
        assert_eq!(parse_window(""), Ok(None));
4631×
        assert!(parse_window("5").is_err() && parse_window("3-3").is_err() && parse_window("1-24").is_err());
4641×
        assert!(in_window((2, 5), 2) && in_window((2, 5), 4) && !in_window((2, 5), 5));
4651×
        assert!(in_window((22, 4), 23) && in_window((22, 4), 1) && !in_window((22, 4), 12));
4661×
        assert!(valid_channel("stable") && valid_channel("canary") && valid_channel("0.3.4"));
4671×
        assert!(!valid_channel("latest") && !valid_channel("0.3.") && !valid_channel(""));
4681×
        assert_eq!(version_of("v0.3.2"), "0.3.2");
4691×
        assert_eq!(version_of("0.3.1 (local build)"), "0.3.1");
4701×
        assert_eq!(asset_for("linux", "riscv64"), "flow-linux-riscv64");
4711×
        assert_eq!(asset_for("windows", "x86_64"), "flow-windows-x86_64.exe");
4721×
        assert_eq!(
4731×
            rolled_back_version("rolled back from flow 0.4.0 to 0.3.1: 3 starts without reaching the hub").as_deref(),
474
            Some("0.4.0")
475
        );
4761×
        assert_eq!(rolled_back_version("runs flow 0.4.0 (was 0.3.1); proven"), None);
4771×
    }
478
}