Runner updates: a build that rolls back on one runner is held faf5246c900e
When a runner reports it rolled back from flow X, the hub offers X to no other runner until an administrator lifts the hold, listed with the runner and the reason on Admin > Runners > Updates. Migration 0097. closes #326
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e661096694ef
- When
- · 2026-10-26 04:35 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
faf5246c900e9ff6804832bff4ab4fc8188bad6a7220901e6209658ac70261c8- Depends on
- 1000b40a2629 3b1bc27fe805 42fa95540468 8501fd5815b9 a7e6e8e6094c b5668108487f b6dfcb61cf5e d23464c0ac60 dc5c64296b0a
- Depended on by
- 1ec29691b67a 9bbca80f777a
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 15 atoms, 2 tree operations, 10 files
Browse the tree at this change
added crates/hub/migrations-pg/0097_runner_update_holds.sql +9 −0
| 1 | + | -- Builds held back from runners (oxygenhub#326): one rolled back on a |
|
| 2 | + | -- runner (it did not reach the hub after updating), so no other runner is |
|
| 3 | + | -- offered it until an administrator lifts the hold. |
|
| 4 | + | CREATE TABLE runner_update_holds ( |
|
| 5 | + | version TEXT PRIMARY KEY, |
|
| 6 | + | runner TEXT NOT NULL, |
|
| 7 | + | why TEXT NOT NULL, |
|
| 8 | + | at_ms BIGINT NOT NULL |
|
| 9 | + | ); |
added crates/hub/migrations/0097_runner_update_holds.sql +9 −0
| 1 | + | -- Builds held back from runners (oxygenhub#326): one rolled back on a |
|
| 2 | + | -- runner (it did not reach the hub after updating), so no other runner is |
|
| 3 | + | -- offered it until an administrator lifts the hold. |
|
| 4 | + | CREATE TABLE runner_update_holds ( |
|
| 5 | + | version TEXT PRIMARY KEY, |
|
| 6 | + | runner TEXT NOT NULL, |
|
| 7 | + | why TEXT NOT NULL, |
|
| 8 | + | at_ms INTEGER NOT NULL |
|
| 9 | + | ); |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A build that rolls back on one runner is held for the rest (#326):** when a runner reports it rolled back from flow X (it did not reach the hub after updating, or would not start), the hub offers X to no other runner, saying why, until an administrator lifts the hold on Admin › Runners › Updates, which lists each held build with the runner and the reason. One failed canary halts the rollout. Migration 0097. |
|
| 7 | 8 | - **Finished work moves to Done (#325):** a card whose issue closes (on its page, through the API, or by a change's `closes #N`), or whose proposal lands or closes, moves to its board's column for finished work. A new board's Done is that column, and so is each existing board's column named Done; a column's menu turns it on or off (one column of a board). Archived cards stay archived. |
|
| 8 | 9 | - **A WASM job's placement names no image (#323):** a job on the WASM tier was placed saying its runner "fetches the image from the hub"; it has none, so the line now starts with what the runner is doing (andymccall/windows-runner-demo run #1). |
|
| 9 | 10 | - **Runner updates: policy, test groups and the offer (#322):** Admin › Runners › Updates sets where runner builds come from (a repository whose releases carry `flow-linux-ARCH` files, and `flow-windows-x86_64.exe` for Windows runners) and who takes them. Updates are off unless chosen; the site, an owner, a test group and a runner may each set updates on or off, a channel (`stable`, `canary` or a version), a window of hours (UTC) and dry run, and the nearest setting wins, each setting on its own. A test group is chosen runners or a percentage of the site's (or an owner's) runners, by a stable hash of each runner's id, so a share is the same machines every rollout and 10% sits inside 50%. `GET /api/v1/flow/runner/update` tells a runner its policy, where each setting came from, and the build to install (its BLAKE3, to check the download against); `POST` takes what the runner did, shown on the page and in the audit log. An organisation's owners set the same for their own runners in the organisation's Settings › Runner updates: the organisation's setting, test groups of its runners (chosen, or a percentage), and each runner's own; nothing of the site's or another owner's. Pins OxygenFlow's main. |
|
| … | 337 unchanged lines |
modified crates/hub/src/db/runner_updates.rs +35 −0
| … | 124 unchanged lines |
||
| 125 | 125 | .await?; |
|
| 126 | 126 | Ok(()) |
|
| 127 | 127 | } |
|
| 128 | + | ||
| 129 | + | /// A build held back from every runner (#326). |
|
| 130 | + | #[derive(Clone, Debug, PartialEq, Eq, FromRow)] |
|
| 131 | + | pub struct Hold { |
|
| 132 | + | pub version: String, |
|
| 133 | + | /// The runner it rolled back on. |
|
| 134 | + | pub runner: String, |
|
| 135 | + | pub why: String, |
|
| 136 | + | pub at_ms: i64, |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | /// Hold `version` back, the first time it rolls back. |
|
| 140 | + | pub async fn hold(pool: &Pool, version: &str, runner: &str, why: &str) -> sqlx::Result<()> { |
|
| 141 | + | sqlx::query("INSERT INTO runner_update_holds (version, runner, why, at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (version) DO NOTHING") |
|
| 142 | + | .bind(version) |
|
| 143 | + | .bind(runner) |
|
| 144 | + | .bind(why) |
|
| 145 | + | .bind(now_ms()) |
|
| 146 | + | .execute(pool) |
|
| 147 | + | .await?; |
|
| 148 | + | Ok(()) |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | pub async fn holds(pool: &Pool) -> sqlx::Result<Vec<Hold>> { |
|
| 152 | + | sqlx::query_as("SELECT * FROM runner_update_holds ORDER BY at_ms DESC").fetch_all(pool).await |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | pub async fn held(pool: &Pool, version: &str) -> sqlx::Result<Option<Hold>> { |
|
| 156 | + | sqlx::query_as("SELECT * FROM runner_update_holds WHERE version = $1").bind(version).fetch_optional(pool).await |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | /// Lift the hold on `version`; whether there was one. |
|
| 160 | + | pub async fn lift(pool: &Pool, version: &str) -> sqlx::Result<bool> { |
|
| 161 | + | Ok(sqlx::query("DELETE FROM runner_update_holds WHERE version = $1").bind(version).execute(pool).await?.rows_affected() > 0) |
|
| 162 | + | } |
modified crates/hub/src/flow.rs +6 −0
| … | 1519 unchanged lines |
||
| 1520 | 1520 | return Err(ApiError(StatusCode::BAD_REQUEST, "say what happened (note)".into())); |
|
| 1521 | 1521 | } |
|
| 1522 | 1522 | db::runner_updates::report(&app.db, me.id, note).await?; |
|
| 1523 | + | // A build that rolled back here is held back from every runner (#326). |
|
| 1524 | + | if let Some(v) = crate::runner_updates::rolled_back_version(note) { |
|
| 1525 | + | let why = note.split_once(": ").map(|(_, w)| w).unwrap_or("rolled back"); |
|
| 1526 | + | db::runner_updates::hold(&app.db, &v, &me.name, why).await?; |
|
| 1527 | + | db::accounts::audit(&app.db, None, "runner.update.held", &v, &format!("rolled back on {}: {why}", me.name)).await.ok(); |
|
| 1528 | + | } |
|
| 1523 | 1529 | db::accounts::audit(&app.db, None, "runner.update", &me.name, note).await.ok(); |
|
| 1524 | 1530 | Ok(StatusCode::NO_CONTENT) |
|
| 1525 | 1531 | } |
|
| … | 1518 unchanged lines |
modified crates/hub/src/runner_updates.rs +18 −0
| … | 139 unchanged lines |
||
| 140 | 140 | parts(a) < parts(b) |
|
| 141 | 141 | } |
|
| 142 | 142 | ||
| 143 | + | /// The version a runner's report says rolled back: `rolled back from flow |
|
| 144 | + | /// 0.4.0 to 0.3.1: …` (the runner's words, oxygenflow spec §11.5.3) → `0.4.0`. |
|
| 145 | + | pub fn rolled_back_version(note: &str) -> Option<String> { |
|
| 146 | + | let v = note.strip_prefix("rolled back from flow ")?.split_whitespace().next()?; |
|
| 147 | + | v.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).then(|| v.to_string()) |
|
| 148 | + | } |
|
| 149 | + | ||
| 143 | 150 | /// The release asset a runner on `os` and `arch` runs: `flow-linux-x86_64`, |
|
| 144 | 151 | /// `flow-windows-x86_64.exe`. |
|
| 145 | 152 | pub fn asset_for(os: &str, arch: &str) -> String { |
|
| … | 27 unchanged lines |
||
| 173 | 180 | let (want, have) = (version_of(&release.tag), version_of(&r.version)); |
|
| 174 | 181 | if want == have { |
|
| 175 | 182 | return Ok((None, format!("up to date: flow {have} ({} channel)", policy.channel))); |
|
| 183 | + | } |
|
| 184 | + | 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 | + | )); |
|
| 176 | 189 | } |
|
| 177 | 190 | let going_back = older(&want, &have); |
|
| 178 | 191 | if going_back && matches!(policy.channel.as_str(), "stable" | "canary") { |
|
| … | 102 unchanged lines |
||
| 281 | 294 | assert_eq!(version_of("0.3.1 (local build)"), "0.3.1"); |
|
| 282 | 295 | assert_eq!(asset_for("linux", "riscv64"), "flow-linux-riscv64"); |
|
| 283 | 296 | assert_eq!(asset_for("windows", "x86_64"), "flow-windows-x86_64.exe"); |
|
| 297 | + | assert_eq!( |
|
| 298 | + | rolled_back_version("rolled back from flow 0.4.0 to 0.3.1: 3 starts without reaching the hub").as_deref(), |
|
| 299 | + | Some("0.4.0") |
|
| 300 | + | ); |
|
| 301 | + | assert_eq!(rolled_back_version("runs flow 0.4.0 (was 0.3.1); proven"), None); |
|
| 284 | 302 | } |
|
| 285 | 303 | } |
modified crates/hub/src/web/mod.rs +1 −0
| … | 171 unchanged lines |
||
| 172 | 172 | .route("/admin/runners/updates/source", post(runner_updates::save_source)) |
|
| 173 | 173 | .route("/admin/runners/updates/policy", post(runner_updates::save_policy)) |
|
| 174 | 174 | .route("/admin/runners/updates/groups", post(runner_updates::create_group)) |
|
| 175 | + | .route("/admin/runners/updates/holds/lift", post(runner_updates::lift_hold)) |
|
| 175 | 176 | .route("/admin/runners/updates/groups/{id}", post(runner_updates::save_group)) |
|
| 176 | 177 | .route("/admin/runners/updates/groups/{id}/delete", post(runner_updates::delete_group)) |
|
| 177 | 178 | .route("/admin/insights", get(insights::site)) |
|
| … | 784 unchanged lines |
modified crates/hub/src/web/runner_updates.rs +24 −1
| … | 123 unchanged lines |
||
| 124 | 124 | owners: Vec<PolicyView>, |
|
| 125 | 125 | groups: Vec<GroupView>, |
|
| 126 | 126 | runners: Vec<RunnerView>, |
|
| 127 | + | /// Builds held back after rolling back on a runner (#326). |
|
| 128 | + | holds: Vec<rdb::Hold>, |
|
| 127 | 129 | notice: Option<String>, |
|
| 128 | 130 | error: Option<String>, |
|
| 129 | 131 | } |
|
| … | 81 unchanged lines |
||
| 211 | 213 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 212 | 214 | let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default(); |
|
| 213 | 215 | let v = views(app, &Reach::Site).await.map_err(e)?; |
|
| 214 | - | Ok(Page(UpdatesPage { shell, repo, site: v.site, owners: v.owners, groups: v.groups, runners: v.runners, notice, error })) |
|
| 216 | + | 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 })) |
|
| 215 | 218 | } |
|
| 216 | 219 | ||
| 217 | 220 | /// What a change did, to say on the page: `Ok` a notice, `Err` what was wrong. |
|
| … | 274 unchanged lines |
||
| 492 | 495 | let outcome = apply_delete_group(&app, &me, &Reach::Site, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 493 | 496 | page_after(&app, shell, outcome).await |
|
| 494 | 497 | } |
|
| 498 | + | ||
| 499 | + | #[derive(Deserialize)] |
|
| 500 | + | pub struct LiftForm { |
|
| 501 | + | #[serde(rename = "_csrf")] |
|
| 502 | + | csrf_token: String, |
|
| 503 | + | version: String, |
|
| 504 | + | } |
|
| 505 | + | ||
| 506 | + | /// `POST /admin/runners/updates/holds/lift`: offer a held build again (#326). |
|
| 507 | + | pub async fn lift_hold(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LiftForm>) -> Result<Page<UpdatesPage>, Error> { |
|
| 508 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 509 | + | let me = require_admin(&viewer, &shell)?; |
|
| 510 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 511 | + | let v = f.version.trim(); |
|
| 512 | + | if !rdb::lift(&app.db, v).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 513 | + | return page(&app, shell, None, Some(format!("flow {v} is not held."))).await; |
|
| 514 | + | } |
|
| 515 | + | accounts::audit(&app.db, Some(&me), "runner.update.lifted", v, "offered again").await.ok(); |
|
| 516 | + | page(&app, shell, Some(format!("flow {v} is offered again where the settings say so.")), None).await |
|
| 517 | + | } |
modified crates/hub/tests/flow.rs +13 −0
| … | 6700 unchanged lines |
||
| 6701 | 6701 | assert!(page.contains("dry run: flow 0.2.0 checks out") && page.contains(">canary</span>"), "{page}"); |
|
| 6702 | 6702 | assert!(page.contains("updates on (group canary)") && page.contains("updates on (runner) · channel 0.1.0 (runner)"), "{page}"); |
|
| 6703 | 6703 | ||
| 6704 | + | // #326: a build that rolls back on one runner is held for every other. |
|
| 6705 | + | let report = serde_json::json!({ "note": "rolled back from flow 0.2.0 to 0.1.0: 3 starts without reaching the hub" }).to_string(); |
|
| 6706 | + | assert_eq!(call(&h.app, "POST", "/api/v1/flow/runner/update", Some(&creds[0]), Some(report)).await.0, StatusCode::NO_CONTENT); |
|
| 6707 | + | let a = ask(0).await; |
|
| 6708 | + | assert!(a.offer.is_none() && a.note.contains("flow 0.2.0 is held: it rolled back on plex (3 starts"), "{a:?}"); |
|
| 6709 | + | let (_, page) = web("GET", "/admin/runners/updates", None).await; |
|
| 6710 | + | assert!(page.contains("Held builds") && page.contains("<strong>flow 0.2.0</strong>"), "{page}"); |
|
| 6711 | + | let (_, page) = web("POST", "/admin/runners/updates/holds/lift", Some(format!("_csrf={csrf}&version=0.2.0"))).await; |
|
| 6712 | + | assert!(page.contains("flow 0.2.0 is offered again"), "{page}"); |
|
| 6713 | + | assert!(ask(0).await.note.contains("waits for its window"), "lifted: back to what its settings say"); |
|
| 6714 | + | let (_, page) = web("POST", "/admin/runners/updates/holds/lift", Some(format!("_csrf={csrf}&version=0.2.0"))).await; |
|
| 6715 | + | assert!(page.contains("flow 0.2.0 is not held."), "{page}"); |
|
| 6716 | + | ||
| 6704 | 6717 | // A group of a share: removed, its runners follow the levels above. |
|
| 6705 | 6718 | let (_, page) = |
|
| 6706 | 6719 | web("POST", &format!("/admin/runners/updates/groups/{}", group.id), Some(format!("_csrf={csrf}&mode=percent&percent=100"))).await; |
|
| … | 130 unchanged lines |
modified templates/admin_runner_updates.html +11 −0
| … | 24 unchanged lines |
||
| 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 | 26 | <button type="submit">Save</button> |
|
| 27 | 27 | </form> |
|
| 28 | + | {% if !holds.is_empty() %} |
|
| 29 | + | <section class="panel strip warn" id="holds"> |
|
| 30 | + | <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> |
|
| 32 | + | {% for h in holds %} |
|
| 33 | + | <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> |
|
| 35 | + | </form> |
|
| 36 | + | {% endfor %} |
|
| 37 | + | </section> |
|
| 38 | + | {% endif %} |
|
| 28 | 39 | <form class="card wide" method="post" action="/admin/runners/updates/policy"> |
|
| 29 | 40 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="site"> |
|
| 30 | 41 | <h2>The site</h2> |
|
| … | 80 unchanged lines |