| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 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 |
|
| 13 |
|
| 14 |
pub const SETTING_REPO: &str = "runner_updates_repo"; |
| 15 |
|
| 16 |
|
| 17 |
|
| 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 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 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 |
|
| 73 |
|
| 74 |
|
| 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 |
|
| 83 |
|
| 84 |
pub fn in_reach(g: &Group, r: &Runner) -> bool { |
| 85 |
g.owner.is_empty() || g.owner == r.scope_owner |
| 86 |
} |
| 87 |
|
| 88 |
|
| 89 |
#[derive(Clone, Debug, Default)] |
| 90 |
pub struct Levels { |
| 91 |
pub policies: HashMap<(String, String), Policy>, |
| 92 |
pub groups: Vec<Group>, |
| 93 |
|
| 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 |
|
| 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 |
|
| 119 |
|
| 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 |
|
| 125 |
|
| 126 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 216 |
|
| 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 |
|
| 227 |
|
| 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 |
|
| 234 |
|
| 235 |
pub fn asset_for(os: &str, arch: &str) -> String { |
| 236 |
format!("flow-{os}-{arch}{}", if os == "windows" { ".exe" } else { "" }) |
| 237 |
} |
| 238 |
|
| 239 |
|
| 240 |
#[derive(Clone, Debug, PartialEq, Eq)] |
| 241 |
pub enum WaveState { |
| 242 |
|
| 243 |
|
| 244 |
|
| 245 |
Proved, |
| 246 |
|
| 247 |
Soaking { runners: usize, on: usize, jobs: i64 }, |
| 248 |
|
| 249 |
Stopped { runner_id: i64 }, |
| 250 |
} |
| 251 |
|
| 252 |
|
| 253 |
|
| 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 |
|
| 272 |
|
| 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 |
|
| 311 |
|
| 312 |
|
| 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 |
|
| 326 |
|
| 327 |
|
| 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 |
|
| 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 |
|
| 471 |
assert_eq!(wave_state(&[1, 2], &[], 5, 24 * hour, 0), WaveState::Soaking { runners: 2, on: 0, jobs: 0 }); |
| 472 |
|
| 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 |
|
| 476 |
assert_eq!(wave_state(&[1, 2], &[soak(1, 5, 0, 0)], 5, 24 * hour, hour), WaveState::Proved); |
| 477 |
|
| 478 |
assert_eq!(wave_state(&[1, 2], &[soak(1, 1, 0, 0)], 5, 24 * hour, 24 * hour), WaveState::Proved); |
| 479 |
|
| 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 |
|
| 485 |
assert_eq!(wave_state(&[1], &[soak(1, 5, 0, 0), soak(2, 2, 1, 0)], 5, 24 * hour, hour), WaveState::Proved); |
| 486 |
|
| 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 |
|
| 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 |
} |