OXYGENHUB

Runner updates for an organisation's own runners dc5c64296b0a

An organisation's owners set, in its Settings, the organisation's
update setting, test groups of its runners (chosen or a percentage)
and each runner's own; the admin page and the organisation's share one
set of rules by reach, so nothing of the site's or another owner's.

refs #322
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7f243bbb638d
When
· 2026-10-26 04:17 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
dc5c64296b0a8f0bd3fea78d05081ac1a0bc8452d320e0f10315d8f4f76c6ab4
Depends on
155cab142b63 19bb31ff1f5c 42fa95540468 4ac01a732552 7974adb0be66 8501fd5815b9 8fb35df7d66e 9756c67d3aec b5668108487f b6dfcb61cf5e d23464c0ac60 d7050703d2f4
Depended on by
42c77f6515c2 1ec29691b67a 9bbca80f777a faf5246c900e
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
64 atoms, 1 tree operation, 8 files

Browse the tree at this change

added templates/runner_update_fields.html +7 −0
1 +
{# One level's update settings (oxygenhub#322); `top` is the site, which inherits from nothing. #}
2 +
{% macro policy_fields(p, top) %}
3 +
  <label class="pol">Updates <select name="updates">{% if !top %}<option value="inherit"{% if p.updates == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.updates == "off" || (top && p.updates == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.updates == "on" %} selected{% endif %}>on</option></select></label>
4 +
  <label class="pol">Channel <input type="text" name="channel" value="{{ p.channel }}" placeholder="{% if top %}stable{% else %}inherit{% endif %}" size="9" list="update-channels"></label>
5 +
  <label class="pol">Window, UTC <input type="text" name="window" value="{{ p.window }}" placeholder="{% if top %}any time{% else %}inherit{% endif %}" size="7" title="Hours in which updates may start, from-to, e.g. 02-05"></label>
6 +
  <label class="pol">Dry run <select name="dry_run">{% if !top %}<option value="inherit"{% if p.dry_run == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.dry_run == "off" || (top && p.dry_run == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.dry_run == "on" %} selected{% endif %}>on</option></select></label>
7 +
{% endmacro %}
modified CHANGELOG.md +1 −1
…
4 unchanged lines
5 5
## Unreleased
6 6
7 7
- **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).
8 -
- **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. Pins OxygenFlow's main.
8 +
- **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.
9 9
10 10
## 0.9.5 (2026-10-26)
11 11
…
334 unchanged lines
modified crates/hub/src/web/mod.rs +4 −0
…
143 unchanged lines
144 144
        .route("/orgs/{name}/settings/storage", post(orgs::storage))
145 145
        .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token))
146 146
        .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove))
147 +
        .route("/orgs/{name}/settings/runners/updates/policy", post(orgs::runner_updates_policy))
148 +
        .route("/orgs/{name}/settings/runners/updates/groups", post(orgs::runner_updates_group_new))
149 +
        .route("/orgs/{name}/settings/runners/updates/groups/{id}", post(orgs::runner_updates_group))
150 +
        .route("/orgs/{name}/settings/runners/updates/groups/{id}/delete", post(orgs::runner_updates_group_delete))
147 151
        .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries))
148 152
        .route("/orgs/{name}/settings/hooks/{id}/redeliver/{delivery}", post(orgs::redeliver))
149 153
        .route("/orgs/{name}/settings/avatar", post(orgs::avatar).layer(DefaultBodyLimit::max(avatar::MAX_BYTES + (64 << 10))))
…
808 unchanged lines
modified crates/hub/src/web/orgs.rs +80 −0
…
67 unchanged lines
68 68
    /// Runners serving the organisation: (runner, what it serves, whether
69 69
    /// it is the organisation's own).
70 70
    runners: Vec<(crate::db::runners::Runner, String, bool)>,
71 +
    /// Its runners' updates (oxygenhub#322): its setting, test groups and runners.
72 +
    updates: super::runner_updates::UpdateViews,
71 73
    /// A registration token just made, shown once.
72 74
    runner_token: Option<String>,
73 75
    /// The organisation's AI assistants and prompt (#97).
…
21 unchanged lines
95 97
    }
96 98
    let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
97 99
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
100 +
    let updates = super::runner_updates::views(app, &super::runner_updates::Reach::Org(org.name.clone()))
101 +
        .await
102 +
        .map_err(|e| Error::from_db(shell.clone(), e))?;
98 103
    let mut runners = Vec::new();
99 104
    for r in crate::db::runners::serving_owner(&app.db, &org.name).await.map_err(|e| Error::from_db(shell.clone(), e))? {
100 105
        let own = r.scope_owner == org.name;
…
18 unchanged lines
119 124
        notice,
120 125
        error,
121 126
        runners,
127 +
        updates,
122 128
        runner_token: None,
123 129
        assistants,
124 130
        variables,
…
367 unchanged lines
492 498
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
493 499
    csrf(&viewer, &f.csrf_token, &shell)?;
494 500
    Ok(axum::Json(super::assistants::models(&app, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))?))
501 +
}
502 +
503 +
/// `POST /orgs/{name}/settings/runners/updates/policy`: the organisation's,
504 +
/// one of its test groups' or one of its runners' update settings.
505 +
pub async fn runner_updates_policy(
506 +
    State(app): State<Shared>,
507 +
    viewer: Viewer,
508 +
    Path(name): Path<String>,
509 +
    Form(f): Form<super::runner_updates::PolicyForm>,
510 +
) -> Result<Page<OrgSettingsPage>, Error> {
511 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
512 +
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
513 +
    csrf(&viewer, &f.csrf_token, &shell)?;
514 +
    let reach = super::runner_updates::Reach::Org(org.name.clone());
515 +
    let outcome = super::runner_updates::apply_policy(&app, &me, &reach, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
516 +
    org_page_after(&app, shell, org, outcome).await
517 +
}
518 +
519 +
/// `POST /orgs/{name}/settings/runners/updates/groups`: a test group of the organisation's runners.
520 +
pub async fn runner_updates_group_new(
521 +
    State(app): State<Shared>,
522 +
    viewer: Viewer,
523 +
    Path(name): Path<String>,
524 +
    axum_extra::extract::Form(f): axum_extra::extract::Form<super::runner_updates::GroupForm>,
525 +
) -> Result<Page<OrgSettingsPage>, Error> {
526 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
527 +
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
528 +
    csrf(&viewer, &f.csrf_token, &shell)?;
529 +
    let reach = super::runner_updates::Reach::Org(org.name.clone());
530 +
    let outcome = super::runner_updates::apply_create_group(&app, &me, &reach, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
531 +
    org_page_after(&app, shell, org, outcome).await
532 +
}
533 +
534 +
/// `POST /orgs/{name}/settings/runners/updates/groups/{id}`: who is in one of its test groups.
535 +
pub async fn runner_updates_group(
536 +
    State(app): State<Shared>,
537 +
    viewer: Viewer,
538 +
    Path((name, id)): Path<(String, i64)>,
539 +
    axum_extra::extract::Form(f): axum_extra::extract::Form<super::runner_updates::GroupForm>,
540 +
) -> Result<Page<OrgSettingsPage>, Error> {
541 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
542 +
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
543 +
    csrf(&viewer, &f.csrf_token, &shell)?;
544 +
    let reach = super::runner_updates::Reach::Org(org.name.clone());
545 +
    let outcome = super::runner_updates::apply_save_group(&app, &me, &reach, id, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
546 +
    org_page_after(&app, shell, org, outcome).await
547 +
}
548 +
549 +
/// `POST /orgs/{name}/settings/runners/updates/groups/{id}/delete`.
550 +
pub async fn runner_updates_group_delete(
551 +
    State(app): State<Shared>,
552 +
    viewer: Viewer,
553 +
    Path((name, id)): Path<(String, i64)>,
554 +
    Form(f): Form<super::runner_updates::CsrfOnly>,
555 +
) -> Result<Page<OrgSettingsPage>, Error> {
556 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
557 +
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
558 +
    csrf(&viewer, &f.csrf_token, &shell)?;
559 +
    let reach = super::runner_updates::Reach::Org(org.name.clone());
560 +
    let outcome = super::runner_updates::apply_delete_group(&app, &me, &reach, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
561 +
    org_page_after(&app, shell, org, outcome).await
562 +
}
563 +
564 +
/// The settings page saying what a change did.
565 +
async fn org_page_after(
566 +
    app: &Shared,
567 +
    shell: Shell,
568 +
    org: accounts::Organisation,
569 +
    outcome: super::runner_updates::Outcome,
570 +
) -> Result<Page<OrgSettingsPage>, Error> {
571 +
    match outcome {
572 +
        Ok(n) => org_page(app, shell, org, Some(n), None).await,
573 +
        Err(why) => org_page(app, shell, org, None, Some(why)).await,
574 +
    }
495 575
}
modified crates/hub/src/web/runner_updates.rs +171 −94
…
127 unchanged lines
128 128
    error: Option<String>,
129 129
}
130 130
131 -
async fn page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<UpdatesPage>, Error> {
132 -
    let e = |e| Error::from_db(shell.clone(), e);
133 -
    let levels = Levels::load(&app.db).await.map_err(e)?;
134 -
    let all = runners::list(&app.db).await.map_err(e)?;
135 -
    let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default();
131 +
/// Who changes runner updates: the site's administrators anything; an
132 +
/// organisation's owners their organisation's setting, its test groups and
133 +
/// its runners (Andy: an organisation tries a build on some of its runners
134 +
/// before the rest).
135 +
#[derive(Clone, Debug, PartialEq, Eq)]
136 +
pub enum Reach {
137 +
    Site,
138 +
    Org(String),
139 +
}
140 +
141 +
impl Reach {
142 +
    fn holds_runner(&self, r: &runners::Runner) -> bool {
143 +
        match self {
144 +
            Reach::Site => true,
145 +
            Reach::Org(o) => r.scope_owner == *o,
146 +
        }
147 +
    }
148 +
149 +
    fn holds_group(&self, g: &Group) -> bool {
150 +
        match self {
151 +
            Reach::Site => true,
152 +
            Reach::Org(o) => g.owner == *o,
153 +
        }
154 +
    }
155 +
}
156 +
157 +
/// What a page shows of runner updates, within `reach`.
158 +
pub struct UpdateViews {
159 +
    pub site: PolicyView,
160 +
    pub owners: Vec<PolicyView>,
161 +
    pub groups: Vec<GroupView>,
162 +
    pub runners: Vec<RunnerView>,
163 +
}
164 +
165 +
pub async fn views(app: &Shared, reach: &Reach) -> sqlx::Result<UpdateViews> {
166 +
    let levels = Levels::load(&app.db).await?;
167 +
    let all = runners::list(&app.db).await?;
136 168
    let policy = |scope: &str, key: &str| {
137 169
        levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy {
138 170
            scope: scope.into(),
139 171
            key: key.into(),
140 172
            ..Policy::default()
141 173
        })
174 +
    };
175 +
    let mut owners: Vec<PolicyView> = match reach {
176 +
        Reach::Site => {
177 +
            levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect()
178 +
        }
179 +
        Reach::Org(o) => vec![PolicyView::of(&policy("owner", o), format!("owner {o}"))],
142 180
    };
143 -
    let mut owners: Vec<PolicyView> =
144 -
        levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect();
145 181
    owners.sort_by(|a, b| a.key.cmp(&b.key));
146 182
    let groups = levels
147 183
        .groups
148 184
        .iter()
185 +
        .filter(|g| reach.holds_group(g))
149 186
        .map(|g| GroupView {
150 187
            g: g.clone(),
151 188
            reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) },
…
3 unchanged lines
155 192
        })
156 193
        .collect();
157 194
    let hour = time::OffsetDateTime::now_utc().hour();
158 -
    let mut views = vec![];
159 -
    for r in all {
195 +
    let mut out = vec![];
196 +
    for r in all.into_iter().filter(|r| reach.holds_runner(r)) {
160 197
        let effective = levels.resolve(&r);
161 -
        let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await.map_err(e)?;
162 -
        views.push(RunnerView {
198 +
        let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await?;
199 +
        out.push(RunnerView {
163 200
            groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(),
164 201
            policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)),
165 202
            effective,
166 203
            offer,
167 204
            r,
168 205
        });
206 +
    }
207 +
    Ok(UpdateViews { site: PolicyView::of(&policy("site", ""), "the site".into()), owners, groups, runners: out })
208 +
}
209 +
210 +
async fn page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<UpdatesPage>, Error> {
211 +
    let e = |e| Error::from_db(shell.clone(), e);
212 +
    let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default();
213 +
    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 }))
215 +
}
216 +
217 +
/// What a change did, to say on the page: `Ok` a notice, `Err` what was wrong.
218 +
pub type Outcome = Result<String, String>;
219 +
220 +
/// [`page`] saying what `outcome` did.
221 +
async fn page_after(app: &Shared, shell: Shell, outcome: Outcome) -> Result<Page<UpdatesPage>, Error> {
222 +
    match outcome {
223 +
        Ok(n) => page(app, shell, Some(n), None).await,
224 +
        Err(why) => page(app, shell, None, Some(why)).await,
169 225
    }
170 -
    Ok(Page(UpdatesPage {
171 -
        site: PolicyView::of(&policy("site", ""), "the site".into()),
172 -
        shell,
173 -
        repo,
174 -
        owners,
175 -
        groups,
176 -
        runners: views,
177 -
        notice,
178 -
        error,
179 -
    }))
180 226
}
181 227
182 228
pub async fn show(State(app): State<Shared>, viewer: Viewer) -> Result<Page<UpdatesPage>, Error> {
…
37 unchanged lines
220 266
#[derive(Deserialize)]
221 267
pub struct PolicyForm {
222 268
    #[serde(rename = "_csrf")]
223 -
    csrf_token: String,
269 +
    pub(crate) csrf_token: String,
224 270
    scope: String,
225 271
    #[serde(default)]
226 272
    key: String,
…
7 unchanged lines
234 280
    dry_run: String,
235 281
}
236 282
237 -
/// `POST /admin/runners/updates/policy`: one level's settings.
238 -
pub async fn save_policy(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PolicyForm>) -> Result<Page<UpdatesPage>, Error> {
239 -
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
240 -
    let me = require_admin(&viewer, &shell)?;
241 -
    csrf(&viewer, &f.csrf_token, &shell)?;
242 -
    let e = |e| Error::from_db(shell.clone(), e);
283 +
/// Set one level's settings, within `reach`.
284 +
pub async fn apply_policy(app: &Shared, me: &accounts::Account, reach: &Reach, f: &PolicyForm) -> sqlx::Result<Outcome> {
243 285
    let key = f.key.trim().to_string();
244 -
    let label = match f.scope.as_str() {
245 -
        "site" if key.is_empty() => "the site".to_string(),
246 -
        "owner" => {
247 -
            let known = accounts::account_by_name(&app.db, &key).await.map_err(e)?.is_some()
248 -
                || accounts::org_by_name(&app.db, &key).await.map_err(e)?.is_some();
286 +
    let label = match (f.scope.as_str(), reach) {
287 +
        ("site", Reach::Site) if key.is_empty() => "the site".to_string(),
288 +
        ("owner", Reach::Org(o)) if key != *o => return Ok(Err("An organisation sets its own runners' updates only.".into())),
289 +
        ("owner", _) => {
290 +
            let known = accounts::account_by_name(&app.db, &key).await?.is_some() || accounts::org_by_name(&app.db, &key).await?.is_some();
249 291
            if !known {
250 -
                return page(&app, shell, None, Some(format!("There is no account or organisation {key:?}."))).await;
292 +
                return Ok(Err(format!("There is no account or organisation {key:?}.")));
251 293
            }
252 294
            format!("owner {key}")
253 295
        }
254 -
        "group" => match key.parse().ok().map(|id| rdb::group(&app.db, id)) {
255 -
            Some(g) => match g.await.map_err(e)? {
296 +
        ("group", _) => match key.parse().ok() {
297 +
            Some(id) => match rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) {
256 298
                Some(g) => format!("group {}", g.name),
257 -
                None => return page(&app, shell, None, Some("That test group is gone.".into())).await,
299 +
                None => return Ok(Err("That test group is gone.".into())),
258 300
            },
259 -
            None => return page(&app, shell, None, Some("That test group is gone.".into())).await,
301 +
            None => return Ok(Err("That test group is gone.".into())),
260 302
        },
261 -
        "runner" => match key.parse().ok().map(|id| runners::get(&app.db, id)) {
262 -
            Some(r) => match r.await.map_err(e)? {
303 +
        ("runner", _) => match key.parse().ok() {
304 +
            Some(id) => match runners::get(&app.db, id).await?.filter(|r| reach.holds_runner(r)) {
263 305
                Some(r) => format!("runner {}", r.name),
264 -
                None => return page(&app, shell, None, Some("That runner is gone.".into())).await,
306 +
                None => return Ok(Err("That runner is gone.".into())),
265 307
            },
266 -
            None => return page(&app, shell, None, Some("That runner is gone.".into())).await,
308 +
            None => return Ok(Err("That runner is gone.".into())),
267 309
        },
268 -
        _ => return page(&app, shell, None, Some("Choose the site, an owner, a test group or a runner.".into())).await,
310 +
        _ => return Ok(Err("Choose the site, an owner, a test group or a runner.".into())),
269 311
    };
270 312
    let tri = |v: &str| match v {
271 313
        "on" => Some(true),
…
2 unchanged lines
274 316
    };
275 317
    let channel = f.channel.trim().to_string();
276 318
    if !channel.is_empty() && !ru::valid_channel(&channel) {
277 -
        return page(&app, shell, None, Some(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}."))).await;
319 +
        return Ok(Err(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}.")));
278 320
    }
279 321
    let window = match ru::parse_window(&f.window) {
280 322
        Ok(w) => w,
281 -
        Err(why) => return page(&app, shell, None, Some(format!("{why}."))).await,
323 +
        Err(why) => return Ok(Err(format!("{why}."))),
282 324
    };
283 325
    let p = Policy {
284 326
        scope: f.scope.clone(),
…
4 unchanged lines
289 331
        window_to: window.map(|w| w.1 as i64),
290 332
        dry_run: tri(&f.dry_run),
291 333
    };
292 -
    rdb::set_policy(&app.db, &p).await.map_err(e)?;
334 +
    rdb::set_policy(&app.db, &p).await?;
293 335
    let words = PolicyView::of(&p, label.clone()).summary();
294 -
    accounts::audit(&app.db, Some(&me), "runner.updates.policy", &label, &words).await.ok();
295 -
    page(&app, shell, Some(format!("Saved for {label}: {words}.")), None).await
336 +
    accounts::audit(&app.db, Some(me), "runner.updates.policy", &label, &words).await.ok();
337 +
    Ok(Ok(format!("Saved for {label}: {words}.")))
338 +
}
339 +
340 +
/// `POST /admin/runners/updates/policy`: one level's settings.
341 +
pub async fn save_policy(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PolicyForm>) -> Result<Page<UpdatesPage>, Error> {
342 +
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
343 +
    let me = require_admin(&viewer, &shell)?;
344 +
    csrf(&viewer, &f.csrf_token, &shell)?;
345 +
    let outcome = apply_policy(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
346 +
    page_after(&app, shell, outcome).await
296 347
}
297 348
298 349
#[derive(Deserialize)]
299 350
pub struct GroupForm {
300 351
    #[serde(rename = "_csrf")]
301 -
    csrf_token: String,
352 +
    pub(crate) csrf_token: String,
302 353
    #[serde(default)]
303 354
    owner: String,
304 355
    #[serde(default)]
…
18 unchanged lines
323 374
    }
324 375
}
325 376
326 -
/// `POST /admin/runners/updates/groups`: a new test group.
327 -
pub async fn create_group(
328 -
    State(app): State<Shared>,
329 -
    viewer: Viewer,
330 -
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
331 -
) -> Result<Page<UpdatesPage>, Error> {
332 -
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
333 -
    let me = require_admin(&viewer, &shell)?;
334 -
    csrf(&viewer, &f.csrf_token, &shell)?;
335 -
    let e = |e| Error::from_db(shell.clone(), e);
377 +
/// The chosen runners a form names that `owner`'s group may hold.
378 +
async fn chosen_in_reach(app: &Shared, owner: &str, f: &GroupForm) -> sqlx::Result<Vec<i64>> {
379 +
    let all = runners::list(&app.db).await?;
380 +
    Ok(f.runner.iter().copied().filter(|id| all.iter().any(|r| r.id == *id && (owner.is_empty() || r.scope_owner == owner))).collect())
381 +
}
382 +
383 +
/// Make a test group, within `reach` (an organisation's are its own).
384 +
pub async fn apply_create_group(app: &Shared, me: &accounts::Account, reach: &Reach, f: &GroupForm) -> sqlx::Result<Outcome> {
336 385
    let name = f.name.trim().to_string();
337 386
    if name.is_empty() || name.len() > 60 {
338 -
        return page(&app, shell, None, Some("A test group needs a name of up to 60 characters.".into())).await;
387 +
        return Ok(Err("A test group needs a name of up to 60 characters.".into()));
339 388
    }
340 -
    let owner = f.owner.trim().to_string();
389 +
    let owner = match reach {
390 +
        Reach::Site => f.owner.trim().to_string(),
391 +
        Reach::Org(o) => o.clone(),
392 +
    };
341 393
    if !owner.is_empty()
342 -
        && accounts::account_by_name(&app.db, &owner).await.map_err(e)?.is_none()
343 -
        && accounts::org_by_name(&app.db, &owner).await.map_err(e)?.is_none()
394 +
        && accounts::account_by_name(&app.db, &owner).await?.is_none()
395 +
        && accounts::org_by_name(&app.db, &owner).await?.is_none()
344 396
    {
345 -
        return page(&app, shell, None, Some(format!("There is no account or organisation {owner:?}."))).await;
397 +
        return Ok(Err(format!("There is no account or organisation {owner:?}.")));
346 398
    }
347 -
    let percent = match percent_of(&f) {
399 +
    let percent = match percent_of(f) {
348 400
        Ok(p) => p,
349 -
        Err(why) => return page(&app, shell, None, Some(why)).await,
401 +
        Err(why) => return Ok(Err(why)),
350 402
    };
351 -
    if rdb::groups(&app.db).await.map_err(e)?.iter().any(|g| g.owner == owner && g.name == name) {
352 -
        return page(&app, shell, None, Some(format!("There is already a test group {name:?} for those runners."))).await;
403 +
    if rdb::groups(&app.db).await?.iter().any(|g| g.owner == owner && g.name == name) {
404 +
        return Ok(Err(format!("There is already a test group {name:?} for those runners.")));
353 405
    }
354 -
    let g = rdb::create_group(&app.db, &owner, &name, percent).await.map_err(e)?;
406 +
    let chosen = chosen_in_reach(app, &owner, f).await?;
407 +
    let g = rdb::create_group(&app.db, &owner, &name, percent).await?;
355 408
    if percent.is_none() {
356 -
        rdb::set_members(&app.db, g.id, &f.runner).await.map_err(e)?;
409 +
        rdb::set_members(&app.db, g.id, &chosen).await?;
357 410
    }
358 411
    let what = match percent {
359 412
        Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }),
360 -
        None => format!("{} chosen runners", f.runner.len()),
413 +
        None => format!("{} chosen runners", chosen.len()),
361 414
    };
362 -
    accounts::audit(&app.db, Some(&me), "runner.updates.group", &name, &format!("created: {what}")).await.ok();
363 -
    page(&app, shell, Some(format!("Test group {name} made: {what}. Give it its settings below.")), None).await
415 +
    accounts::audit(&app.db, Some(me), "runner.updates.group", &name, &format!("created: {what}")).await.ok();
416 +
    Ok(Ok(format!("Test group {name} made: {what}. Give it its settings below.")))
364 417
}
365 418
366 -
/// `POST /admin/runners/updates/groups/{id}`: who is in a test group.
367 -
pub async fn save_group(
419 +
/// `POST /admin/runners/updates/groups`: a new test group.
420 +
pub async fn create_group(
368 421
    State(app): State<Shared>,
369 422
    viewer: Viewer,
370 -
    Path(id): Path<i64>,
371 423
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
372 424
) -> Result<Page<UpdatesPage>, Error> {
373 425
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
374 426
    let me = require_admin(&viewer, &shell)?;
375 427
    csrf(&viewer, &f.csrf_token, &shell)?;
376 -
    let e = |e| Error::from_db(shell.clone(), e);
377 -
    let Some(g) = rdb::group(&app.db, id).await.map_err(e)? else {
378 -
        return page(&app, shell, None, Some("That test group is gone.".into())).await;
428 +
    let outcome = apply_create_group(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
429 +
    page_after(&app, shell, outcome).await
430 +
}
431 +
432 +
/// Set who is in test group `id`, within `reach`.
433 +
pub async fn apply_save_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64, f: &GroupForm) -> sqlx::Result<Outcome> {
434 +
    let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else {
435 +
        return Ok(Err("That test group is gone.".into()));
379 436
    };
380 -
    let percent = match percent_of(&f) {
437 +
    let percent = match percent_of(f) {
381 438
        Ok(p) => p,
382 -
        Err(why) => return page(&app, shell, None, Some(why)).await,
439 +
        Err(why) => return Ok(Err(why)),
383 440
    };
384 -
    rdb::set_percent(&app.db, id, percent).await.map_err(e)?;
385 -
    rdb::set_members(&app.db, id, if percent.is_some() { &[] } else { &f.runner }).await.map_err(e)?;
441 +
    let chosen = if percent.is_some() { vec![] } else { chosen_in_reach(app, &g.owner, f).await? };
442 +
    rdb::set_percent(&app.db, id, percent).await?;
443 +
    rdb::set_members(&app.db, id, &chosen).await?;
386 444
    let what = match percent {
387 445
        Some(p) => format!("{p}% of its runners"),
388 -
        None => format!("{} chosen runners", f.runner.len()),
446 +
        None => format!("{} chosen runners", chosen.len()),
389 447
    };
390 -
    accounts::audit(&app.db, Some(&me), "runner.updates.group", &g.name, &what).await.ok();
391 -
    page(&app, shell, Some(format!("Test group {} is now {what}.", g.name)), None).await
448 +
    accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, &what).await.ok();
449 +
    Ok(Ok(format!("Test group {} is now {what}.", g.name)))
392 450
}
393 451
452 +
/// `POST /admin/runners/updates/groups/{id}`: who is in a test group.
453 +
pub async fn save_group(
454 +
    State(app): State<Shared>,
455 +
    viewer: Viewer,
456 +
    Path(id): Path<i64>,
457 +
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
458 +
) -> Result<Page<UpdatesPage>, Error> {
459 +
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
460 +
    let me = require_admin(&viewer, &shell)?;
461 +
    csrf(&viewer, &f.csrf_token, &shell)?;
462 +
    let outcome = apply_save_group(&app, &me, &Reach::Site, id, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
463 +
    page_after(&app, shell, outcome).await
464 +
}
465 +
394 466
#[derive(Deserialize)]
395 467
pub struct CsrfOnly {
396 468
    #[serde(rename = "_csrf")]
397 -
    csrf_token: String,
469 +
    pub(crate) csrf_token: String,
398 470
}
399 471
472 +
/// Remove test group `id`, within `reach`.
473 +
pub async fn apply_delete_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64) -> sqlx::Result<Outcome> {
474 +
    let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else {
475 +
        return Ok(Err("That test group is gone.".into()));
476 +
    };
477 +
    rdb::delete_group(&app.db, id).await?;
478 +
    accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, "removed").await.ok();
479 +
    Ok(Ok(format!("Test group {} removed; its runners follow the levels above it.", g.name)))
480 +
}
481 +
400 482
/// `POST /admin/runners/updates/groups/{id}/delete`.
401 483
pub async fn delete_group(
402 484
    State(app): State<Shared>,
…
4 unchanged lines
407 489
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
408 490
    let me = require_admin(&viewer, &shell)?;
409 491
    csrf(&viewer, &f.csrf_token, &shell)?;
410 -
    let e = |e| Error::from_db(shell.clone(), e);
411 -
    let Some(g) = rdb::group(&app.db, id).await.map_err(e)? else {
412 -
        return page(&app, shell, None, Some("That test group is gone.".into())).await;
413 -
    };
414 -
    rdb::delete_group(&app.db, id).await.map_err(e)?;
415 -
    accounts::audit(&app.db, Some(&me), "runner.updates.group", &g.name, "removed").await.ok();
416 -
    page(&app, shell, Some(format!("Test group {} removed; its runners follow the levels above it.", g.name)), None).await
492 +
    let outcome = apply_delete_group(&app, &me, &Reach::Site, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
493 +
    page_after(&app, shell, outcome).await
417 494
}
modified crates/hub/tests/flow.rs +100 −0
…
6733 unchanged lines
6734 6734
    let placed = db::flow::job(&h.app.db, id).await.unwrap().unwrap().placed;
6735 6735
    assert!(placed.starts_with("idle") && !placed.contains("image"), "{placed}");
6736 6736
}
6737 +
6738 +
/// oxygenhub#322: an organisation's owners set their own runners' updates,
6739 +
/// test groups of their own runners, and nothing beyond them.
6740 +
#[tokio::test(flavor = "multi_thread")]
6741 +
async fn an_organisation_sets_its_own_runners_updates() {
6742 +
    use oxy_core::identity::SecretKey;
6743 +
    let h = hub().await;
6744 +
    let res = oxygenhub::router(h.app.clone())
6745 +
        .oneshot(
6746 +
            Request::post("/register")
6747 +
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
6748 +
                .body(Body::from("username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup"))
6749 +
                .unwrap(),
6750 +
        )
6751 +
        .await
6752 +
        .unwrap();
6753 +
    let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string();
6754 +
    let web = |method: &'static str, path: String, body: Option<String>| {
6755 +
        let (app, cookie) = (h.app.clone(), cookie.clone());
6756 +
        async move {
6757 +
            let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie);
6758 +
            if body.is_some() {
6759 +
                req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
6760 +
            }
6761 +
            let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap();
6762 +
            let st = res.status();
6763 +
            (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
6764 +
        }
6765 +
    };
6766 +
    let csrf_of = |page: &str| {
6767 +
        let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len();
6768 +
        page[i..].split('"').next().unwrap().to_string()
6769 +
    };
6770 +
    let (_, page) = web("GET", "/orgs/new".into(), None).await;
6771 +
    let csrf = csrf_of(&page);
6772 +
    web("POST", "/orgs/new".into(), Some(format!("_csrf={csrf}&name=acme&display_name=Acme"))).await;
6773 +
    // Two of acme's runners, and the site's (hub.toml's, from its first claim).
6774 +
    let mut creds = vec![];
6775 +
    for name in ["a1", "a2"] {
6776 +
        let (_, page) = web("POST", "/orgs/acme/settings/runners/token".into(), Some(format!("_csrf={csrf}"))).await;
6777 +
        let i = page.find("--token ").unwrap() + "--token ".len();
6778 +
        let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect();
6779 +
        let identity = SecretKey::generate().identity(name, "", 1);
6780 +
        let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0", "max_jobs": 1 });
6781 +
        let reg = serde_json::json!({ "token": token, "name": name, "identity": hex::encode(identity.encode()), "info": info });
6782 +
        let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await;
6783 +
        assert_eq!(st, StatusCode::OK, "{body}");
6784 +
        creds.push(serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string());
6785 +
    }
6786 +
    claim(&h.app).await;
6787 +
    let all = db::runners::list(&h.app.db).await.unwrap();
6788 +
    let id = |n: &str| all.iter().find(|r| r.name == n).unwrap().id;
6789 +
    let set = |form: String| web("POST", "/orgs/acme/settings/runners/updates/policy".into(), Some(format!("_csrf={csrf}&{form}")));
6790 +
6791 +
    let (_, page) = set("scope=owner&key=acme&updates=on".into()).await;
6792 +
    assert!(page.contains("Saved for owner acme: updates on."), "{page}");
6793 +
    let (_, page) = set("scope=site&updates=on".into()).await;
6794 +
    assert!(page.contains("Choose the site, an owner, a test group or a runner."), "not the site's: {page}");
6795 +
    let (_, page) = set("scope=owner&key=andy&updates=on".into()).await;
6796 +
    assert!(page.contains("An organisation sets its own runners"), "{page}");
6797 +
    let (_, page) = set(format!("scope=runner&key={}&updates=on", id("test-1"))).await;
6798 +
    assert!(page.contains("That runner is gone."), "the site's runner is not acme's: {page}");
6799 +
6800 +
    // A test group of acme's runners; a runner not acme's is left out.
6801 +
    let (_, page) = web(
6802 +
        "POST",
6803 +
        "/orgs/acme/settings/runners/updates/groups".into(),
6804 +
        Some(format!("_csrf={csrf}&name=canary&owner=&mode=chosen&runner={}&runner={}", id("a1"), id("test-1"))),
6805 +
    )
6806 +
    .await;
6807 +
    assert!(page.contains("Test group canary made: 1 chosen runners"), "{page}");
6808 +
    let canary = db::runner_updates::groups(&h.app.db).await.unwrap().remove(0);
6809 +
    assert_eq!(canary.owner, "acme", "an organisation's groups are its own");
6810 +
    let (_, page) = set(format!("scope=group&key={}&channel=canary", canary.id)).await;
6811 +
    assert!(page.contains("Saved for group canary: channel canary."), "{page}");
6812 +
    let (_, page) = set(format!("scope=runner&key={}&updates=off", id("a2"))).await;
6813 +
    assert!(page.contains("Saved for runner a2: updates off."), "{page}");
6814 +
6815 +
    // A site group is not the organisation's to change.
6816 +
    let site = db::runner_updates::create_group(&h.app.db, "", "everyone", Some(100)).await.unwrap();
6817 +
    let (_, page) =
6818 +
        web("POST", format!("/orgs/acme/settings/runners/updates/groups/{}/delete", site.id), Some(format!("_csrf={csrf}"))).await;
6819 +
    assert!(page.contains("That test group is gone."), "{page}");
6820 +
    assert!(db::runner_updates::group(&h.app.db, site.id).await.unwrap().is_some());
6821 +
6822 +
    // The page shows it; each runner gets its policy.
6823 +
    let (_, page) = web("GET", "/orgs/acme/settings".into(), None).await;
6824 +
    assert!(page.contains("id=\"runner-updates\"") && page.contains(">canary <small"), "{page}");
6825 +
    assert!(page.contains("updates on (owner acme)") && page.contains("updates off (runner)"), "{page}");
6826 +
    let ask = |cred: String| {
6827 +
        let app = h.app.clone();
6828 +
        async move {
6829 +
            let (_, body) = call(&app, "GET", "/api/v1/flow/runner/update", Some(&cred), None).await;
6830 +
            serde_json::from_str::<flow_core::api::UpdateAnswer>(&body).unwrap().policy
6831 +
        }
6832 +
    };
6833 +
    let a1 = ask(creds[0].clone()).await;
6834 +
    assert!(a1.updates && a1.channel == "canary" && a1.from[..2] == ["owner acme".to_string(), "group canary".to_string()], "{a1:?}");
6835 +
    assert!(!ask(creds[1].clone()).await.updates, "a2's own setting wins");
6836 +
}
modified templates/admin_runner_updates.html +7 −12
1 1
{% extends "base.html" %}
2 2
{% import "admin_nav.html" as an %}
3 -
{# One level's update settings (oxygenhub#322); `top` is the site, which inherits from nothing. #}
4 -
{% macro policy_fields(p, top) %}
5 -
  <label class="pol">Updates <select name="updates">{% if !top %}<option value="inherit"{% if p.updates == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.updates == "off" || (top && p.updates == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.updates == "on" %} selected{% endif %}>on</option></select></label>
6 -
  <label class="pol">Channel <input type="text" name="channel" value="{{ p.channel }}" placeholder="{% if top %}stable{% else %}inherit{% endif %}" size="9" list="update-channels"></label>
7 -
  <label class="pol">Window, UTC <input type="text" name="window" value="{{ p.window }}" placeholder="{% if top %}any time{% else %}inherit{% endif %}" size="7" title="Hours in which updates may start, from-to, e.g. 02-05"></label>
8 -
  <label class="pol">Dry run <select name="dry_run">{% if !top %}<option value="inherit"{% if p.dry_run == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.dry_run == "off" || (top && p.dry_run == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.dry_run == "on" %} selected{% endif %}>on</option></select></label>
9 -
{% endmacro %}
3 +
{% import "runner_update_fields.html" as ruf %}
4 +
10 5
{% block title %}Runner updates · Administration{% endblock %}
11 6
{% block content %}
12 7
<div class="head"><h1>Runners <small><a href="/admin">Administration</a></small></h1></div>
…
21 unchanged lines
34 29
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="site">
35 30
  <h2>The site</h2>
36 31
  <p class="small mute">What every runner does unless a nearer level says otherwise: {{ site.summary() }}.</p>
37 -
  <div class="pol-row">{% call policy_fields(site, true) %}</div>
32 +
  <div class="pol-row">{% call ruf::policy_fields(site, true) %}</div>
38 33
  <button type="submit">Save</button>
39 34
</form>
40 35
<section class="panel" id="owners">
…
3 unchanged lines
44 39
  {% for o in owners %}
45 40
  <form class="pol-form" method="post" action="/admin/runners/updates/policy">
46 41
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="owner"><input type="hidden" name="key" value="{{ o.key }}">
47 -
    <strong>{{ o.key }}</strong>{% call policy_fields(o, false) %}<button type="submit">Save</button>
42 +
    <strong>{{ o.key }}</strong>{% call ruf::policy_fields(o, false) %}<button type="submit">Save</button>
48 43
  </form>
49 44
  {% endfor %}
50 45
  <form class="pol-form" method="post" action="/admin/runners/updates/policy">
51 46
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="owner">
52 -
    <label class="pol">Owner <input type="text" name="key" size="12" placeholder="account or org" required></label>{% call policy_fields(site, false) %}<button type="submit">Add</button>
47 +
    <label class="pol">Owner <input type="text" name="key" size="12" placeholder="account or org" required></label>{% call ruf::policy_fields(site, false) %}<button type="submit">Add</button>
53 48
  </form>
54 49
</section>
55 50
<section class="panel" id="groups">
…
6 unchanged lines
62 57
    <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>
63 58
    <form class="pol-form" method="post" action="/admin/runners/updates/policy">
64 59
      <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}">
65 -
      {% call policy_fields(gv.policy, false) %}<button type="submit">Save settings</button>
60 +
      {% call ruf::policy_fields(gv.policy, false) %}<button type="submit">Save settings</button>
66 61
    </form>
67 62
    <details><summary class="small">Who is in it</summary>
68 63
    <form method="post" action="/admin/runners/updates/groups/{{ g.id }}">
…
35 unchanged lines
104 99
    <td class="small">{% if r.update_note.is_empty() %}<span class="mute">nothing yet</span>{% else %}{{ r.update_note }}{% if let Some(t) = r.update_ms %} <time class="mute" title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}{% endif %}</td>
105 100
    <td><form class="pol-form tiny" method="post" action="/admin/runners/updates/policy">
106 101
      <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="runner"><input type="hidden" name="key" value="{{ r.id }}">
107 -
      {% call policy_fields(rv.policy, false) %}<button type="submit">set</button></form></td>
102 +
      {% call ruf::policy_fields(rv.policy, false) %}<button type="submit">set</button></form></td>
108 103
  </tr>
109 104
  {% endfor %}
110 105
  </table></div>
…
5 unchanged lines
modified templates/org_settings.html +54 −0
…
2 unchanged lines
3 3
{% import "assistants_panel.html" as ai %}
4 4
{% import "variables_panel.html" as va %}
5 5
{% import "secrets_panel.html" as se %}
6 +
{% import "runner_update_fields.html" as ruf %}
6 7
{% block title %}{{ org.name }} settings{% endblock %}
7 8
{% block content %}
8 9
<div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div>
…
82 unchanged lines
91 92
  </table></div>{% endif %}
92 93
  <form method="post" action="/orgs/{{ org.name }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for the organisation</button></form>
93 94
</section>
95 +
<section class="panel" id="runner-updates">
96 +
  <h2>Runner updates</h2>
97 +
  <p class="small">The organisation's own runners can update themselves: off unless chosen. Try a build on a test group first (runners you pick, or a percentage of them, the same machines every time), then turn updates on for the rest. A runner's own setting wins over a group's, a group's over the organisation's, the organisation's over the site's.</p>
98 +
  {% let ou = updates.owners[0] %}
99 +
  <form class="pol-form" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/policy">
100 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="owner"><input type="hidden" name="key" value="{{ org.name }}">
101 +
    <strong>The organisation</strong>{% call ruf::policy_fields(ou, false) %}<button type="submit">Save</button>
102 +
  </form>
103 +
  <h3>Test groups</h3>
104 +
  {% if updates.groups.is_empty() %}<p class="mute small">None yet.</p>{% endif %}
105 +
  {% for gv in updates.groups %}{% let g = gv.g %}
106 +
  <div class="card wide update-group">
107 +
    <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>
108 +
    <p class="small">{% for m in gv.members %}<span class="lbl">{{ m }}</span> {% endfor %}</p>
109 +
    <form class="pol-form" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/policy">
110 +
      <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}">
111 +
      {% call ruf::policy_fields(gv.policy, false) %}<button type="submit">Save settings</button>
112 +
    </form>
113 +
    <details><summary class="small">Who is in it</summary>
114 +
    <form method="post" action="/orgs/{{ org.name }}/settings/runners/updates/groups/{{ g.id }}">
115 +
      <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
116 +
      <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>
117 +
      <label class="pol"><input type="radio" name="mode" value="chosen"{% if g.percent.is_none() %} checked{% endif %}> these runners:</label>
118 +
      <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>
119 +
      <button type="submit">Save members</button>
120 +
    </form>
121 +
    <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>
122 +
    </details>
123 +
  </div>
124 +
  {% endfor %}
125 +
  <form class="card wide" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/groups">
126 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
127 +
    <h3>New test group</h3>
128 +
    <label class="pol">Name <input type="text" name="name" size="14" required placeholder="canary"></label>
129 +
    <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>
130 +
    <label class="pol"><input type="radio" name="mode" value="chosen" checked> these runners:</label>
131 +
    <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>
132 +
    <button type="submit">Make group</button>
133 +
  </form>
134 +
  {% if !updates.runners.is_empty() %}
135 +
  <h3>The organisation's runners</h3>
136 +
  <div class="table"><table>
137 +
  <tr><th>Runner</th><th>Runs</th><th>Its policy</th><th>Now</th><th>Last said</th><th>Its own settings</th></tr>
138 +
  {% for rv in updates.runners %}{% let r = rv.r %}
139 +
  <tr><td><strong>{{ r.name }}</strong>{% for g in rv.groups %} <span class="lbl">{{ g }}</span>{% endfor %}</td>
140 +
    <td class="small">{% if r.version.is_empty() %}<span class="mute">unknown</span>{% else %}flow {{ r.version }}{% endif %}</td>
141 +
    <td class="small">{{ rv.effective_words() }}</td><td class="small">{{ rv.offer }}</td>
142 +
    <td class="small">{% if r.update_note.is_empty() %}<span class="mute">nothing yet</span>{% else %}{{ r.update_note }}{% endif %}</td>
143 +
    <td><form class="pol-form tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/policy"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="runner"><input type="hidden" name="key" value="{{ r.id }}">{% call ruf::policy_fields(rv.policy, false) %}<button type="submit">set</button></form></td></tr>
144 +
  {% endfor %}
145 +
  </table></div>
146 +
  {% endif %}
147 +
</section>
94 148
{% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %}
95 149
{% call va::panel("/orgs/" ~ org.name ~ "/settings", variables, shell.csrf, "Jobs of every repository of the organisation get these, beside the site's and each repository's own.") %}
96 150
{% call se::panel("/orgs/" ~ org.name ~ "/settings", secrets, shell.csrf, "Jobs of every repository of the organisation may use these, beside the site's and each repository's own.") %}
…
4 unchanged lines