oh runner-updates and the admin API for runner updates 9bbca80f777a
GET /api/v1/admin/runner-updates (every level, groups, runners, holds),
PUT .../settings (one level; 422 saying what is wrong), POST
.../holds/{version}/lift; oh runner-updates [show|set|lift], set keeping
what is not given. Docs in docs/oh.md.
refs #322
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 914dd5bf2d79
- When
- · 2026-10-26 04:55 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
9bbca80f777aed6309fc4ad96021778b69027408c2822cdcaedd22027bd0c2b2- Depends on
- 166f6e9bf4f1 42fa95540468 5c6e92fd8d71 617c2840446c 62ceb2eec18c 8501fd5815b9 8aa5ef384ef1 a7e6e8e6094c b5668108487f b66e70b7b6c7 d23464c0ac60 dc5c64296b0a faf5246c900e
- Depended on by
- b7e1be264c0a 04f9c984439e fac6853c4e46 1ec29691b67a 8eae2f0e255c
- 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
- 17 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **`oh runner-updates` and its API (#322):** site administrators read every level's update settings, the test groups, each runner's policy and offer, and the held builds (`GET /api/v1/admin/runner-updates`); `oh runner-updates set group:canary --updates on --window 02-05` changes one level, keeping what is not given (`PUT …/settings`, 422 saying what is wrong); `oh runner-updates lift 0.4.0` offers a held build again (`POST …/holds/{version}/lift`). |
|
| 7 | 8 | - **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. |
|
| 8 | 9 | - **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. |
|
| 9 | 10 | - **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). |
|
| … | 338 unchanged lines |
modified crates/hub-api/src/lib.rs +76 −0
| … | 1566 unchanged lines |
||
| 1567 | 1567 | pub report: TestReport, |
|
| 1568 | 1568 | pub cases: Vec<TestCase>, |
|
| 1569 | 1569 | } |
|
| 1570 | + | ||
| 1571 | + | /// `GET /api/v1/admin/runner-updates` (oxygenhub#322): where runner builds |
|
| 1572 | + | /// come from, every level's settings, the test groups, each runner's policy |
|
| 1573 | + | /// and what it is offered, and the builds held back (#326). Site |
|
| 1574 | + | /// administrators only. |
|
| 1575 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1576 | + | pub struct RunnerUpdates { |
|
| 1577 | + | /// The repository whose releases carry runner builds (`owner/name`). |
|
| 1578 | + | pub repo: String, |
|
| 1579 | + | pub settings: Vec<UpdateSetting>, |
|
| 1580 | + | pub groups: Vec<RunnerGroup>, |
|
| 1581 | + | pub runners: Vec<RunnerUpdate>, |
|
| 1582 | + | pub holds: Vec<HeldBuild>, |
|
| 1583 | + | } |
|
| 1584 | + | ||
| 1585 | + | /// One level's update settings; `None` inherits from the next level out. |
|
| 1586 | + | /// `PUT /api/v1/admin/runner-updates/settings` takes one. |
|
| 1587 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1588 | + | pub struct UpdateSetting { |
|
| 1589 | + | /// `site`, `owner`, `group` or `runner`. |
|
| 1590 | + | pub scope: String, |
|
| 1591 | + | /// Empty for the site; the owner's name, the group's id, the runner's id. |
|
| 1592 | + | #[serde(default)] |
|
| 1593 | + | pub key: String, |
|
| 1594 | + | #[serde(default)] |
|
| 1595 | + | pub updates: Option<bool>, |
|
| 1596 | + | /// `stable`, `canary` or a version. |
|
| 1597 | + | #[serde(default)] |
|
| 1598 | + | pub channel: Option<String>, |
|
| 1599 | + | /// Hours (UTC) in which updates may start, `02-05`. |
|
| 1600 | + | #[serde(default)] |
|
| 1601 | + | pub window: Option<String>, |
|
| 1602 | + | #[serde(default)] |
|
| 1603 | + | pub dry_run: Option<bool>, |
|
| 1604 | + | } |
|
| 1605 | + | ||
| 1606 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1607 | + | pub struct RunnerGroup { |
|
| 1608 | + | pub id: i64, |
|
| 1609 | + | /// Empty for a group of the site's runners. |
|
| 1610 | + | pub owner: String, |
|
| 1611 | + | pub name: String, |
|
| 1612 | + | /// A share of the runners in reach; `None`: chosen ones. |
|
| 1613 | + | pub percent: Option<i64>, |
|
| 1614 | + | /// Its runners now, by name. |
|
| 1615 | + | pub members: Vec<String>, |
|
| 1616 | + | } |
|
| 1617 | + | ||
| 1618 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1619 | + | pub struct RunnerUpdate { |
|
| 1620 | + | pub id: i64, |
|
| 1621 | + | pub name: String, |
|
| 1622 | + | /// The owner it serves; empty for the site's. |
|
| 1623 | + | pub owner: String, |
|
| 1624 | + | pub version: String, |
|
| 1625 | + | pub groups: Vec<String>, |
|
| 1626 | + | pub updates: bool, |
|
| 1627 | + | pub channel: String, |
|
| 1628 | + | #[serde(default)] |
|
| 1629 | + | pub window: Option<String>, |
|
| 1630 | + | pub dry_run: bool, |
|
| 1631 | + | /// Where each setting came from, in the order updates, channel, window, dry run. |
|
| 1632 | + | pub from: Vec<String>, |
|
| 1633 | + | /// What the hub would offer it now, in words. |
|
| 1634 | + | pub now: String, |
|
| 1635 | + | /// What it last said about its update. |
|
| 1636 | + | pub said: String, |
|
| 1637 | + | } |
|
| 1638 | + | ||
| 1639 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1640 | + | pub struct HeldBuild { |
|
| 1641 | + | pub version: String, |
|
| 1642 | + | pub runner: String, |
|
| 1643 | + | pub why: String, |
|
| 1644 | + | pub at_ms: i64, |
|
| 1645 | + | } |
modified crates/hub/src/api/mod.rs +97 −0
| … | 176 unchanged lines |
||
| 177 | 177 | .route("/orgs/{org}/assistants", get(org_assistants).post(org_assistant_put)) |
|
| 178 | 178 | .route("/orgs/{org}/assistants/prompt", get(org_prompt).put(org_prompt_put)) |
|
| 179 | 179 | .route("/orgs/{org}/assistants/{assistant}", axum::routing::delete(org_assistant_delete)) |
|
| 180 | + | .route("/admin/runner-updates", get(runner_updates)) |
|
| 181 | + | .route("/admin/runner-updates/settings", axum::routing::put(runner_update_setting)) |
|
| 182 | + | .route("/admin/runner-updates/holds/{version}/lift", post(runner_update_lift)) |
|
| 180 | 183 | .route("/admin/assistants", get(site_assistants).post(site_assistant_put)) |
|
| 181 | 184 | .route("/admin/assistants/prompt", get(site_prompt).put(site_prompt_put)) |
|
| 182 | 185 | .route("/admin/assistants/{assistant}", axum::routing::delete(site_assistant_delete)) |
|
| … | 2598 unchanged lines |
||
| 2781 | 2784 | } |
|
| 2782 | 2785 | } |
|
| 2783 | 2786 | ||
| 2787 | + | /// `GET /api/v1/admin/runner-updates` (oxygenhub#322, #326). |
|
| 2788 | + | async fn runner_updates(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<RunnerUpdates>, ApiError> { |
|
| 2789 | + | let c = caller(&app, &headers).await?; |
|
| 2790 | + | require_site_admin(&c)?; |
|
| 2791 | + | use crate::web::runner_updates::{views, Reach}; |
|
| 2792 | + | let v = views(&app, &Reach::Site).await?; |
|
| 2793 | + | let setting = |p: &crate::web::runner_updates::PolicyView| UpdateSetting { |
|
| 2794 | + | scope: p.scope.clone(), |
|
| 2795 | + | key: p.key.clone(), |
|
| 2796 | + | updates: match p.updates { |
|
| 2797 | + | "on" => Some(true), |
|
| 2798 | + | "off" => Some(false), |
|
| 2799 | + | _ => None, |
|
| 2800 | + | }, |
|
| 2801 | + | channel: Some(p.channel.clone()).filter(|c| !c.is_empty()), |
|
| 2802 | + | window: Some(p.window.clone()).filter(|w| !w.is_empty()), |
|
| 2803 | + | dry_run: match p.dry_run { |
|
| 2804 | + | "on" => Some(true), |
|
| 2805 | + | "off" => Some(false), |
|
| 2806 | + | _ => None, |
|
| 2807 | + | }, |
|
| 2808 | + | }; |
|
| 2809 | + | let mut settings = vec![setting(&v.site)]; |
|
| 2810 | + | settings.extend(v.owners.iter().map(setting)); |
|
| 2811 | + | settings.extend(v.groups.iter().map(|g| setting(&g.policy))); |
|
| 2812 | + | settings.extend(v.runners.iter().map(|r| setting(&r.policy))); |
|
| 2813 | + | settings.retain(|s| s.scope == "site" || s.updates.is_some() || s.channel.is_some() || s.window.is_some() || s.dry_run.is_some()); |
|
| 2814 | + | Ok(Json(RunnerUpdates { |
|
| 2815 | + | repo: db::accounts::setting(&app.db, crate::runner_updates::SETTING_REPO).await?.unwrap_or_default(), |
|
| 2816 | + | settings, |
|
| 2817 | + | groups: v |
|
| 2818 | + | .groups |
|
| 2819 | + | .iter() |
|
| 2820 | + | .map(|g| RunnerGroup { |
|
| 2821 | + | id: g.g.id, |
|
| 2822 | + | owner: g.g.owner.clone(), |
|
| 2823 | + | name: g.g.name.clone(), |
|
| 2824 | + | percent: g.g.percent, |
|
| 2825 | + | members: g.members.clone(), |
|
| 2826 | + | }) |
|
| 2827 | + | .collect(), |
|
| 2828 | + | runners: v |
|
| 2829 | + | .runners |
|
| 2830 | + | .iter() |
|
| 2831 | + | .map(|r| RunnerUpdate { |
|
| 2832 | + | id: r.r.id, |
|
| 2833 | + | name: r.r.name.clone(), |
|
| 2834 | + | owner: r.r.scope_owner.clone(), |
|
| 2835 | + | version: r.r.version.clone(), |
|
| 2836 | + | groups: r.groups.clone(), |
|
| 2837 | + | updates: r.effective.updates, |
|
| 2838 | + | channel: r.effective.channel.clone(), |
|
| 2839 | + | window: r.effective.window.map(|(a, b)| format!("{a:02}-{b:02}")), |
|
| 2840 | + | dry_run: r.effective.dry_run, |
|
| 2841 | + | from: r.effective.from.clone(), |
|
| 2842 | + | now: r.offer.clone(), |
|
| 2843 | + | said: r.r.update_note.clone(), |
|
| 2844 | + | }) |
|
| 2845 | + | .collect(), |
|
| 2846 | + | holds: db::runner_updates::holds(&app.db) |
|
| 2847 | + | .await? |
|
| 2848 | + | .into_iter() |
|
| 2849 | + | .map(|h| HeldBuild { version: h.version, runner: h.runner, why: h.why, at_ms: h.at_ms }) |
|
| 2850 | + | .collect(), |
|
| 2851 | + | })) |
|
| 2852 | + | } |
|
| 2853 | + | ||
| 2854 | + | /// `PUT /api/v1/admin/runner-updates/settings`: one level's settings; what |
|
| 2855 | + | /// it set, in words, or 422 saying what is wrong. |
|
| 2856 | + | async fn runner_update_setting( |
|
| 2857 | + | State(app): State<Shared>, |
|
| 2858 | + | headers: HeaderMap, |
|
| 2859 | + | Json(u): Json<UpdateSetting>, |
|
| 2860 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 2861 | + | let c = caller(&app, &headers).await?; |
|
| 2862 | + | let me = require_site_admin(&c)?.clone(); |
|
| 2863 | + | use crate::web::runner_updates::{apply_policy, PolicyForm, Reach}; |
|
| 2864 | + | match apply_policy(&app, &me, &Reach::Site, &PolicyForm::of(&u)).await? { |
|
| 2865 | + | Ok(notice) => Ok(Json(serde_json::json!({ "notice": notice }))), |
|
| 2866 | + | Err(why) => Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, why)), |
|
| 2867 | + | } |
|
| 2868 | + | } |
|
| 2869 | + | ||
| 2870 | + | /// `POST /api/v1/admin/runner-updates/holds/{version}/lift` (#326). |
|
| 2871 | + | async fn runner_update_lift(State(app): State<Shared>, headers: HeaderMap, Path(version): Path<String>) -> Result<StatusCode, ApiError> { |
|
| 2872 | + | let c = caller(&app, &headers).await?; |
|
| 2873 | + | let me = require_site_admin(&c)?.clone(); |
|
| 2874 | + | if !db::runner_updates::lift(&app.db, &version).await? { |
|
| 2875 | + | return Err(not_found("that build is not held")); |
|
| 2876 | + | } |
|
| 2877 | + | db::accounts::audit(&app.db, Some(&me), "runner.update.lifted", &version, "offered again").await.ok(); |
|
| 2878 | + | Ok(StatusCode::NO_CONTENT) |
|
| 2879 | + | } |
|
| 2880 | + | ||
| 2784 | 2881 | async fn runner_of(app: &Shared, r: db::runners::Runner, running: i64) -> hub_api::Runner { |
|
| 2785 | 2882 | let serves = match r.scope_repo_id { |
|
| 2786 | 2883 | Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().map(|p| p.slug()).unwrap_or_default(), |
|
| … | 305 unchanged lines |
modified crates/hub/src/web/runner_updates.rs +20 −0
| … | 282 unchanged lines |
||
| 283 | 283 | dry_run: String, |
|
| 284 | 284 | } |
|
| 285 | 285 | ||
| 286 | + | impl PolicyForm { |
|
| 287 | + | /// The form an API setting stands for. |
|
| 288 | + | pub fn of(u: &hub_api::UpdateSetting) -> PolicyForm { |
|
| 289 | + | let tri = |b: Option<bool>| match b { |
|
| 290 | + | Some(true) => "on", |
|
| 291 | + | Some(false) => "off", |
|
| 292 | + | None => "inherit", |
|
| 293 | + | }; |
|
| 294 | + | PolicyForm { |
|
| 295 | + | csrf_token: String::new(), |
|
| 296 | + | scope: u.scope.clone(), |
|
| 297 | + | key: u.key.clone(), |
|
| 298 | + | updates: tri(u.updates).into(), |
|
| 299 | + | channel: u.channel.clone().unwrap_or_default(), |
|
| 300 | + | window: u.window.clone().unwrap_or_default(), |
|
| 301 | + | dry_run: tri(u.dry_run).into(), |
|
| 302 | + | } |
|
| 303 | + | } |
|
| 304 | + | } |
|
| 305 | + | ||
| 286 | 306 | /// Set one level's settings, within `reach`. |
|
| 287 | 307 | pub async fn apply_policy(app: &Shared, me: &accounts::Account, reach: &Reach, f: &PolicyForm) -> sqlx::Result<Outcome> { |
|
| 288 | 308 | let key = f.key.trim().to_string(); |
|
| … | 229 unchanged lines |
modified crates/hub/tests/flow.rs +37 −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 | + | // The same through the API (`oh runner-updates`), for administrators only. |
|
| 6705 | + | let (_, tokens) = web("GET", "/settings/tokens", None).await; |
|
| 6706 | + | let tcsrf = { |
|
| 6707 | + | let i = tokens.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 6708 | + | tokens[i..].split('"').next().unwrap().to_string() |
|
| 6709 | + | }; |
|
| 6710 | + | let (_, made) = web("POST", "/settings/tokens", Some(format!("_csrf={tcsrf}&name=admin&scopes=user%3Aread"))).await; |
|
| 6711 | + | let i = made.find("oxyhub_").unwrap(); |
|
| 6712 | + | let admin_token: String = made[i..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); |
|
| 6713 | + | let (st, body) = call(&h.app, "GET", "/api/v1/admin/runner-updates", Some(&admin_token), None).await; |
|
| 6714 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 6715 | + | let all: hub_api::RunnerUpdates = serde_json::from_str(&body).unwrap(); |
|
| 6716 | + | assert_eq!(all.repo, "andy/oxygenflow"); |
|
| 6717 | + | assert!(all.settings.iter().any(|s| s.scope == "group" && s.updates == Some(true) && s.window.is_some()), "{all:?}"); |
|
| 6718 | + | let plex_row = all.runners.iter().find(|r| r.name == "plex").unwrap(); |
|
| 6719 | + | assert!(plex_row.updates && plex_row.from[0] == "group canary" && plex_row.groups == ["canary"], "{plex_row:?}"); |
|
| 6720 | + | let put = |body: serde_json::Value| { |
|
| 6721 | + | let (app, t) = (h.app.clone(), admin_token.clone()); |
|
| 6722 | + | async move { call(&app, "PUT", "/api/v1/admin/runner-updates/settings", Some(&t), Some(body.to_string())).await } |
|
| 6723 | + | }; |
|
| 6724 | + | let (st, body) = |
|
| 6725 | + | put(serde_json::json!({ "scope": "runner", "key": narvi.id.to_string(), "updates": true, "channel": "0.1.0", "dry_run": true })) |
|
| 6726 | + | .await; |
|
| 6727 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 6728 | + | assert!(body.contains("Saved for runner narvi: updates on, channel 0.1.0, dry run on."), "{body}"); |
|
| 6729 | + | let (st, body) = put(serde_json::json!({ "scope": "site", "channel": "latest" })).await; |
|
| 6730 | + | assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); |
|
| 6731 | + | assert_eq!( |
|
| 6732 | + | call(&h.app, "GET", "/api/v1/admin/runner-updates", Some(&creds[0]), None).await.0, |
|
| 6733 | + | StatusCode::UNAUTHORIZED, |
|
| 6734 | + | "not a runner's" |
|
| 6735 | + | ); |
|
| 6736 | + | ||
| 6704 | 6737 | // #326: a build that rolls back on one runner is held for every other. |
|
| 6705 | 6738 | 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 | 6739 | assert_eq!(call(&h.app, "POST", "/api/v1/flow/runner/update", Some(&creds[0]), Some(report)).await.0, StatusCode::NO_CONTENT); |
|
| … | 1 unchanged lines |
||
| 6708 | 6741 | assert!(a.offer.is_none() && a.note.contains("flow 0.2.0 is held: it rolled back on plex (3 starts"), "{a:?}"); |
|
| 6709 | 6742 | let (_, page) = web("GET", "/admin/runners/updates", None).await; |
|
| 6710 | 6743 | assert!(page.contains("Held builds") && page.contains("<strong>flow 0.2.0</strong>"), "{page}"); |
|
| 6744 | + | let (_, body) = call(&h.app, "GET", "/api/v1/admin/runner-updates", Some(&admin_token), None).await; |
|
| 6745 | + | assert!(body.contains("\"version\":\"0.2.0\"") && body.contains("\"runner\":\"plex\""), "the hold through the API: {body}"); |
|
| 6711 | 6746 | let (_, page) = web("POST", "/admin/runners/updates/holds/lift", Some(format!("_csrf={csrf}&version=0.2.0"))).await; |
|
| 6712 | 6747 | assert!(page.contains("flow 0.2.0 is offered again"), "{page}"); |
|
| 6748 | + | let (st, _) = call(&h.app, "POST", "/api/v1/admin/runner-updates/holds/0.2.0/lift", Some(&admin_token), Some("{}".into())).await; |
|
| 6749 | + | assert_eq!(st, StatusCode::NOT_FOUND, "no longer held"); |
|
| 6713 | 6750 | assert!(ask(0).await.note.contains("waits for its window"), "lifted: back to what its settings say"); |
|
| 6714 | 6751 | let (_, page) = web("POST", "/admin/runners/updates/holds/lift", Some(format!("_csrf={csrf}&version=0.2.0"))).await; |
|
| 6715 | 6752 | assert!(page.contains("flow 0.2.0 is not held."), "{page}"); |
|
| … | 134 unchanged lines |
modified crates/oh/src/main.rs +172 −0
| … | 119 unchanged lines |
||
| 120 | 120 | #[command(subcommand)] |
|
| 121 | 121 | cmd: DropCmd, |
|
| 122 | 122 | }, |
|
| 123 | + | /// Runners that update themselves (site administrators; oxygenhub#322): |
|
| 124 | + | /// where builds come from, who takes them, what is held back |
|
| 125 | + | #[command(name = "runner-updates")] |
|
| 126 | + | RunnerUpdates { |
|
| 127 | + | #[command(subcommand)] |
|
| 128 | + | cmd: Option<RunnerUpdatesCmd>, |
|
| 129 | + | }, |
|
| 123 | 130 | /// Call the API directly: `oh api /repos/OWNER/NAME/issues` |
|
| 124 | 131 | Api { |
|
| 125 | 132 | /// A path under /api/v1 |
|
| … | 64 unchanged lines |
||
| 190 | 197 | Delete { |
|
| 191 | 198 | /// The drop's id, or its address |
|
| 192 | 199 | id: String, |
|
| 200 | + | }, |
|
| 201 | + | } |
|
| 202 | + | ||
| 203 | + | #[derive(Subcommand)] |
|
| 204 | + | enum RunnerUpdatesCmd { |
|
| 205 | + | /// Every level's settings, the test groups, each runner's policy and what it is offered (the default) |
|
| 206 | + | Show, |
|
| 207 | + | /// Change one level's settings; what is not given stays as it is |
|
| 208 | + | Set { |
|
| 209 | + | /// `site`, `owner:NAME`, `group:NAME` (or `group:OWNER/NAME`, `group:ID`), `runner:NAME` (or `runner:ID`) |
|
| 210 | + | scope: String, |
|
| 211 | + | /// `on`, `off` or `inherit` |
|
| 212 | + | #[arg(long)] |
|
| 213 | + | updates: Option<String>, |
|
| 214 | + | /// `stable`, `canary`, a version, or `inherit` |
|
| 215 | + | #[arg(long)] |
|
| 216 | + | channel: Option<String>, |
|
| 217 | + | /// Hours (UTC) updates may start in, `02-05`, or `inherit` |
|
| 218 | + | #[arg(long)] |
|
| 219 | + | window: Option<String>, |
|
| 220 | + | /// `on`, `off` or `inherit` |
|
| 221 | + | #[arg(long)] |
|
| 222 | + | dry_run: Option<String>, |
|
| 193 | 223 | }, |
|
| 224 | + | /// Offer a held build again (it rolled back on a runner) |
|
| 225 | + | Lift { version: String }, |
|
| 194 | 226 | } |
|
| 195 | 227 | ||
| 196 | 228 | #[derive(Subcommand)] |
|
| … | 644 unchanged lines |
||
| 841 | 873 | Cmd::Project { cmd, owner } => project(&c, &t, cmd, owner, json), |
|
| 842 | 874 | Cmd::Assistant { cmd, org, site } => assistant(&c, &t, cmd, org, site, json), |
|
| 843 | 875 | Cmd::Agent { cmd } => agent(&c, cmd, json), |
|
| 876 | + | Cmd::RunnerUpdates { cmd } => runner_updates(&c, cmd.unwrap_or(RunnerUpdatesCmd::Show), json), |
|
| 844 | 877 | Cmd::Drop { cmd } => drop_cmd(&c, cmd, json), |
|
| 878 | + | } |
|
| 879 | + | } |
|
| 880 | + | ||
| 881 | + | /// `on`/`off`/`inherit` as a setting: `Some(Some(b))` to set, `Some(None)` to inherit. |
|
| 882 | + | fn switch(v: Option<&str>) -> Result<Option<Option<bool>>> { |
|
| 883 | + | Ok(match v { |
|
| 884 | + | None => None, |
|
| 885 | + | Some("on") => Some(Some(true)), |
|
| 886 | + | Some("off") => Some(Some(false)), |
|
| 887 | + | Some("inherit") => Some(None), |
|
| 888 | + | Some(other) => bail!("{other:?}: say on, off or inherit"), |
|
| 889 | + | }) |
|
| 890 | + | } |
|
| 891 | + | ||
| 892 | + | /// A scope as `oh runner-updates set` takes it, resolved to (scope, key). |
|
| 893 | + | fn update_scope(scope: &str, all: &hub_api::RunnerUpdates) -> Result<(String, String)> { |
|
| 894 | + | let (kind, name) = scope.split_once(':').unwrap_or((scope, "")); |
|
| 895 | + | Ok(match kind { |
|
| 896 | + | "site" if name.is_empty() => ("site".into(), String::new()), |
|
| 897 | + | "owner" if !name.is_empty() => ("owner".into(), name.into()), |
|
| 898 | + | "group" => { |
|
| 899 | + | let g = all |
|
| 900 | + | .groups |
|
| 901 | + | .iter() |
|
| 902 | + | .find(|g| g.id.to_string() == name || g.name == name || format!("{}/{}", g.owner, g.name) == name) |
|
| 903 | + | .with_context(|| format!("no test group {name:?} (see `oh runner-updates`)"))?; |
|
| 904 | + | ("group".into(), g.id.to_string()) |
|
| 905 | + | } |
|
| 906 | + | "runner" => { |
|
| 907 | + | let r = all |
|
| 908 | + | .runners |
|
| 909 | + | .iter() |
|
| 910 | + | .find(|r| r.id.to_string() == name || r.name == name) |
|
| 911 | + | .with_context(|| format!("no runner {name:?} (see `oh runner-updates`)"))?; |
|
| 912 | + | ("runner".into(), r.id.to_string()) |
|
| 913 | + | } |
|
| 914 | + | _ => bail!("{scope:?}: say site, owner:NAME, group:NAME or runner:NAME"), |
|
| 915 | + | }) |
|
| 916 | + | } |
|
| 917 | + | ||
| 918 | + | fn runner_updates(c: &Client, cmd: RunnerUpdatesCmd, json: bool) -> Result<i32> { |
|
| 919 | + | let all: hub_api::RunnerUpdates = c.get("/admin/runner-updates")?; |
|
| 920 | + | match cmd { |
|
| 921 | + | RunnerUpdatesCmd::Show if json => println!("{}", serde_json::to_string_pretty(&all)?), |
|
| 922 | + | RunnerUpdatesCmd::Show => { |
|
| 923 | + | println!( |
|
| 924 | + | "{} {}", |
|
| 925 | + | ui::strong("Builds from"), |
|
| 926 | + | if all.repo.is_empty() { ui::dim("no repository: no runner is offered a build") } else { all.repo.clone() } |
|
| 927 | + | ); |
|
| 928 | + | for h in &all.holds { |
|
| 929 | + | println!("{} flow {} held: rolled back on {} ({})", ui::warn("!"), h.version, h.runner, h.why); |
|
| 930 | + | } |
|
| 931 | + | for s in &all.settings { |
|
| 932 | + | let mut said = vec![]; |
|
| 933 | + | if let Some(u) = s.updates { |
|
| 934 | + | said.push(format!("updates {}", if u { "on" } else { "off" })); |
|
| 935 | + | } |
|
| 936 | + | if let Some(ch) = &s.channel { |
|
| 937 | + | said.push(format!("channel {ch}")); |
|
| 938 | + | } |
|
| 939 | + | if let Some(w) = &s.window { |
|
| 940 | + | said.push(format!("window {w} UTC")); |
|
| 941 | + | } |
|
| 942 | + | if let Some(d) = s.dry_run { |
|
| 943 | + | said.push(format!("dry run {}", if d { "on" } else { "off" })); |
|
| 944 | + | } |
|
| 945 | + | let level = if s.scope == "site" { "site".to_string() } else { format!("{}:{}", s.scope, s.key) }; |
|
| 946 | + | println!(" {:<18} {}", level, if said.is_empty() { ui::dim("off unless chosen") } else { said.join(", ") }); |
|
| 947 | + | } |
|
| 948 | + | for g in &all.groups { |
|
| 949 | + | let of = if g.owner.is_empty() { "the site's runners".to_string() } else { format!("{}'s runners", g.owner) }; |
|
| 950 | + | let what = g.percent.map(|p| format!("{p}% of {of}")).unwrap_or_else(|| format!("chosen, of {of}")); |
|
| 951 | + | println!( |
|
| 952 | + | " group {} ({what}): {}", |
|
| 953 | + | ui::strong(&g.name), |
|
| 954 | + | if g.members.is_empty() { "none".into() } else { g.members.join(", ") } |
|
| 955 | + | ); |
|
| 956 | + | } |
|
| 957 | + | for r in &all.runners { |
|
| 958 | + | let on = if r.updates { ui::ok(&format!("on, {}", r.channel)) } else { ui::dim("off") }; |
|
| 959 | + | println!(" {:<16} flow {:<22} {} ({}) {}", r.name, r.version, on, r.from[0], ui::dim(&r.now)); |
|
| 960 | + | } |
|
| 961 | + | } |
|
| 962 | + | RunnerUpdatesCmd::Set { scope, updates, channel, window, dry_run } => { |
|
| 963 | + | let (scope, key) = update_scope(&scope, &all)?; |
|
| 964 | + | let mut s = all.settings.iter().find(|x| x.scope == scope && x.key == key).cloned().unwrap_or(hub_api::UpdateSetting { |
|
| 965 | + | scope: scope.clone(), |
|
| 966 | + | key: key.clone(), |
|
| 967 | + | ..Default::default() |
|
| 968 | + | }); |
|
| 969 | + | if let Some(u) = switch(updates.as_deref())? { |
|
| 970 | + | s.updates = u; |
|
| 971 | + | } |
|
| 972 | + | if let Some(d) = switch(dry_run.as_deref())? { |
|
| 973 | + | s.dry_run = d; |
|
| 974 | + | } |
|
| 975 | + | if let Some(ch) = channel { |
|
| 976 | + | s.channel = Some(ch).filter(|c| c != "inherit"); |
|
| 977 | + | } |
|
| 978 | + | if let Some(w) = window { |
|
| 979 | + | s.window = Some(w).filter(|w| w != "inherit"); |
|
| 980 | + | } |
|
| 981 | + | let out: serde_json::Value = serde_json::from_str(&c.send_text("PUT", "/admin/runner-updates/settings", &s)?)?; |
|
| 982 | + | println!("{} {}", ui::ok("✔"), out["notice"].as_str().unwrap_or("saved")); |
|
| 983 | + | } |
|
| 984 | + | RunnerUpdatesCmd::Lift { version } => { |
|
| 985 | + | c.send_text("POST", &format!("/admin/runner-updates/holds/{version}/lift"), &serde_json::json!({}))?; |
|
| 986 | + | println!("{} flow {version} is offered again where the settings say so", ui::ok("✔")); |
|
| 987 | + | } |
|
| 845 | 988 | } |
|
| 989 | + | Ok(0) |
|
| 846 | 990 | } |
|
| 847 | 991 | ||
| 848 | 992 | /// A drop's id from an id or its address (`https://hub/owner/drops/ID`). |
|
| … | 1255 unchanged lines |
||
| 2104 | 2248 | ||
| 2105 | 2249 | #[cfg(test)] |
|
| 2106 | 2250 | mod tests { |
|
| 2251 | + | ||
| 2252 | + | #[test] |
|
| 2253 | + | fn runner_update_scopes_resolve_by_name_or_id() { |
|
| 2254 | + | let all = hub_api::RunnerUpdates { |
|
| 2255 | + | repo: String::new(), |
|
| 2256 | + | settings: vec![], |
|
| 2257 | + | groups: vec![hub_api::RunnerGroup { id: 4, owner: "acme".into(), name: "canary".into(), percent: None, members: vec![] }], |
|
| 2258 | + | runners: vec![], |
|
| 2259 | + | holds: vec![], |
|
| 2260 | + | }; |
|
| 2261 | + | assert_eq!(update_scope("site", &all).unwrap(), ("site".to_string(), String::new())); |
|
| 2262 | + | assert_eq!(update_scope("owner:acme", &all).unwrap(), ("owner".to_string(), "acme".to_string())); |
|
| 2263 | + | for g in ["group:canary", "group:acme/canary", "group:4"] { |
|
| 2264 | + | assert_eq!(update_scope(g, &all).unwrap(), ("group".to_string(), "4".to_string()), "{g}"); |
|
| 2265 | + | } |
|
| 2266 | + | assert!(update_scope("runner:plex", &all).unwrap_err().to_string().contains("no runner")); |
|
| 2267 | + | assert!(update_scope("everyone", &all).is_err()); |
|
| 2268 | + | assert_eq!(switch(Some("inherit")).unwrap(), Some(None)); |
|
| 2269 | + | assert!(switch(Some("yes")).is_err()); |
|
| 2270 | + | } |
|
| 2107 | 2271 | use super::*; |
|
| 2108 | 2272 | ||
| 2109 | 2273 | /// `oh drop` (oxygenhub#279): its commands and flags parse as the |
|
| … | 10 unchanged lines |
||
| 2120 | 2284 | _ => panic!("not drop create"), |
|
| 2121 | 2285 | } |
|
| 2122 | 2286 | assert!(Cli::try_parse_from(["oh", "drop", "create"]).is_err(), "a drop needs a file"); |
|
| 2287 | + | match Cli::try_parse_from(["oh", "runner-updates", "set", "group:canary", "--updates", "on", "--window", "02-05"]).unwrap() { |
|
| 2288 | + | Cli { cmd: Cmd::RunnerUpdates { cmd: Some(RunnerUpdatesCmd::Set { scope, updates, window, channel, dry_run }) }, .. } => { |
|
| 2289 | + | assert_eq!((scope.as_str(), updates.as_deref(), window.as_deref()), ("group:canary", Some("on"), Some("02-05"))); |
|
| 2290 | + | assert!(channel.is_none() && dry_run.is_none()); |
|
| 2291 | + | } |
|
| 2292 | + | _ => panic!("not runner-updates set"), |
|
| 2293 | + | } |
|
| 2294 | + | assert!(matches!(Cli::try_parse_from(["oh", "runner-updates"]).unwrap().cmd, Cmd::RunnerUpdates { cmd: None })); |
|
| 2123 | 2295 | match Cli::try_parse_from(["oh", "drop", "list", "--user", "bob", "--json"]).unwrap() { |
|
| 2124 | 2296 | Cli { cmd: Cmd::Drop { cmd: DropCmd::List { user } }, json, .. } => assert_eq!((user.as_deref(), json), (Some("bob"), true)), |
|
| 2125 | 2297 | _ => panic!("not drop list"), |
|
| … | 13 unchanged lines |
modified docs/oh.md +3 −0
| … | 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 | |
|
| 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 | + | | `oh runner-updates lift VERSION` | Offer a build held after it rolled back on a runner (#326) | |
|
| 68 | 71 | | `oh api PATH [-X METHOD] [-d JSON]` | Any API call, JSON out | |
|
| 69 | 72 | ||
| 70 | 73 | Exit codes: 0 on success, 1 when a watched run or an audit fails, 2 on an |
|
| … | 5 unchanged lines |