OXYGENHUB

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