OXYGENHUB

Coverage / crates/hub/src/web/runner_updates.rs 87.9%

254 of 289 lines run, 33 of 86 functions · run #469 on main · run missed

1
//! Admin → Runners → Updates (oxygenhub#322): where runner builds come
2
//! from, and who takes them: the site's, each owner's, each test group's
3
//! and each runner's settings, the nearest winning; test groups of chosen
4
//! runners or a percentage of them.
5
6
use super::settings::csrf;
7
use super::{filters, Error, Page, Shell};
8
use crate::auth::Viewer;
9
use crate::db::runner_updates::{self as rdb, Group, Policy};
10
use crate::db::{accounts, runners};
11
use crate::runner_updates::{self as ru, Levels};
12
use crate::Shared;
13
use askama::Template;
14
use axum::extract::{Form, Path, State};
15
use flow_core::api::UpdatePolicy;
16
use serde::Deserialize;
17
1815×
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
1915×
    match viewer.account() {
2014×
        Some(a) if a.is_admin => Ok(a.clone()),
211×
        _ => Err(Error::not_found(shell.clone())),
22
    }
2315×
}
24
25
/// One level's settings as its form shows them: `inherit`, `on` or `off`
26
/// for the switches, blank for an inherited channel or window.
27
pub struct PolicyView {
28
    pub scope: String,
29
    pub key: String,
30
    pub label: String,
31
    pub updates: &'static str,
32
    pub channel: String,
33
    pub window: String,
34
    pub dry_run: &'static str,
35
}
36
37
impl PolicyView {
3854×
    fn of(p: &Policy, label: String) -> PolicyView {
39108×
        let tri = |b: Option<bool>| match b {
4030×
            Some(true) => "on",
4110×
            Some(false) => "off",
4268×
            None => "inherit",
43108×
        };
44
        PolicyView {
4554×
            scope: p.scope.clone(),
4654×
            key: p.key.clone(),
4754×
            label,
4854×
            updates: tri(p.updates),
4954×
            channel: p.channel.clone().unwrap_or_default(),
5054×
            window: match (p.window_from, p.window_to) {
517×
                (Some(a), Some(b)) => format!("{a:02}-{b:02}"),
5247×
                _ => String::new(),
53
            },
5454×
            dry_run: tri(p.dry_run),
55
        }
5654×
    }
57
58
    /// The settings in words, for a table.
5918×
    pub fn summary(&self) -> String {
6018×
        let mut s = vec![];
6118×
        if self.updates != "inherit" {
6213×
            s.push(format!("updates {}", self.updates));
6313×
        }
6418×
        if !self.channel.is_empty() {
651×
            s.push(format!("channel {}", self.channel));
6617×
        }
6718×
        if !self.window.is_empty() {
681×
            s.push(format!("window {} UTC", self.window));
6917×
        }
7018×
        if self.dry_run != "inherit" {
7110×
            s.push(format!("dry run {}", self.dry_run));
7210×
        }
7318×
        if s.is_empty() {
745×
            "inherits everything".into()
75
        } else {
7613×
            s.join(", ")
77
        }
7818×
    }
79
}
80
81
pub struct GroupView {
82
    pub g: Group,
83
    /// Whom it can hold, in words.
84
    pub reach: String,
85
    /// Its members now, by name.
86
    pub members: Vec<String>,
87
    pub chosen: Vec<i64>,
88
    pub policy: PolicyView,
89
}
90
91
pub struct RunnerView {
92
    pub r: runners::Runner,
93
    pub groups: Vec<String>,
94
    pub effective: UpdatePolicy,
95
    /// What the hub would offer it now.
96
    pub offer: String,
97
    pub policy: PolicyView,
98
}
99
100
impl RunnerView {
101
    /// Its effective settings, each with where it came from.
10226×
    pub fn effective_words(&self) -> String {
10326×
        let e = &self.effective;
10426×
        let mut s = vec![format!("updates {} ({})", if e.updates { "on" } else { "off" }, e.from[0])];
10526×
        if e.updates {
10617×
            s.push(format!("channel {} ({})", e.channel, e.from[1]));
10717×
            if let Some((a, b)) = e.window {
1087×
                s.push(format!("window {a:02}–{b:02} UTC ({})", e.from[2]));
10910×
            }
11017×
            if e.dry_run {
11116×
                s.push(format!("dry run ({})", e.from[3]));
11216×
            }
1139×
        }
11426×
        s.join(" · ")
11526×
    }
116
}
117
118
#[derive(Template)]
119
#[template(path = "admin_runner_updates.html")]
120
pub struct UpdatesPage {
121
    shell: Shell,
122
    repo: String,
123
    site: PolicyView,
124
    owners: Vec<PolicyView>,
125
    groups: Vec<GroupView>,
126
    runners: Vec<RunnerView>,
127
    notice: Option<String>,
128
    error: Option<String>,
129
}
130
13114×
async fn page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<UpdatesPage>, Error> {
13214×
    let e = |e| Error::from_db(shell.clone(), e);
13314×
    let levels = Levels::load(&app.db).await.map_err(e)?;
13414×
    let all = runners::list(&app.db).await.map_err(e)?;
13514×
    let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default();
13650×
    let policy = |scope: &str, key: &str| {
13750×
        levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy {
13850×
            scope: scope.into(),
13950×
            key: key.into(),
14050×
            ..Policy::default()
14150×
        })
14250×
    };
14314×
    let mut owners: Vec<PolicyView> =
14426×
        levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect();
14514×
    owners.sort_by(|a, b| a.key.cmp(&b.key));
14614×
    let groups = levels
14714×
        .groups
14814×
        .iter()
14914×
        .map(|g| GroupView {
15010×
            g: g.clone(),
15110×
            reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) },
15220×
            members: all.iter().filter(|r| levels.member(g, r)).map(|r| r.name.clone()).collect(),
15310×
            chosen: levels.chosen.iter().filter(|(gid, _)| *gid == g.id).map(|(_, r)| *r).collect(),
15410×
            policy: PolicyView::of(&policy("group", &g.id.to_string()), format!("group {}", g.name)),
15510×
        })
15614×
        .collect();
15714×
    let hour = time::OffsetDateTime::now_utc().hour();
15814×
    let mut views = vec![];
15926×
    for r in all {
16026×
        let effective = levels.resolve(&r);
16126×
        let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await.map_err(e)?;
16226×
        views.push(RunnerView {
16326×
            groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(),
16426×
            policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)),
16526×
            effective,
16626×
            offer,
16726×
            r,
168
        });
169
    }
17014×
    Ok(Page(UpdatesPage {
17114×
        site: PolicyView::of(&policy("site", ""), "the site".into()),
17214×
        shell,
17314×
        repo,
17414×
        owners,
17514×
        groups,
17614×
        runners: views,
17714×
        notice,
17814×
        error,
17914×
    }))
18014×
}
181
1823×
pub async fn show(State(app): State<Shared>, viewer: Viewer) -> Result<Page<UpdatesPage>, Error> {
1833×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
1843×
    require_admin(&viewer, &shell)?;
1852×
    page(&app, shell, None, None).await
1863×
}
187
188
#[derive(Deserialize)]
189
pub struct SourceForm {
190
    #[serde(rename = "_csrf")]
191
    csrf_token: String,
192
    repo: String,
193
}
194
195
/// `POST /admin/runners/updates/source`: the repository whose releases carry runner builds.
1962×
pub async fn save_source(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SourceForm>) -> Result<Page<UpdatesPage>, Error> {
1972×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
1982×
    let me = require_admin(&viewer, &shell)?;
1992×
    csrf(&viewer, &f.csrf_token, &shell)?;
2002×
    let repo = f.repo.trim().trim_matches('/').to_string();
2012×
    if !repo.is_empty() {
2022×
        let found = match repo.split_once('/') {
2032×
            Some((o, n)) => crate::db::repos::get(&app.db, o, n).await.map_err(|e| Error::from_db(shell.clone(), e))?,
2040×
            None => None,
205
        };
2062×
        if found.is_none() {
2071×
            return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await;
2081×
        }
2090×
    }
2101×
    accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2111×
    accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok();
2121×
    let n = if repo.is_empty() {
2130×
        "Saved: no repository, so no runner is offered a build.".into()
214
    } else {
2151×
        format!("Saved: runner builds come from {repo}'s releases.")
216
    };
2171×
    page(&app, shell, Some(n), None).await
2182×
}
219
220
#[derive(Deserialize)]
221
pub struct PolicyForm {
222
    #[serde(rename = "_csrf")]
223
    csrf_token: String,
224
    scope: String,
225
    #[serde(default)]
226
    key: String,
227
    #[serde(default)]
228
    updates: String,
229
    #[serde(default)]
230
    channel: String,
231
    #[serde(default)]
232
    window: String,
233
    #[serde(default)]
234
    dry_run: String,
235
}
236
237
/// `POST /admin/runners/updates/policy`: one level's settings.
2386×
pub async fn save_policy(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PolicyForm>) -> Result<Page<UpdatesPage>, Error> {
2396×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
2406×
    let me = require_admin(&viewer, &shell)?;
2416×
    csrf(&viewer, &f.csrf_token, &shell)?;
2426×
    let e = |e| Error::from_db(shell.clone(), e);
2436×
    let key = f.key.trim().to_string();
2446×
    let label = match f.scope.as_str() {
2456×
        "site" if key.is_empty() => "the site".to_string(),
2463×
        "owner" => {
2470×
            let known = accounts::account_by_name(&app.db, &key).await.map_err(e)?.is_some()
2480×
                || accounts::org_by_name(&app.db, &key).await.map_err(e)?.is_some();
2490×
            if !known {
2500×
                return page(&app, shell, None, Some(format!("There is no account or organisation {key:?}."))).await;
2510×
            }
2520×
            format!("owner {key}")
253
        }
2543×
        "group" => match key.parse().ok().map(|id| rdb::group(&app.db, id)) {
2552×
            Some(g) => match g.await.map_err(e)? {
2562×
                Some(g) => format!("group {}", g.name),
2570×
                None => return page(&app, shell, None, Some("That test group is gone.".into())).await,
258
            },
2590×
            None => return page(&app, shell, None, Some("That test group is gone.".into())).await,
260
        },
2611×
        "runner" => match key.parse().ok().map(|id| runners::get(&app.db, id)) {
2621×
            Some(r) => match r.await.map_err(e)? {
2631×
                Some(r) => format!("runner {}", r.name),
2640×
                None => return page(&app, shell, None, Some("That runner is gone.".into())).await,
265
            },
2660×
            None => return page(&app, shell, None, Some("That runner is gone.".into())).await,
267
        },
2680×
        _ => return page(&app, shell, None, Some("Choose the site, an owner, a test group or a runner.".into())).await,
269
    };
2708×
    let tri = |v: &str| match v {
2718×
        "on" => Some(true),
2724×
        "off" => Some(false),
2733×
        _ => None,
2748×
    };
2756×
    let channel = f.channel.trim().to_string();
2766×
    if !channel.is_empty() && !ru::valid_channel(&channel) {
2771×
        return page(&app, shell, None, Some(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}."))).await;
2785×
    }
2795×
    let window = match ru::parse_window(&f.window) {
2804×
        Ok(w) => w,
2811×
        Err(why) => return page(&app, shell, None, Some(format!("{why}."))).await,
282
    };
2834×
    let p = Policy {
2844×
        scope: f.scope.clone(),
2854×
        key,
2864×
        updates: tri(&f.updates),
2874×
        channel: Some(channel).filter(|c| !c.is_empty()),
2884×
        window_from: window.map(|w| w.0 as i64),
2894×
        window_to: window.map(|w| w.1 as i64),
2904×
        dry_run: tri(&f.dry_run),
291
    };
2924×
    rdb::set_policy(&app.db, &p).await.map_err(e)?;
2934×
    let words = PolicyView::of(&p, label.clone()).summary();
2944×
    accounts::audit(&app.db, Some(&me), "runner.updates.policy", &label, &words).await.ok();
2954×
    page(&app, shell, Some(format!("Saved for {label}: {words}.")), None).await
2966×
}
297
298
#[derive(Deserialize)]
299
pub struct GroupForm {
300
    #[serde(rename = "_csrf")]
301
    csrf_token: String,
302
    #[serde(default)]
303
    owner: String,
304
    #[serde(default)]
305
    name: String,
306
    /// `percent` or `chosen`.
307
    #[serde(default)]
308
    mode: String,
309
    #[serde(default)]
310
    percent: String,
311
    #[serde(default)]
312
    runner: Vec<i64>,
313
}
314
315
/// The percentage a form gives, or why it cannot be one.
3163×
fn percent_of(f: &GroupForm) -> Result<Option<i64>, String> {
3173×
    if f.mode != "percent" {
3181×
        return Ok(None);
3192×
    }
3202×
    match f.percent.trim().trim_end_matches('%').parse::<i64>() {
3212×
        Ok(p) if (1..=100).contains(&p) => Ok(Some(p)),
3221×
        _ => Err("A percentage is a whole number from 1 to 100.".into()),
323
    }
3243×
}
325
326
/// `POST /admin/runners/updates/groups`: a new test group.
3272×
pub async fn create_group(
3282×
    State(app): State<Shared>,
3292×
    viewer: Viewer,
3302×
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
3312×
) -> Result<Page<UpdatesPage>, Error> {
3322×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
3332×
    let me = require_admin(&viewer, &shell)?;
3342×
    csrf(&viewer, &f.csrf_token, &shell)?;
3352×
    let e = |e| Error::from_db(shell.clone(), e);
3362×
    let name = f.name.trim().to_string();
3372×
    if name.is_empty() || name.len() > 60 {
3380×
        return page(&app, shell, None, Some("A test group needs a name of up to 60 characters.".into())).await;
3392×
    }
3402×
    let owner = f.owner.trim().to_string();
3412×
    if !owner.is_empty()
3420×
        && accounts::account_by_name(&app.db, &owner).await.map_err(e)?.is_none()
3430×
        && accounts::org_by_name(&app.db, &owner).await.map_err(e)?.is_none()
344
    {
3450×
        return page(&app, shell, None, Some(format!("There is no account or organisation {owner:?}."))).await;
3462×
    }
3472×
    let percent = match percent_of(&f) {
3481×
        Ok(p) => p,
3491×
        Err(why) => return page(&app, shell, None, Some(why)).await,
350
    };
3511×
    if rdb::groups(&app.db).await.map_err(e)?.iter().any(|g| g.owner == owner && g.name == name) {
3520×
        return page(&app, shell, None, Some(format!("There is already a test group {name:?} for those runners."))).await;
3531×
    }
3541×
    let g = rdb::create_group(&app.db, &owner, &name, percent).await.map_err(e)?;
3551×
    if percent.is_none() {
3561×
        rdb::set_members(&app.db, g.id, &f.runner).await.map_err(e)?;
3570×
    }
3581×
    let what = match percent {
3590×
        Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }),
3601×
        None => format!("{} chosen runners", f.runner.len()),
361
    };
3621×
    accounts::audit(&app.db, Some(&me), "runner.updates.group", &name, &format!("created: {what}")).await.ok();
3631×
    page(&app, shell, Some(format!("Test group {name} made: {what}. Give it its settings below.")), None).await
3642×
}
365
366
/// `POST /admin/runners/updates/groups/{id}`: who is in a test group.
3671×
pub async fn save_group(
3681×
    State(app): State<Shared>,
3691×
    viewer: Viewer,
3701×
    Path(id): Path<i64>,
3711×
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
3721×
) -> Result<Page<UpdatesPage>, Error> {
3731×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
3741×
    let me = require_admin(&viewer, &shell)?;
3751×
    csrf(&viewer, &f.csrf_token, &shell)?;
3761×
    let e = |e| Error::from_db(shell.clone(), e);
3771×
    let Some(g) = rdb::group(&app.db, id).await.map_err(e)? else {
3780×
        return page(&app, shell, None, Some("That test group is gone.".into())).await;
379
    };
3801×
    let percent = match percent_of(&f) {
3811×
        Ok(p) => p,
3820×
        Err(why) => return page(&app, shell, None, Some(why)).await,
383
    };
3841×
    rdb::set_percent(&app.db, id, percent).await.map_err(e)?;
3851×
    rdb::set_members(&app.db, id, if percent.is_some() { &[] } else { &f.runner }).await.map_err(e)?;
3861×
    let what = match percent {
3871×
        Some(p) => format!("{p}% of its runners"),
3880×
        None => format!("{} chosen runners", f.runner.len()),
389
    };
3901×
    accounts::audit(&app.db, Some(&me), "runner.updates.group", &g.name, &what).await.ok();
3911×
    page(&app, shell, Some(format!("Test group {} is now {what}.", g.name)), None).await
3921×
}
393
394
#[derive(Deserialize)]
395
pub struct CsrfOnly {
396
    #[serde(rename = "_csrf")]
397
    csrf_token: String,
398
}
399
400
/// `POST /admin/runners/updates/groups/{id}/delete`.
4011×
pub async fn delete_group(
4021×
    State(app): State<Shared>,
4031×
    viewer: Viewer,
4041×
    Path(id): Path<i64>,
4051×
    Form(f): Form<CsrfOnly>,
4061×
) -> Result<Page<UpdatesPage>, Error> {
4071×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
4081×
    let me = require_admin(&viewer, &shell)?;
4091×
    csrf(&viewer, &f.csrf_token, &shell)?;
4101×
    let e = |e| Error::from_db(shell.clone(), e);
4111×
    let Some(g) = rdb::group(&app.db, id).await.map_err(e)? else {
4120×
        return page(&app, shell, None, Some("That test group is gone.".into())).await;
413
    };
4141×
    rdb::delete_group(&app.db, id).await.map_err(e)?;
4151×
    accounts::audit(&app.db, Some(&me), "runner.updates.group", &g.name, "removed").await.ok();
4161×
    page(&app, shell, Some(format!("Test group {} removed; its runners follow the levels above it.", g.name)), None).await
4171×
}