Runner updates in waves: a later wave takes a version once the waves before it proved it; an error stops it (refs andymccall/oxygenflow#84) 1ec29691b67a
Test groups get a wave (1 to 9). A version reaches wave n+1 once every runner of the earlier waves that took it has finished soak_jobs jobs on it (site setting, 5) with none in error, or soak_hours (24) have passed since its first job there; runners in no wave come last. A job ending in error on a wave stops the version: the next runner to ask holds it for all (a page view only says so), naming the job. Jobs keep the runner build that claimed them (migration 0102, flow_jobs.runner_version). Admin and organisation pages set waves and the soak; oh runner-updates shows them. First slice: no comparison with the version before, and no hub-driven rollback of a stopped wave.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key d695efb67452
- When
- · 2026-10-28 01:21 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
1ec29691b67afa5ab607846ce4bf5334d76401e26c7bb82a6bc0f277c3e36956- Depends on
- 166f6e9bf4f1 19bb31ff1f5c 3b1bc27fe805 42fa95540468 4dfa4599b3f7 501d8b268dd4 5c6e92fd8d71 8501fd5815b9 9bbca80f777a b5668108487f b6b7e4cf6b24 b6dfcb61cf5e d23464c0ac60 d7050703d2f4 dc5c64296b0a faf5246c900e
- Depended on by
- 04f9c984439e c26267b3aba6 8306eca4e4a3 851a26a2a6d1
- In bookmarks
- main
- In tags
- v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 57 atoms, 2 tree operations, 15 files
Browse the tree at this change
added crates/hub/migrations-pg/0102_runner_waves.sql +6 −0
| 1 | + | -- Runner updates in waves (oxygenflow#84): the runner build each job ran |
|
| 2 | + | -- on, from its claim, to count the jobs that prove a version; and the wave |
|
| 3 | + | -- a test group's runners take a version in (none: after every wave). |
|
| 4 | + | ALTER TABLE flow_jobs ADD COLUMN runner_version TEXT NOT NULL DEFAULT ''; |
|
| 5 | + | ALTER TABLE runner_groups ADD COLUMN wave BIGINT; |
|
| 6 | + | CREATE INDEX flow_jobs_runner_version ON flow_jobs (runner_version) WHERE runner_version <> ''; |
added crates/hub/migrations/0102_runner_waves.sql +6 −0
| 1 | + | -- Runner updates in waves (oxygenflow#84): the runner build each job ran |
|
| 2 | + | -- on, from its claim, to count the jobs that prove a version; and the wave |
|
| 3 | + | -- a test group's runners take a version in (none: after every wave). |
|
| 4 | + | ALTER TABLE flow_jobs ADD COLUMN runner_version TEXT NOT NULL DEFAULT ''; |
|
| 5 | + | ALTER TABLE runner_groups ADD COLUMN wave INTEGER; |
|
| 6 | + | CREATE INDEX flow_jobs_runner_version ON flow_jobs (runner_version) WHERE runner_version <> ''; |
modified CHANGELOG.md +4 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## Unreleased |
|
| 6 | + | ||
| 7 | + | - **Runner updates in waves (oxygenflow#84).** A test group can be given a wave (1 to 9) on Admin › Runners › Updates or an organisation's settings: a new version reaches wave 2's runners only once every runner of wave 1 that took it has finished a number of jobs on it (5 by default) with none ending in error, or a number of hours (24) has passed since its first job there; runners in no wave come last. A job that ends in `error` on a wave's runner (the runner's or the machine's fault; failed tests do not count) stops the version: the next runner to ask holds it for every runner, and the page names the job, until an administrator lifts the hold. Each job now keeps the runner build it was claimed by (migration 0102). A runner waiting says so: "flow 0.5.1 waits for wave 1 to prove it: 1 of 2 runners on it, 3 jobs". First slice: errors are not yet weighed against the version before, and a stopped wave's runners are not rolled back by the hub. |
|
| 8 | + | ||
| 5 | 9 | ## 0.9.11 (2026-10-28) |
|
| 6 | 10 | ||
| 7 | 11 | <<<<<<< oxy conflict 1 of 1, side 1 of 2: change 681d2bd9 "Approval gates and expedite for clients: a run's jobs over the API say their approval, and POST …/flow/runs/N/expedite makes a run urgent as the run page does, sharing flow::expedite (refs oxygenhub-android#5)" |
|
| … | 395 unchanged lines |
modified crates/hub-api/src/lib.rs +3 −0
| … | 1640 unchanged lines |
||
| 1641 | 1641 | pub percent: Option<i64>, |
|
| 1642 | 1642 | /// Its runners now, by name. |
|
| 1643 | 1643 | pub members: Vec<String>, |
|
| 1644 | + | /// The wave it takes a version in (oxygenflow#84); `None`: after every wave. |
|
| 1645 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1646 | + | pub wave: Option<i64>, |
|
| 1644 | 1647 | } |
|
| 1645 | 1648 | ||
| 1646 | 1649 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| … | 27 unchanged lines |
modified crates/hub/src/api/mod.rs +1 −0
| … | 2928 unchanged lines |
||
| 2929 | 2929 | name: g.g.name.clone(), |
|
| 2930 | 2930 | percent: g.g.percent, |
|
| 2931 | 2931 | members: g.members.clone(), |
|
| 2932 | + | wave: g.g.wave, |
|
| 2932 | 2933 | }) |
|
| 2933 | 2934 | .collect(), |
|
| 2934 | 2935 | runners: v |
|
| … | 360 unchanged lines |
modified crates/hub/src/db/flow.rs +7 −0
| … | 852 unchanged lines |
||
| 853 | 853 | claim_ranked(pool, runtimes, runner, token_hash, room, scope, labels, &|_| false).await |
|
| 854 | 854 | } |
|
| 855 | 855 | ||
| 856 | + | /// Keep the runner build job `id` was claimed by (oxygenflow#84), for |
|
| 857 | + | /// counting the jobs that prove a version in a wave. |
|
| 858 | + | pub async fn set_runner_version(pool: &Pool, id: i64, version: &str) -> sqlx::Result<()> { |
|
| 859 | + | sqlx::query("UPDATE flow_jobs SET runner_version = $1 WHERE id = $2").bind(version).bind(id).execute(pool).await?; |
|
| 860 | + | Ok(()) |
|
| 861 | + | } |
|
| 862 | + | ||
| 856 | 863 | /// [`claim_within`], passing over a job `elsewhere` says a better runner |
|
| 857 | 864 | /// should take (oxygenflow#42). |
|
| 858 | 865 | #[allow(clippy::too_many_arguments)] |
|
| … | 806 unchanged lines |
modified crates/hub/src/db/runner_updates.rs +51 −0
| … | 32 unchanged lines |
||
| 33 | 33 | /// `None`: only the chosen ones. |
|
| 34 | 34 | pub percent: Option<i64>, |
|
| 35 | 35 | pub created_ms: i64, |
|
| 36 | + | /// The wave its runners take a version in (oxygenflow#84): wave 1 |
|
| 37 | + | /// first, each later one once those before it have proved the version; |
|
| 38 | + | /// `None`: after every wave. |
|
| 39 | + | pub wave: Option<i64>, |
|
| 36 | 40 | } |
|
| 37 | 41 | ||
| 38 | 42 | pub async fn policies(pool: &Pool) -> sqlx::Result<Vec<Policy>> { |
|
| … | 54 unchanged lines |
||
| 93 | 97 | Ok(()) |
|
| 94 | 98 | } |
|
| 95 | 99 | ||
| 100 | + | pub async fn set_wave(pool: &Pool, id: i64, wave: Option<i64>) -> sqlx::Result<()> { |
|
| 101 | + | sqlx::query("UPDATE runner_groups SET wave = $1 WHERE id = $2").bind(wave).bind(id).execute(pool).await?; |
|
| 102 | + | Ok(()) |
|
| 103 | + | } |
|
| 104 | + | ||
| 96 | 105 | /// Remove a group, its members and its policy. |
|
| 97 | 106 | pub async fn delete_group(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 98 | 107 | sqlx::query("DELETE FROM runner_group_members WHERE group_id = $1").bind(id).execute(pool).await?; |
|
| … | 60 unchanged lines |
||
| 159 | 168 | /// Lift the hold on `version`; whether there was one. |
|
| 160 | 169 | pub async fn lift(pool: &Pool, version: &str) -> sqlx::Result<bool> { |
|
| 161 | 170 | Ok(sqlx::query("DELETE FROM runner_update_holds WHERE version = $1").bind(version).execute(pool).await?.rows_affected() > 0) |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | /// How a runner has done on a version (oxygenflow#84): its finished jobs |
|
| 174 | + | /// on that build, how many ended in `error` (the runner's or the |
|
| 175 | + | /// infrastructure's fault, not a test's), and when the first started. |
|
| 176 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, FromRow)] |
|
| 177 | + | pub struct Soak { |
|
| 178 | + | pub runner_id: i64, |
|
| 179 | + | pub jobs: i64, |
|
| 180 | + | pub errors: i64, |
|
| 181 | + | pub first_ms: Option<i64>, |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | /// Every runner's [`Soak`] on `version`. |
|
| 185 | + | pub async fn soaks(pool: &Pool, version: &str) -> sqlx::Result<Vec<Soak>> { |
|
| 186 | + | sqlx::query_as( |
|
| 187 | + | "SELECT runner_id, COUNT(*) AS jobs, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors, MIN(started_ms) AS first_ms |
|
| 188 | + | FROM flow_jobs WHERE runner_version = $1 AND runner_id IS NOT NULL AND status IN ('passed', 'failed', 'error') |
|
| 189 | + | GROUP BY runner_id", |
|
| 190 | + | ) |
|
| 191 | + | .bind(version) |
|
| 192 | + | .fetch_all(pool) |
|
| 193 | + | .await |
|
| 194 | + | } |
|
| 195 | + | ||
| 196 | + | /// The first job on `version` that ended in error on runner `runner`, in |
|
| 197 | + | /// words: `job build of OWNER/NAME run #N: why`. |
|
| 198 | + | pub async fn first_error(pool: &Pool, version: &str, runner: i64) -> sqlx::Result<Option<String>> { |
|
| 199 | + | let row: Option<(String, String, String, i64, String)> = sqlx::query_as( |
|
| 200 | + | "SELECT j.name, p.owner, p.name, r.number, j.error FROM flow_jobs j |
|
| 201 | + | JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id |
|
| 202 | + | WHERE j.runner_version = $1 AND j.runner_id = $2 AND j.status = 'error' |
|
| 203 | + | ORDER BY j.finished_ms LIMIT 1", |
|
| 204 | + | ) |
|
| 205 | + | .bind(version) |
|
| 206 | + | .bind(runner) |
|
| 207 | + | .fetch_optional(pool) |
|
| 208 | + | .await?; |
|
| 209 | + | Ok(row.map(|(job, owner, name, n, error)| { |
|
| 210 | + | let why: String = error.chars().take(120).collect(); |
|
| 211 | + | format!("job {job} of {owner}/{name} run #{n}{}", if why.is_empty() { String::new() } else { format!(": {why}") }) |
|
| 212 | + | })) |
|
| 162 | 213 | } |
modified crates/hub/src/flow.rs +7 −4
| … | 1124 unchanged lines |
||
| 1125 | 1125 | else { |
|
| 1126 | 1126 | return Ok(StatusCode::NO_CONTENT.into_response()); |
|
| 1127 | 1127 | }; |
|
| 1128 | + | // The build it runs on, for waves of runner updates (oxygenflow#84). |
|
| 1129 | + | fdb::set_runner_version(&app.db, job.id, &crate::runner_updates::version_of(&me_now.version)).await?; |
|
| 1128 | 1130 | // Why here (oxygenflow#42). |
|
| 1129 | 1131 | let waited = |
|
| 1130 | 1132 | HELD.lock().unwrap_or_else(|p| p.into_inner()).as_mut().and_then(|h| h.remove(&(hub_key.clone(), job.id))).map(|t| now - t); |
|
| … | 387 unchanged lines |
||
| 1518 | 1520 | pub async fn runner_update(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<flow_core::api::UpdateAnswer>, ApiError> { |
|
| 1519 | 1521 | let me = runner_auth(&app, &headers).await?; |
|
| 1520 | 1522 | db::runners::seen(&app.db, me.id, None, None).await?; |
|
| 1521 | - | let policy = crate::runner_updates::Levels::load(&app.db).await?.resolve(&me); |
|
| 1523 | + | let levels = crate::runner_updates::Levels::load(&app.db).await?; |
|
| 1524 | + | let policy = levels.resolve(&me); |
|
| 1522 | 1525 | let hour = time::OffsetDateTime::now_utc().hour(); |
|
| 1523 | - | let (offer, note) = crate::runner_updates::offer(&app.db, &me, &policy, hour).await?; |
|
| 1526 | + | let (offer, note) = crate::runner_updates::offer(&app.db, &levels, &me, &policy, hour, db::now_ms(), true).await?; |
|
| 1524 | 1527 | Ok(Json(flow_core::api::UpdateAnswer { policy, offer, note })) |
|
| 1525 | 1528 | } |
|
| 1526 | 1529 | ||
| … | 15 unchanged lines |
||
| 1542 | 1545 | // A build that rolled back here is held back from every runner (#326). |
|
| 1543 | 1546 | if let Some(v) = crate::runner_updates::rolled_back_version(note) { |
|
| 1544 | 1547 | let why = note.split_once(": ").map(|(_, w)| w).unwrap_or("rolled back"); |
|
| 1545 | - | db::runner_updates::hold(&app.db, &v, &me.name, why).await?; |
|
| 1546 | - | db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("rolled back on {}: {why}", me.name)).await.ok(); |
|
| 1548 | + | db::runner_updates::hold(&app.db, &v, &me.name, &format!("it rolled back on {} ({why})", me.name)).await?; |
|
| 1549 | + | db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("it rolled back on {}: {why}", me.name)).await.ok(); |
|
| 1547 | 1550 | } |
|
| 1548 | 1551 | db::accounts::audit(&app.db, None, "runner.update", &me.name, note).await.ok(); |
|
| 1549 | 1552 | Ok(StatusCode::NO_CONTENT) |
|
| … | 1586 unchanged lines |
modified crates/hub/src/runner_updates.rs +184 −9
| … | 2 unchanged lines |
||
| 3 | 3 | //! owner, its test groups and the runner itself set, the nearest setting |
|
| 4 | 4 | //! winning. Off unless some level turns updates on. |
|
| 5 | 5 | ||
| 6 | - | use crate::db::runner_updates::{self as rdb, Group, Policy}; |
|
| 6 | + | use crate::db::runner_updates::{self as rdb, Group, Policy, Soak}; |
|
| 7 | 7 | use crate::db::runners::Runner; |
|
| 8 | 8 | use crate::db::{self, Pool}; |
|
| 9 | 9 | use flow_core::api::{UpdateOffer, UpdatePolicy}; |
|
| … | 2 unchanged lines |
||
| 12 | 12 | /// The site setting naming the repository whose releases carry runner |
|
| 13 | 13 | /// builds (`owner/name`), each an asset `flow-OS-ARCH`. |
|
| 14 | 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; |
|
| 15 | 22 | ||
| 16 | 23 | /// A runner's place, 0 to 99, for percentage groups: a stable hash of its |
|
| 17 | 24 | /// id, so a group's share is the same machines every rollout, and a |
|
| … | 41 unchanged lines |
||
| 59 | 66 | self.policies.get(&(scope.to_string(), key.to_string())) |
|
| 60 | 67 | } |
|
| 61 | 68 | ||
| 69 | + | /// The wave `r` takes a version in: the first of its groups' waves; |
|
| 70 | + | /// `None` after every wave. |
|
| 71 | + | pub fn wave_of(&self, r: &Runner) -> Option<i64> { |
|
| 72 | + | self.groups_of(r).iter().filter_map(|g| g.wave).min() |
|
| 73 | + | } |
|
| 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. |
|
| 78 | + | pub fn waves_before<'a>(&self, r: &Runner, all: &'a [Runner]) -> Vec<(i64, Vec<&'a Runner>)> { |
|
| 79 | + | let mine = self.wave_of(r); |
|
| 80 | + | let mut waves: Vec<i64> = |
|
| 81 | + | self.groups.iter().filter(|g| in_reach(g, r)).filter_map(|g| g.wave).filter(|w| mine.is_none_or(|m| *w < m)).collect(); |
|
| 82 | + | waves.sort(); |
|
| 83 | + | waves.dedup(); |
|
| 84 | + | waves |
|
| 85 | + | .into_iter() |
|
| 86 | + | .map(|w| { |
|
| 87 | + | let members = all |
|
| 88 | + | .iter() |
|
| 89 | + | .filter(|o| self.wave_of(o) == Some(w) && self.groups_of(o).iter().any(|g| g.wave == Some(w) && in_reach(g, r))) |
|
| 90 | + | .collect(); |
|
| 91 | + | (w, members) |
|
| 92 | + | }) |
|
| 93 | + | .collect() |
|
| 94 | + | } |
|
| 95 | + | ||
| 62 | 96 | /// The levels that apply to `r`, nearest first, each with its name. |
|
| 63 | 97 | fn chain(&self, r: &Runner) -> Vec<(String, &Policy)> { |
|
| 64 | 98 | let mut out = vec![]; |
|
| … | 86 unchanged lines |
||
| 151 | 185 | /// `flow-windows-x86_64.exe`. |
|
| 152 | 186 | pub fn asset_for(os: &str, arch: &str) -> String { |
|
| 153 | 187 | format!("flow-{os}-{arch}{}", if os == "windows" { ".exe" } else { "" }) |
|
| 188 | + | } |
|
| 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). |
|
| 205 | + | pub fn wave_state(members: &[i64], soaks: &[Soak], soak_jobs: i64, soak_ms: i64, now: i64) -> WaveState { |
|
| 206 | + | let mine: Vec<&Soak> = soaks.iter().filter(|s| members.contains(&s.runner_id) && s.jobs > 0).collect(); |
|
| 207 | + | if let Some(s) = mine.iter().find(|s| s.errors > 0) { |
|
| 208 | + | return WaveState::Stopped { runner_id: s.runner_id }; |
|
| 209 | + | } |
|
| 210 | + | if members.is_empty() { |
|
| 211 | + | return WaveState::Proved; |
|
| 212 | + | } |
|
| 213 | + | let jobs = mine.iter().map(|s| s.jobs).sum(); |
|
| 214 | + | let soaked = mine.iter().filter_map(|s| s.first_ms).min().is_some_and(|first| now - first >= soak_ms); |
|
| 215 | + | if !mine.is_empty() && (soaked || mine.iter().all(|s| s.jobs >= soak_jobs)) { |
|
| 216 | + | WaveState::Proved |
|
| 217 | + | } else { |
|
| 218 | + | WaveState::Soaking { runners: members.len(), on: mine.len(), jobs } |
|
| 219 | + | } |
|
| 220 | + | } |
|
| 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. |
|
| 224 | + | async fn wave_gate( |
|
| 225 | + | pool: &Pool, |
|
| 226 | + | levels: &Levels, |
|
| 227 | + | r: &Runner, |
|
| 228 | + | version: &str, |
|
| 229 | + | now: i64, |
|
| 230 | + | ) -> sqlx::Result<Result<Option<String>, (String, String)>> { |
|
| 231 | + | let all = db::runners::list(pool).await?; |
|
| 232 | + | let waves = levels.waves_before(r, &all); |
|
| 233 | + | if waves.is_empty() { |
|
| 234 | + | return Ok(Ok(None)); |
|
| 235 | + | } |
|
| 236 | + | let setting = |v: Option<String>, d: i64| v.and_then(|s| s.trim().parse::<i64>().ok()).filter(|n| *n > 0).unwrap_or(d); |
|
| 237 | + | let soak_jobs = setting(db::accounts::setting(pool, SETTING_SOAK_JOBS).await?, SOAK_JOBS); |
|
| 238 | + | let soak_hours = setting(db::accounts::setting(pool, SETTING_SOAK_HOURS).await?, SOAK_HOURS); |
|
| 239 | + | let soaks = rdb::soaks(pool, version).await?; |
|
| 240 | + | for (w, members) in waves { |
|
| 241 | + | let ids: Vec<i64> = members.iter().map(|m| m.id).collect(); |
|
| 242 | + | match wave_state(&ids, &soaks, soak_jobs, soak_hours * 3_600_000, now) { |
|
| 243 | + | WaveState::Proved => continue, |
|
| 244 | + | WaveState::Stopped { runner_id } => { |
|
| 245 | + | let name = members.iter().find(|m| m.id == runner_id).map(|m| m.name.clone()).unwrap_or_default(); |
|
| 246 | + | let job = rdb::first_error(pool, version, runner_id).await?.unwrap_or_else(|| "a job".into()); |
|
| 247 | + | return Ok(Err((name.clone(), format!("wave {w} stopped it on {name}: {job} ended in error")))); |
|
| 248 | + | } |
|
| 249 | + | WaveState::Soaking { runners, on, jobs } => { |
|
| 250 | + | 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)", |
|
| 252 | + | if runners == 1 { "" } else { "s" }, |
|
| 253 | + | if jobs == 1 { "" } else { "s" }, |
|
| 254 | + | )))); |
|
| 255 | + | } |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | Ok(Ok(None)) |
|
| 154 | 259 | } |
|
| 155 | 260 | ||
| 156 | 261 | /// The build `r` should install under `policy` at hour `hour` (UTC), or |
|
| 157 | - | /// why there is none. |
|
| 158 | - | pub async fn offer(pool: &Pool, r: &Runner, policy: &UpdatePolicy, hour: u8) -> sqlx::Result<(Option<UpdateOffer>, String)> { |
|
| 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). |
|
| 264 | + | pub async fn offer( |
|
| 265 | + | pool: &Pool, |
|
| 266 | + | levels: &Levels, |
|
| 267 | + | r: &Runner, |
|
| 268 | + | policy: &UpdatePolicy, |
|
| 269 | + | hour: u8, |
|
| 270 | + | now: i64, |
|
| 271 | + | act: bool, |
|
| 272 | + | ) -> sqlx::Result<(Option<UpdateOffer>, String)> { |
|
| 159 | 273 | if !policy.updates { |
|
| 160 | 274 | return Ok((None, format!("updates are off ({})", policy.from[0]))); |
|
| 161 | 275 | } |
|
| … | 20 unchanged lines |
||
| 182 | 296 | return Ok((None, format!("up to date: flow {have} ({} channel)", policy.channel))); |
|
| 183 | 297 | } |
|
| 184 | 298 | if let Some(h) = rdb::held(pool, &want).await? { |
|
| 185 | - | return Ok(( |
|
| 186 | - | None, |
|
| 187 | - | format!("flow {want} is held: it rolled back on {} ({}); lift the hold on Admin › Runners › Updates", h.runner, h.why), |
|
| 188 | - | )); |
|
| 299 | + | return Ok((None, format!("flow {want} is held: {}; lift the hold on Admin › Runners › Updates", h.why))); |
|
| 189 | 300 | } |
|
| 190 | 301 | let going_back = older(&want, &have); |
|
| 191 | 302 | if going_back && matches!(policy.channel.as_str(), "stable" | "canary") { |
|
| 192 | 303 | return Ok((None, format!("runs flow {have}, newer than the {} channel's {want}", policy.channel))); |
|
| 304 | + | } |
|
| 305 | + | // Waves (oxygenflow#84): a later wave takes a version once those before it proved it. |
|
| 306 | + | if !going_back { |
|
| 307 | + | match wave_gate(pool, levels, r, &want, now).await? { |
|
| 308 | + | Ok(None) => {} |
|
| 309 | + | Ok(Some(why)) => return Ok((None, why)), |
|
| 310 | + | Err((runner, why)) => { |
|
| 311 | + | if act { |
|
| 312 | + | rdb::hold(pool, &want, &runner, &why).await?; |
|
| 313 | + | db::accounts::audit(pool, None, "runner.update.held", &want, &why).await.ok(); |
|
| 314 | + | } |
|
| 315 | + | return Ok((None, format!("flow {want} is held: {why}; lift the hold on Admin › Runners › Updates"))); |
|
| 316 | + | } |
|
| 317 | + | } |
|
| 193 | 318 | } |
|
| 194 | 319 | let file = asset_for(&r.os, &r.arch); |
|
| 195 | 320 | let assets = db::releases::assets(pool, release.id).await?; |
|
| … | 56 unchanged lines |
||
| 252 | 377 | pol("group", "1", &|p| p.channel = Some("canary".into())), |
|
| 253 | 378 | pol("runner", "3", &|p| p.updates = Some(false)), |
|
| 254 | 379 | ]); |
|
| 255 | - | l.groups.push(Group { id: 1, owner: "acme".into(), name: "canary".into(), percent: None, created_ms: 0 }); |
|
| 380 | + | l.groups.push(Group { id: 1, owner: "acme".into(), name: "canary".into(), percent: None, created_ms: 0, wave: None }); |
|
| 256 | 381 | l.chosen.insert((1, 2)); |
|
| 257 | 382 | l.chosen.insert((1, 9)); |
|
| 258 | 383 | ||
| … | 16 unchanged lines |
||
| 275 | 400 | #[test] |
|
| 276 | 401 | fn a_percentage_group_takes_its_share_of_the_runners_in_reach() { |
|
| 277 | 402 | let mut l = Levels::default(); |
|
| 278 | - | l.groups.push(Group { id: 5, owner: String::new(), name: "farm 20%".into(), percent: Some(20), created_ms: 0 }); |
|
| 403 | + | l.groups.push(Group { id: 5, owner: String::new(), name: "farm 20%".into(), percent: Some(20), created_ms: 0, wave: None }); |
|
| 279 | 404 | let n = (1..=500).filter(|id| !l.groups_of(&runner(*id, "")).is_empty()).count(); |
|
| 280 | 405 | assert!((60..=140).contains(&n), "{n}"); |
|
| 406 | + | } |
|
| 407 | + | ||
| 408 | + | #[test] |
|
| 409 | + | fn a_wave_proves_a_version_by_its_jobs_or_its_time_and_an_error_stops_it() { |
|
| 410 | + | let soak = |runner_id, jobs, errors, first_ms| Soak { runner_id, jobs, errors, first_ms: Some(first_ms) }; |
|
| 411 | + | let hour = 3_600_000; |
|
| 412 | + | // Nobody has taken it yet: not proved. |
|
| 413 | + | 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. |
|
| 415 | + | let s = [soak(1, 3, 0, 0), soak(9, 50, 0, 0)]; |
|
| 416 | + | 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. |
|
| 418 | + | 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. |
|
| 420 | + | 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. |
|
| 422 | + | assert_eq!( |
|
| 423 | + | 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. |
|
| 427 | + | 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. |
|
| 429 | + | assert_eq!(wave_state(&[], &[], 5, 24 * hour, 0), WaveState::Proved); |
|
| 430 | + | } |
|
| 431 | + | ||
| 432 | + | #[test] |
|
| 433 | + | fn a_runner_waits_for_the_waves_before_its_own() { |
|
| 434 | + | let mut l = Levels::default(); |
|
| 435 | + | let group = |id, name: &str, wave| Group { id, owner: String::new(), name: name.into(), percent: None, created_ms: 0, wave }; |
|
| 436 | + | l.groups.extend([group(1, "first", Some(1)), group(2, "second", Some(2)), group(3, "watchers", None)]); |
|
| 437 | + | l.chosen.extend([(1, 1), (2, 2), (2, 1), (3, 3)]); |
|
| 438 | + | let all: Vec<Runner> = (1..=4).map(|id| runner(id, "")).collect(); |
|
| 439 | + | let ids = |ws: Vec<(i64, Vec<&Runner>)>| { |
|
| 440 | + | ws.into_iter().map(|(w, rs)| (w, rs.iter().map(|r| r.id).collect::<Vec<_>>())).collect::<Vec<_>>() |
|
| 441 | + | }; |
|
| 442 | + | assert_eq!(l.wave_of(&all[0]), Some(1), "its first wave"); |
|
| 443 | + | assert!(l.waves_before(&all[0], &all).is_empty(), "wave 1 goes first"); |
|
| 444 | + | assert_eq!(ids(l.waves_before(&all[1], &all)), vec![(1, vec![1])]); |
|
| 445 | + | assert_eq!(l.wave_of(&all[2]), None, "a group with no wave"); |
|
| 446 | + | assert_eq!(ids(l.waves_before(&all[2], &all)), vec![(1, vec![1]), (2, vec![2])], "in no wave: after every wave"); |
|
| 447 | + | 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. |
|
| 449 | + | l.groups.push(Group { id: 4, owner: "acme".into(), name: "acme first".into(), percent: None, created_ms: 0, wave: Some(1) }); |
|
| 450 | + | l.chosen.insert((4, 5)); |
|
| 451 | + | let mut all = all; |
|
| 452 | + | all.push(runner(5, "acme")); |
|
| 453 | + | all.push(runner(6, "acme")); |
|
| 454 | + | assert_eq!(ids(l.waves_before(&all[5], &all)), vec![(1, vec![1, 5]), (2, vec![2])]); |
|
| 455 | + | assert_eq!(ids(l.waves_before(&all[3], &all)), vec![(1, vec![1]), (2, vec![2])], "not another owner's"); |
|
| 281 | 456 | } |
|
| 282 | 457 | ||
| 283 | 458 | #[test] |
|
| … | 20 unchanged lines |
modified crates/hub/src/web/runner_updates.rs +68 −4
| … | 125 unchanged lines |
||
| 126 | 126 | runners: Vec<RunnerView>, |
|
| 127 | 127 | /// Builds held back after rolling back on a runner (#326). |
|
| 128 | 128 | holds: Vec<rdb::Hold>, |
|
| 129 | + | /// Proving a version in a wave (oxygenflow#84). |
|
| 130 | + | soak_jobs: i64, |
|
| 131 | + | soak_hours: i64, |
|
| 129 | 132 | notice: Option<String>, |
|
| 130 | 133 | error: Option<String>, |
|
| 131 | 134 | } |
|
| … | 65 unchanged lines |
||
| 197 | 200 | let mut out = vec![]; |
|
| 198 | 201 | for r in all.into_iter().filter(|r| reach.holds_runner(r)) { |
|
| 199 | 202 | let effective = levels.resolve(&r); |
|
| 200 | - | let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await?; |
|
| 203 | + | let (_, offer) = ru::offer(&app.db, &levels, &r, &effective, hour, crate::db::now_ms(), false).await?; |
|
| 201 | 204 | out.push(RunnerView { |
|
| 202 | 205 | groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(), |
|
| 203 | 206 | policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)), |
|
| … | 10 unchanged lines |
||
| 214 | 217 | let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default(); |
|
| 215 | 218 | let v = views(app, &Reach::Site).await.map_err(e)?; |
|
| 216 | 219 | let holds = rdb::holds(&app.db).await.map_err(e)?; |
|
| 217 | - | Ok(Page(UpdatesPage { shell, repo, site: v.site, owners: v.owners, groups: v.groups, runners: v.runners, holds, notice, error })) |
|
| 220 | + | let number = |v: Option<String>, d: i64| v.and_then(|s| s.trim().parse::<i64>().ok()).filter(|n| *n > 0).unwrap_or(d); |
|
| 221 | + | let soak_jobs = number(accounts::setting(&app.db, ru::SETTING_SOAK_JOBS).await.map_err(e)?, ru::SOAK_JOBS); |
|
| 222 | + | let soak_hours = number(accounts::setting(&app.db, ru::SETTING_SOAK_HOURS).await.map_err(e)?, ru::SOAK_HOURS); |
|
| 223 | + | Ok(Page(UpdatesPage { |
|
| 224 | + | shell, |
|
| 225 | + | repo, |
|
| 226 | + | site: v.site, |
|
| 227 | + | owners: v.owners, |
|
| 228 | + | groups: v.groups, |
|
| 229 | + | runners: v.runners, |
|
| 230 | + | holds, |
|
| 231 | + | soak_jobs, |
|
| 232 | + | soak_hours, |
|
| 233 | + | notice, |
|
| 234 | + | error, |
|
| 235 | + | })) |
|
| 218 | 236 | } |
|
| 219 | 237 | ||
| 220 | 238 | /// What a change did, to say on the page: `Ok` a notice, `Err` what was wrong. |
|
| … | 18 unchanged lines |
||
| 239 | 257 | #[serde(rename = "_csrf")] |
|
| 240 | 258 | csrf_token: String, |
|
| 241 | 259 | repo: String, |
|
| 260 | + | #[serde(default)] |
|
| 261 | + | soak_jobs: String, |
|
| 262 | + | #[serde(default)] |
|
| 263 | + | soak_hours: String, |
|
| 242 | 264 | } |
|
| 243 | 265 | ||
| 244 | 266 | /// `POST /admin/runners/updates/source`: the repository whose releases carry runner builds. |
|
| … | 11 unchanged lines |
||
| 256 | 278 | return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await; |
|
| 257 | 279 | } |
|
| 258 | 280 | } |
|
| 281 | + | // Waves (oxygenflow#84): blank keeps the default. |
|
| 282 | + | for (key, value, max, what) in [ |
|
| 283 | + | (ru::SETTING_SOAK_JOBS, &f.soak_jobs, 1000, "jobs each runner of a wave finishes"), |
|
| 284 | + | (ru::SETTING_SOAK_HOURS, &f.soak_hours, 720, "hours a wave soaks"), |
|
| 285 | + | ] { |
|
| 286 | + | let v = value.trim(); |
|
| 287 | + | if !v.is_empty() && !v.parse::<i64>().is_ok_and(|n| (1..=max).contains(&n)) { |
|
| 288 | + | return page(&app, shell, None, Some(format!("The {what} is a whole number from 1 to {max}."))).await; |
|
| 289 | + | } |
|
| 290 | + | accounts::set_setting(&app.db, key, v).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 291 | + | } |
|
| 259 | 292 | accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 260 | 293 | accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok(); |
|
| 261 | 294 | let n = if repo.is_empty() { |
|
| … | 122 unchanged lines |
||
| 384 | 417 | percent: String, |
|
| 385 | 418 | #[serde(default)] |
|
| 386 | 419 | runner: Vec<i64>, |
|
| 420 | + | /// Its wave (oxygenflow#84), 1 to 9; blank for none. |
|
| 421 | + | #[serde(default)] |
|
| 422 | + | wave: String, |
|
| 423 | + | } |
|
| 424 | + | ||
| 425 | + | /// The wave a form gives, or why it cannot be one. |
|
| 426 | + | fn wave_of(f: &GroupForm) -> Result<Option<i64>, String> { |
|
| 427 | + | let w = f.wave.trim(); |
|
| 428 | + | if w.is_empty() { |
|
| 429 | + | return Ok(None); |
|
| 430 | + | } |
|
| 431 | + | match w.parse::<i64>() { |
|
| 432 | + | Ok(n) if (1..=9).contains(&n) => Ok(Some(n)), |
|
| 433 | + | _ => Err("A wave is a whole number from 1 to 9, or blank for after every wave.".into()), |
|
| 434 | + | } |
|
| 387 | 435 | } |
|
| 388 | 436 | ||
| 389 | 437 | /// The percentage a form gives, or why it cannot be one. |
|
| … | 36 unchanged lines |
||
| 426 | 474 | if rdb::groups(&app.db).await?.iter().any(|g| g.owner == owner && g.name == name) { |
|
| 427 | 475 | return Ok(Err(format!("There is already a test group {name:?} for those runners."))); |
|
| 428 | 476 | } |
|
| 477 | + | let wave = match wave_of(f) { |
|
| 478 | + | Ok(w) => w, |
|
| 479 | + | Err(why) => return Ok(Err(why)), |
|
| 480 | + | }; |
|
| 429 | 481 | let chosen = chosen_in_reach(app, &owner, f).await?; |
|
| 430 | 482 | let g = rdb::create_group(&app.db, &owner, &name, percent).await?; |
|
| 483 | + | rdb::set_wave(&app.db, g.id, wave).await?; |
|
| 431 | 484 | if percent.is_none() { |
|
| 432 | 485 | rdb::set_members(&app.db, g.id, &chosen).await?; |
|
| 433 | 486 | } |
|
| 434 | - | let what = match percent { |
|
| 487 | + | let mut what = match percent { |
|
| 435 | 488 | Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }), |
|
| 436 | 489 | None => format!("{} chosen runners", chosen.len()), |
|
| 437 | 490 | }; |
|
| 491 | + | if let Some(w) = wave { |
|
| 492 | + | what.push_str(&format!(", wave {w}")); |
|
| 493 | + | } |
|
| 438 | 494 | accounts::audit(&app.db, Some(me), "runner.updates.group", &name, &format!("created: {what}")).await.ok(); |
|
| 439 | 495 | Ok(Ok(format!("Test group {name} made: {what}. Give it its settings below."))) |
|
| 440 | 496 | } |
|
| … | 20 unchanged lines |
||
| 461 | 517 | Ok(p) => p, |
|
| 462 | 518 | Err(why) => return Ok(Err(why)), |
|
| 463 | 519 | }; |
|
| 520 | + | let wave = match wave_of(f) { |
|
| 521 | + | Ok(w) => w, |
|
| 522 | + | Err(why) => return Ok(Err(why)), |
|
| 523 | + | }; |
|
| 464 | 524 | let chosen = if percent.is_some() { vec![] } else { chosen_in_reach(app, &g.owner, f).await? }; |
|
| 465 | 525 | rdb::set_percent(&app.db, id, percent).await?; |
|
| 466 | 526 | rdb::set_members(&app.db, id, &chosen).await?; |
|
| 467 | - | let what = match percent { |
|
| 527 | + | rdb::set_wave(&app.db, id, wave).await?; |
|
| 528 | + | let mut what = match percent { |
|
| 468 | 529 | Some(p) => format!("{p}% of its runners"), |
|
| 469 | 530 | None => format!("{} chosen runners", chosen.len()), |
|
| 470 | 531 | }; |
|
| 532 | + | if let Some(w) = wave { |
|
| 533 | + | what.push_str(&format!(" in wave {w}")); |
|
| 534 | + | } |
|
| 471 | 535 | accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, &what).await.ok(); |
|
| 472 | 536 | Ok(Ok(format!("Test group {} is now {what}.", g.name))) |
|
| 473 | 537 | } |
|
| … | 64 unchanged lines |
modified crates/hub/tests/flow.rs +131 −0
| … | 1427 unchanged lines |
||
| 1428 | 1428 | let (st, body) = claim_as(credential.clone()).await; |
|
| 1429 | 1429 | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1430 | 1430 | let job: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 1431 | + | // The build it ran on is kept with the job, for waves of updates (oxygenflow#84). |
|
| 1432 | + | let kept: String = |
|
| 1433 | + | sqlx::query_scalar("SELECT runner_version FROM flow_jobs WHERE runner_id = $1").bind(plex.id).fetch_one(&h.app.db).await.unwrap(); |
|
| 1434 | + | assert_eq!(kept, "0.1.0"); |
|
| 1431 | 1435 | let (_, page) = web("GET", format!("/admin/runners/{}", plex.id), None).await; |
|
| 1432 | 1436 | assert!( |
|
| 1433 | 1437 | page.contains("andy/demo") && page.contains(job["job"]["name"].as_str().unwrap()) && page.contains("Running now</dt><dd>1 job"), |
|
| … | 5583 unchanged lines |
||
| 7017 | 7021 | assert!(!ask(0).await.policy.updates, "back to the site's off"); |
|
| 7018 | 7022 | } |
|
| 7019 | 7023 | ||
| 7024 | + | /// oxygenflow#84: a version reaches a later wave once every runner of the |
|
| 7025 | + | /// waves before it that took it has run its soak jobs on it; a job ending |
|
| 7026 | + | /// in error there stops it for every runner. |
|
| 7027 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 7028 | + | async fn a_version_reaches_wave_two_once_wave_one_proves_it() { |
|
| 7029 | + | use oxy_core::identity::SecretKey; |
|
| 7030 | + | let h = hub().await; |
|
| 7031 | + | create(&h.app, "andy", "oxygenflow").await; |
|
| 7032 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 7033 | + | .oneshot( |
|
| 7034 | + | Request::post("/register") |
|
| 7035 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 7036 | + | .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 7037 | + | .unwrap(), |
|
| 7038 | + | ) |
|
| 7039 | + | .await |
|
| 7040 | + | .unwrap(); |
|
| 7041 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 7042 | + | let admin = db::accounts::account_by_name(&h.app.db, "carol").await.unwrap().unwrap(); |
|
| 7043 | + | db::accounts::set_admin(&h.app.db, admin.id, true).await.unwrap(); |
|
| 7044 | + | let web = |method: &'static str, path: &str, body: Option<String>| { |
|
| 7045 | + | let (app, cookie, path) = (h.app.clone(), cookie.clone(), path.to_string()); |
|
| 7046 | + | async move { |
|
| 7047 | + | let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie); |
|
| 7048 | + | if body.is_some() { |
|
| 7049 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 7050 | + | } |
|
| 7051 | + | let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap(); |
|
| 7052 | + | (res.status(), String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
|
| 7053 | + | } |
|
| 7054 | + | }; |
|
| 7055 | + | let (_, page) = web("GET", "/admin/runners/updates", None).await; |
|
| 7056 | + | let csrf = { |
|
| 7057 | + | let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 7058 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 7059 | + | }; |
|
| 7060 | + | let mut creds = vec![]; |
|
| 7061 | + | for name in ["plex", "narvi"] { |
|
| 7062 | + | let (_, page) = web("POST", "/admin/runners/token", Some(format!("_csrf={csrf}"))).await; |
|
| 7063 | + | let i = page.find("--token ").unwrap() + "--token ".len(); |
|
| 7064 | + | let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect(); |
|
| 7065 | + | let identity = SecretKey::generate().identity(&format!("{name} (OxygenFlow runner)"), "", 1); |
|
| 7066 | + | let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0 (build)", "max_jobs": 1 }); |
|
| 7067 | + | let reg = serde_json::json!({ "token": token, "name": name, "identity": hex::encode(identity.encode()), "info": info }); |
|
| 7068 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await; |
|
| 7069 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 7070 | + | creds.push(serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string()); |
|
| 7071 | + | } |
|
| 7072 | + | let ask = |who: usize| { |
|
| 7073 | + | let (app, cred) = (h.app.clone(), creds[who].clone()); |
|
| 7074 | + | async move { |
|
| 7075 | + | let (st, body) = call(&app, "GET", "/api/v1/flow/runner/update", Some(&cred), None).await; |
|
| 7076 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 7077 | + | serde_json::from_str::<flow_core::api::UpdateAnswer>(&body).unwrap() |
|
| 7078 | + | } |
|
| 7079 | + | }; |
|
| 7080 | + | let repo = db::repos::get(&h.app.db, "andy", "oxygenflow").await.unwrap().unwrap(); |
|
| 7081 | + | let rel = db::releases::create(&h.app.db, repo.id, "v0.2.0", "0.2.0", "", false, None).await.unwrap(); |
|
| 7082 | + | db::releases::add_asset(&h.app.db, rel.id, "flow-linux-x86_64", 42, &"ab".repeat(32), "application/octet-stream").await.unwrap(); |
|
| 7083 | + | let (_, page) = |
|
| 7084 | + | web("POST", "/admin/runners/updates/source", Some(format!("_csrf={csrf}&repo=andy%2Foxygenflow&soak_jobs=2&soak_hours=48"))).await; |
|
| 7085 | + | assert!(page.contains("runner builds come from andy/oxygenflow") && page.contains("value=\"2\""), "{page}"); |
|
| 7086 | + | let (_, page) = web("POST", "/admin/runners/updates/source", Some(format!("_csrf={csrf}&repo=andy%2Foxygenflow&soak_jobs=0"))).await; |
|
| 7087 | + | assert!(page.contains("is a whole number from 1 to 1000"), "{page}"); |
|
| 7088 | + | web("POST", "/admin/runners/updates/policy", Some(format!("_csrf={csrf}&scope=site&updates=on"))).await; |
|
| 7089 | + | ||
| 7090 | + | // plex in wave 1, narvi in wave 2. |
|
| 7091 | + | let runners = db::runners::list(&h.app.db).await.unwrap(); |
|
| 7092 | + | let id = |name: &str| runners.iter().find(|r| r.name == name).unwrap().id; |
|
| 7093 | + | let (_, page) = |
|
| 7094 | + | web("POST", "/admin/runners/updates/groups", Some(format!("_csrf={csrf}&name=first&mode=chosen&runner={}&wave=1", id("plex")))) |
|
| 7095 | + | .await; |
|
| 7096 | + | assert!(page.contains("Test group first made: 1 chosen runners, wave 1") && page.contains(" · wave 1</small>"), "{page}"); |
|
| 7097 | + | let (_, page) = |
|
| 7098 | + | web("POST", "/admin/runners/updates/groups", Some(format!("_csrf={csrf}&name=rest&mode=chosen&runner={}&wave=0", id("narvi")))) |
|
| 7099 | + | .await; |
|
| 7100 | + | assert!(page.contains("A wave is a whole number from 1 to 9"), "{page}"); |
|
| 7101 | + | web("POST", "/admin/runners/updates/groups", Some(format!("_csrf={csrf}&name=rest&mode=chosen&runner={}&wave=2", id("narvi")))).await; |
|
| 7102 | + | assert_eq!(ask(0).await.offer.map(|o| o.version).as_deref(), Some("0.2.0"), "wave 1 goes first"); |
|
| 7103 | + | let b = ask(1).await; |
|
| 7104 | + | assert!(b.offer.is_none() && b.note.contains("flow 0.2.0 waits for wave 1 to prove it: 0 of 1 runner on it, 0 jobs"), "{b:?}"); |
|
| 7105 | + | ||
| 7106 | + | // plex runs 0.2.0: one job is not enough, two are. |
|
| 7107 | + | let run: i64 = sqlx::query_scalar( |
|
| 7108 | + | "INSERT INTO flow_runs (repo_id, bookmark, state, heads, status, created_ms, number) VALUES ($1, 'main', 's', 'h', 'passed', 1, 1) RETURNING id", |
|
| 7109 | + | ) |
|
| 7110 | + | .bind(repo.id) |
|
| 7111 | + | .fetch_one(&h.app.db) |
|
| 7112 | + | .await |
|
| 7113 | + | .unwrap(); |
|
| 7114 | + | let job = |name: &'static str, status: &'static str| { |
|
| 7115 | + | let (app, plex) = (h.app.clone(), id("plex")); |
|
| 7116 | + | async move { |
|
| 7117 | + | sqlx::query( |
|
| 7118 | + | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, runner, runner_id, started_ms, finished_ms, error, runner_version) |
|
| 7119 | + | VALUES ($1, $2, 0, '', '{}', 'microvm', 'microvm', $3, 'plex', $4, $5, $5, $6, '0.2.0')", |
|
| 7120 | + | ) |
|
| 7121 | + | .bind(run) |
|
| 7122 | + | .bind(name) |
|
| 7123 | + | .bind(status) |
|
| 7124 | + | .bind(plex) |
|
| 7125 | + | .bind(db::now_ms()) |
|
| 7126 | + | .bind(if status == "error" { "the runner lost its disk" } else { "" }) |
|
| 7127 | + | .execute(&app.db) |
|
| 7128 | + | .await |
|
| 7129 | + | .unwrap(); |
|
| 7130 | + | } |
|
| 7131 | + | }; |
|
| 7132 | + | job("build", "passed").await; |
|
| 7133 | + | assert!(ask(1).await.note.contains("1 of 1 runner on it, 1 job (each needs 2"), "still soaking"); |
|
| 7134 | + | job("test", "failed").await; |
|
| 7135 | + | assert_eq!(ask(1).await.offer.map(|o| o.version).as_deref(), Some("0.2.0"), "a failed test is not the runner's fault: proved"); |
|
| 7136 | + | let (_, page) = web("GET", "/admin/runners/updates", None).await; |
|
| 7137 | + | assert!(!page.contains("waits for wave"), "{page}"); |
|
| 7138 | + | ||
| 7139 | + | // An error on wave 1 stops it: the page only says so; the next runner to ask holds it for all. |
|
| 7140 | + | job("lint", "error").await; |
|
| 7141 | + | let (_, page) = web("GET", "/admin/runners/updates", None).await; |
|
| 7142 | + | assert!(page.contains("wave 1 stopped it on plex: job lint of andy/oxygenflow run #1: the runner lost its disk"), "{page}"); |
|
| 7143 | + | assert!(db::runner_updates::held(&h.app.db, "0.2.0").await.unwrap().is_none(), "a page view holds nothing"); |
|
| 7144 | + | let b = ask(1).await; |
|
| 7145 | + | assert!(b.offer.is_none() && b.note.contains("flow 0.2.0 is held: wave 1 stopped it on plex"), "{b:?}"); |
|
| 7146 | + | assert!(ask(0).await.note.contains("is held"), "wave 1 too"); |
|
| 7147 | + | let (_, page) = web("GET", "/admin/runners/updates", None).await; |
|
| 7148 | + | assert!(page.contains("Held builds") && page.contains("wave 1 stopped it on plex"), "{page}"); |
|
| 7149 | + | } |
|
| 7150 | + | ||
| 7020 | 7151 | /// #323: a WASM job has no image, so its placement says nothing of one. |
|
| 7021 | 7152 | #[tokio::test(flavor = "multi_thread")] |
|
| 7022 | 7153 | async fn a_wasm_jobs_placement_names_no_image() { |
|
| … | 120 unchanged lines |
modified crates/oh/src/main.rs +12 −2
| … | 949 unchanged lines |
||
| 950 | 950 | } |
|
| 951 | 951 | for g in &all.groups { |
|
| 952 | 952 | let of = if g.owner.is_empty() { "the site's runners".to_string() } else { format!("{}'s runners", g.owner) }; |
|
| 953 | - | let what = g.percent.map(|p| format!("{p}% of {of}")).unwrap_or_else(|| format!("chosen, of {of}")); |
|
| 953 | + | let mut what = g.percent.map(|p| format!("{p}% of {of}")).unwrap_or_else(|| format!("chosen, of {of}")); |
|
| 954 | + | if let Some(w) = g.wave { |
|
| 955 | + | what.push_str(&format!(", wave {w}")); |
|
| 956 | + | } |
|
| 954 | 957 | println!( |
|
| 955 | 958 | " group {} ({what}): {}", |
|
| 956 | 959 | ui::strong(&g.name), |
|
| … | 1300 unchanged lines |
||
| 2257 | 2260 | let all = hub_api::RunnerUpdates { |
|
| 2258 | 2261 | repo: String::new(), |
|
| 2259 | 2262 | settings: vec![], |
|
| 2260 | - | groups: vec![hub_api::RunnerGroup { id: 4, owner: "acme".into(), name: "canary".into(), percent: None, members: vec![] }], |
|
| 2263 | + | groups: vec![hub_api::RunnerGroup { |
|
| 2264 | + | id: 4, |
|
| 2265 | + | owner: "acme".into(), |
|
| 2266 | + | name: "canary".into(), |
|
| 2267 | + | percent: None, |
|
| 2268 | + | members: vec![], |
|
| 2269 | + | wave: None, |
|
| 2270 | + | }], |
|
| 2261 | 2271 | runners: vec![], |
|
| 2262 | 2272 | holds: vec![], |
|
| 2263 | 2273 | }; |
|
| … | 50 unchanged lines |
modified docs/oh.md +1 −1
| … | 64 unchanged lines |
||
| 65 | 65 | | `oh drop list [--user NAME]` | Your drops, or someone's public ones | |
|
| 66 | 66 | | `oh drop view ID [-f FILE]` | A drop's files, by id or address; `-f` prints one file's content alone | |
|
| 67 | 67 | | `oh drop delete ID` | Delete a drop and its history | |
|
| 68 | - | | `oh runner-updates [show]` | Site administrators: runners that update themselves (oxygenhub#322): where builds come from, every level's settings, test groups, each runner's policy and what it is offered, held builds | |
|
| 68 | + | | `oh runner-updates [show]` | Site administrators: runners that update themselves (oxygenhub#322): where builds come from, every level's settings, test groups and their waves (oxygenflow#84), each runner's policy and what it is offered (or which wave it waits for), held builds | |
|
| 69 | 69 | | `oh runner-updates set SCOPE [--updates on\|off\|inherit] [--channel C] [--window 02-05] [--dry-run on\|off\|inherit]` | Change one level (`site`, `owner:NAME`, `group:NAME`, `runner:NAME`); what is not given stays; `inherit` clears a setting | |
|
| 70 | 70 | | `oh runner-updates lift VERSION` | Offer a build held after it rolled back on a runner (#326) | |
|
| 71 | 71 | | `oh api PATH [-X METHOD] [-d JSON]` | Any API call, JSON out | |
|
| … | 7 unchanged lines |
modified templates/admin_runner_updates.html +11 −3
| … | 22 unchanged lines |
||
| 23 | 23 | <label for="update-repo">The repository whose releases carry runner builds</label> |
|
| 24 | 24 | <input type="text" id="update-repo" name="repo" value="{{ repo }}" placeholder="owner/name, e.g. andymccall/oxygenflow"> |
|
| 25 | 25 | <p class="small mute">Each release offers one file per machine, <code>flow-linux-x86_64</code>, <code>flow-linux-aarch64</code>, <code>flow-linux-riscv64</code>, <code>flow-windows-x86_64.exe</code>. <em>stable</em> is its newest release, <em>canary</em> its newest including pre-releases; a version (<code>0.3.4</code>) pins it, going back if need be.</p> |
|
| 26 | + | <h3>Waves</h3> |
|
| 27 | + | <p class="small mute">Give test groups a wave (1, 2, …) and a new version reaches them in order: wave 2's runners are offered it once every runner of wave 1 that took it has finished this many jobs on it with none ending in error, or this many hours have passed since its first job there. A job that ends in error (the runner's or the machine's fault, not a test's) on a wave stops the version for everyone until you lift it. Runners in no wave come last.</p> |
|
| 28 | + | <div class="pol-row"> |
|
| 29 | + | <label class="pol">Jobs each <input type="number" name="soak_jobs" min="1" max="1000" size="4" value="{{ soak_jobs }}"></label> |
|
| 30 | + | <label class="pol">or hours <input type="number" name="soak_hours" min="1" max="720" size="4" value="{{ soak_hours }}"></label> |
|
| 31 | + | </div> |
|
| 26 | 32 | <button type="submit">Save</button> |
|
| 27 | 33 | </form> |
|
| 28 | 34 | {% if !holds.is_empty() %} |
|
| 29 | 35 | <section class="panel strip warn" id="holds"> |
|
| 30 | 36 | <h2>Held builds</h2> |
|
| 31 | - | <p class="small">A build that rolled back on a runner is offered to no other until you lift the hold.</p> |
|
| 37 | + | <p class="small">A build that rolled back on a runner, or that a wave stopped, is offered to no other until you lift the hold.</p> |
|
| 32 | 38 | {% for h in holds %} |
|
| 33 | 39 | <form class="pol-form" method="post" action="/admin/runners/updates/holds/lift"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="version" value="{{ h.version }}"> |
|
| 34 | - | <strong>flow {{ h.version }}</strong><span class="small">rolled back on {{ h.runner }}: {{ h.why }} · <time title="{{ h.at_ms|when_i }}">{{ h.at_ms|ago_i }}</time></span><button type="submit" class="small">LIFT</button> |
|
| 40 | + | <strong>flow {{ h.version }}</strong><span class="small">{{ h.why }} · <time title="{{ h.at_ms|when_i }}">{{ h.at_ms|ago_i }}</time></span><button type="submit" class="small">LIFT</button> |
|
| 35 | 41 | </form> |
|
| 36 | 42 | {% endfor %} |
|
| 37 | 43 | </section> |
|
| … | 26 unchanged lines |
||
| 64 | 70 | {% if groups.is_empty() %}<p class="mute small">No test groups yet.</p>{% endif %} |
|
| 65 | 71 | {% for gv in groups %}{% let g = gv.g %} |
|
| 66 | 72 | <div class="card wide update-group"> |
|
| 67 | - | <h3>{{ g.name }} <small class="mute">{{ gv.reach }} · {% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now</small></h3> |
|
| 73 | + | <h3>{{ g.name }} <small class="mute">{{ gv.reach }} · {% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now{% if let Some(w) = g.wave %} · wave {{ w }}{% endif %}</small></h3> |
|
| 68 | 74 | <p class="small">{% if gv.members.is_empty() %}<span class="mute">No runners in it.</span>{% else %}{% for m in gv.members %}<span class="lbl">{{ m }}</span> {% endfor %}{% endif %}</p> |
|
| 69 | 75 | <form class="pol-form" method="post" action="/admin/runners/updates/policy"> |
|
| 70 | 76 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}"> |
|
| … | 5 unchanged lines |
||
| 76 | 82 | <label class="pol"><input type="radio" name="mode" value="percent"{% if g.percent.is_some() %} checked{% endif %}> a percentage of {{ gv.reach }}: <input type="number" name="percent" min="1" max="100" size="4" value="{% if let Some(p) = g.percent %}{{ p }}{% endif %}">%</label> |
|
| 77 | 83 | <label class="pol"><input type="radio" name="mode" value="chosen"{% if g.percent.is_none() %} checked{% endif %}> these runners:</label> |
|
| 78 | 84 | <div class="pol-row">{% for rv in runners %}{% if g.owner.is_empty() || g.owner == rv.r.scope_owner %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"{% if gv.chosen.contains(rv.r.id) %} checked{% endif %}> {{ rv.r.name }}</label>{% endif %}{% endfor %}</div> |
|
| 85 | + | <label class="pol">Wave <input type="number" name="wave" min="1" max="9" size="3" value="{% if let Some(w) = g.wave %}{{ w }}{% endif %}" title="Blank: after every wave"></label> |
|
| 79 | 86 | <button type="submit">Save members</button> |
|
| 80 | 87 | </form> |
|
| 81 | 88 | <form class="tiny" method="post" action="/admin/runners/updates/groups/{{ g.id }}/delete" data-confirm="Remove the test group {{ g.name }}? Its runners follow the levels above it."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove group</button></form> |
|
| … | 10 unchanged lines |
||
| 92 | 99 | <label class="pol"><input type="radio" name="mode" value="percent"> a percentage: <input type="number" name="percent" min="1" max="100" size="4">%</label> |
|
| 93 | 100 | <label class="pol"><input type="radio" name="mode" value="chosen" checked> these runners:</label> |
|
| 94 | 101 | <div class="pol-row">{% for rv in runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"> {{ rv.r.name }}</label>{% endfor %}</div> |
|
| 102 | + | <label class="pol">Wave <input type="number" name="wave" min="1" max="9" size="3" title="Blank: after every wave"></label> |
|
| 95 | 103 | <button type="submit">Make group</button> |
|
| 96 | 104 | </form> |
|
| 97 | 105 | </section> |
|
| … | 24 unchanged lines |
modified templates/org_settings.html +3 −1
| … | 115 unchanged lines |
||
| 116 | 116 | {% if updates.groups.is_empty() %}<p class="mute small">None yet.</p>{% endif %} |
|
| 117 | 117 | {% for gv in updates.groups %}{% let g = gv.g %} |
|
| 118 | 118 | <div class="card wide update-group"> |
|
| 119 | - | <h3>{{ g.name }} <small class="mute">{% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now</small></h3> |
|
| 119 | + | <h3>{{ g.name }} <small class="mute">{% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now{% if let Some(w) = g.wave %} · wave {{ w }}{% endif %}</small></h3> |
|
| 120 | 120 | <p class="small">{% for m in gv.members %}<span class="lbl">{{ m }}</span> {% endfor %}</p> |
|
| 121 | 121 | <form class="pol-form" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/policy"> |
|
| 122 | 122 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}"> |
|
| … | 5 unchanged lines |
||
| 128 | 128 | <label class="pol"><input type="radio" name="mode" value="percent"{% if g.percent.is_some() %} checked{% endif %}> a percentage: <input type="number" name="percent" min="1" max="100" value="{% if let Some(p) = g.percent %}{{ p }}{% endif %}">%</label> |
|
| 129 | 129 | <label class="pol"><input type="radio" name="mode" value="chosen"{% if g.percent.is_none() %} checked{% endif %}> these runners:</label> |
|
| 130 | 130 | <div class="pol-row">{% for rv in updates.runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"{% if gv.chosen.contains(rv.r.id) %} checked{% endif %}> {{ rv.r.name }}</label>{% endfor %}</div> |
|
| 131 | + | <label class="pol">Wave <input type="number" name="wave" min="1" max="9" size="3" value="{% if let Some(w) = g.wave %}{{ w }}{% endif %}" title="Blank: after every wave"></label> |
|
| 131 | 132 | <button type="submit">Save members</button> |
|
| 132 | 133 | </form> |
|
| 133 | 134 | <form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/groups/{{ g.id }}/delete" data-confirm="Remove the test group {{ g.name }}?"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove group</button></form> |
|
| … | 7 unchanged lines |
||
| 141 | 142 | <label class="pol"><input type="radio" name="mode" value="percent"> a percentage of the organisation's runners: <input type="number" name="percent" min="1" max="100">%</label> |
|
| 142 | 143 | <label class="pol"><input type="radio" name="mode" value="chosen" checked> these runners:</label> |
|
| 143 | 144 | <div class="pol-row">{% for rv in updates.runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"> {{ rv.r.name }}</label>{% endfor %}</div> |
|
| 145 | + | <label class="pol">Wave <input type="number" name="wave" min="1" max="9" size="3" title="Blank: after every wave"></label> |
|
| 144 | 146 | <button type="submit">Make group</button> |
|
| 145 | 147 | </form> |
|
| 146 | 148 | {% if !updates.runners.is_empty() %} |
|
| … | 20 unchanged lines |