OXYGENHUB

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

337 of 364 lines run, 59 of 140 functions · run #507 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
1818×
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
1918×
    match viewer.account() {
2017×
        Some(a) if a.is_admin => Ok(a.clone()),
211×
        _ => Err(Error::not_found(shell.clone())),
22
    }
2318×
}
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 {
38165×
    fn of(p: &Policy, label: String) -> PolicyView {
39330×
        let tri = |b: Option<bool>| match b {
4063×
            Some(true) => "on",
4119×
            Some(false) => "off",
42248×
            None => "inherit",
43330×
        };
44
        PolicyView {
45165×
            scope: p.scope.clone(),
46165×
            key: p.key.clone(),
47165×
            label,
48165×
            updates: tri(p.updates),
49165×
            channel: p.channel.clone().unwrap_or_default(),
50165×
            window: match (p.window_from, p.window_to) {
5112×
                (Some(a), Some(b)) => format!("{a:02}-{b:02}"),
52153×
                _ => String::new(),
53
            },
54165×
            dry_run: tri(p.dry_run),
55
        }
56165×
    }
57
58
    /// The settings in words, for a table.
5925×
    pub fn summary(&self) -> String {
6025×
        let mut s = vec![];
6125×
        if self.updates != "inherit" {
6219×
            s.push(format!("updates {}", self.updates));
6319×
        }
6425×
        if !self.channel.is_empty() {
653×
            s.push(format!("channel {}", self.channel));
6622×
        }
6725×
        if !self.window.is_empty() {
681×
            s.push(format!("window {} UTC", self.window));
6924×
        }
7025×
        if self.dry_run != "inherit" {
7114×
            s.push(format!("dry run {}", self.dry_run));
7214×
        }
7325×
        if s.is_empty() {
745×
            "inherits everything".into()
75
        } else {
7620×
            s.join(", ")
77
        }
7825×
    }
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.
10252×
    pub fn effective_words(&self) -> String {
10352×
        let e = &self.effective;
10452×
        let mut s = vec![format!("updates {} ({})", if e.updates { "on" } else { "off" }, e.from[0])];
10552×
        if e.updates {
10638×
            s.push(format!("channel {} ({})", e.channel, e.from[1]));
10738×
            if let Some((a, b)) = e.window {
10810×
                s.push(format!("window {a:02}–{b:02} UTC ({})", e.from[2]));
10928×
            }
11038×
            if e.dry_run {
11122×
                s.push(format!("dry run ({})", e.from[3]));
11222×
            }
11314×
        }
11452×
        s.join(" · ")
11552×
    }
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
    /// Builds held back after rolling back on a runner (#326).
128
    holds: Vec<rdb::Hold>,
129
    notice: Option<String>,
130
    error: Option<String>,
131
}
132
133
/// Who changes runner updates: the site's administrators anything; an
134
/// organisation's owners their organisation's setting, its test groups and
135
/// its runners (Andy: an organisation tries a build on some of its runners
136
/// before the rest).
137
#[derive(Clone, Debug, PartialEq, Eq)]
138
pub enum Reach {
139
    Site,
140
    Org(String),
141
}
142
143
impl Reach {
14473×
    fn holds_runner(&self, r: &runners::Runner) -> bool {
14573×
        match self {
14638×
            Reach::Site => true,
14735×
            Reach::Org(o) => r.scope_owner == *o,
148
        }
14973×
    }
150
15128×
    fn holds_group(&self, g: &Group) -> bool {
15228×
        match self {
15319×
            Reach::Site => true,
1549×
            Reach::Org(o) => g.owner == *o,
155
        }
15628×
    }
157
}
158
159
/// What a page shows of runner updates, within `reach`.
160
pub struct UpdateViews {
161
    pub site: PolicyView,
162
    pub owners: Vec<PolicyView>,
163
    pub groups: Vec<GroupView>,
164
    pub runners: Vec<RunnerView>,
165
}
166
16750×
pub async fn views(app: &Shared, reach: &Reach) -> sqlx::Result<UpdateViews> {
16850×
    let levels = Levels::load(&app.db).await?;
16950×
    let all = runners::list(&app.db).await?;
170157×
    let policy = |scope: &str, key: &str| {
171157×
        levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy {
172157×
            scope: scope.into(),
173157×
            key: key.into(),
174157×
            ..Policy::default()
175157×
        })
176157×
    };
17750×
    let mut owners: Vec<PolicyView> = match reach {
178
        Reach::Site => {
17941×
            levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect()
180
        }
18131×
        Reach::Org(o) => vec![PolicyView::of(&policy("owner", o), format!("owner {o}"))],
182
    };
18350×
    owners.sort_by(|a, b| a.key.cmp(&b.key));
18450×
    let groups = levels
18550×
        .groups
18650×
        .iter()
18750×
        .filter(|g| reach.holds_group(g))
18850×
        .map(|g| GroupView {
18920×
            g: g.clone(),
19020×
            reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) },
19145×
            members: all.iter().filter(|r| levels.member(g, r)).map(|r| r.name.clone()).collect(),
19220×
            chosen: levels.chosen.iter().filter(|(gid, _)| *gid == g.id).map(|(_, r)| *r).collect(),
19320×
            policy: PolicyView::of(&policy("group", &g.id.to_string()), format!("group {}", g.name)),
19420×
        })
19550×
        .collect();
19650×
    let hour = time::OffsetDateTime::now_utc().hour();
19750×
    let mut out = vec![];
19869×
    for r in all.into_iter().filter(|r| reach.holds_runner(r)) {
19956×
        let effective = levels.resolve(&r);
20056×
        let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await?;
20156×
        out.push(RunnerView {
20256×
            groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(),
20356×
            policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)),
20456×
            effective,
20556×
            offer,
20656×
            r,
207
        });
208
    }
20950×
    Ok(UpdateViews { site: PolicyView::of(&policy("site", ""), "the site".into()), owners, groups, runners: out })
21050×
}
211
21217×
async fn page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<UpdatesPage>, Error> {
21317×
    let e = |e| Error::from_db(shell.clone(), e);
21417×
    let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default();
21517×
    let v = views(app, &Reach::Site).await.map_err(e)?;
21617×
    let holds = rdb::holds(&app.db).await.map_err(e)?;
21717×
    Ok(Page(UpdatesPage { shell, repo, site: v.site, owners: v.owners, groups: v.groups, runners: v.runners, holds, notice, error }))
21817×
}
219
220
/// What a change did, to say on the page: `Ok` a notice, `Err` what was wrong.
221
pub type Outcome = Result<String, String>;
222
223
/// [`page`] saying what `outcome` did.
22410×
async fn page_after(app: &Shared, shell: Shell, outcome: Outcome) -> Result<Page<UpdatesPage>, Error> {
22510×
    match outcome {
2267×
        Ok(n) => page(app, shell, Some(n), None).await,
2273×
        Err(why) => page(app, shell, None, Some(why)).await,
228
    }
22910×
}
230
2314×
pub async fn show(State(app): State<Shared>, viewer: Viewer) -> Result<Page<UpdatesPage>, Error> {
2324×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
2334×
    require_admin(&viewer, &shell)?;
2343×
    page(&app, shell, None, None).await
2354×
}
236
237
#[derive(Deserialize)]
238
pub struct SourceForm {
239
    #[serde(rename = "_csrf")]
240
    csrf_token: String,
241
    repo: String,
242
}
243
244
/// `POST /admin/runners/updates/source`: the repository whose releases carry runner builds.
2452×
pub async fn save_source(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SourceForm>) -> Result<Page<UpdatesPage>, Error> {
2462×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
2472×
    let me = require_admin(&viewer, &shell)?;
2482×
    csrf(&viewer, &f.csrf_token, &shell)?;
2492×
    let repo = f.repo.trim().trim_matches('/').to_string();
2502×
    if !repo.is_empty() {
2512×
        let found = match repo.split_once('/') {
2522×
            Some((o, n)) => crate::db::repos::get(&app.db, o, n).await.map_err(|e| Error::from_db(shell.clone(), e))?,
2530×
            None => None,
254
        };
2552×
        if found.is_none() {
2561×
            return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await;
2571×
        }
2580×
    }
2591×
    accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2601×
    accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok();
2611×
    let n = if repo.is_empty() {
2620×
        "Saved: no repository, so no runner is offered a build.".into()
263
    } else {
2641×
        format!("Saved: runner builds come from {repo}'s releases.")
265
    };
2661×
    page(&app, shell, Some(n), None).await
2672×
}
268
269
#[derive(Deserialize)]
270
pub struct PolicyForm {
271
    #[serde(rename = "_csrf")]
272
    pub(crate) csrf_token: String,
273
    scope: String,
274
    #[serde(default)]
275
    key: String,
276
    #[serde(default)]
277
    updates: String,
278
    #[serde(default)]
279
    channel: String,
280
    #[serde(default)]
281
    window: String,
282
    #[serde(default)]
283
    dry_run: String,
284
}
285
286
impl PolicyForm {
287
    /// The form an API setting stands for.
2882×
    pub fn of(u: &hub_api::UpdateSetting) -> PolicyForm {
2894×
        let tri = |b: Option<bool>| match b {
2902×
            Some(true) => "on",
2910×
            Some(false) => "off",
2922×
            None => "inherit",
2934×
        };
2942×
        PolicyForm {
2952×
            csrf_token: String::new(),
2962×
            scope: u.scope.clone(),
2972×
            key: u.key.clone(),
2982×
            updates: tri(u.updates).into(),
2992×
            channel: u.channel.clone().unwrap_or_default(),
3002×
            window: u.window.clone().unwrap_or_default(),
3012×
            dry_run: tri(u.dry_run).into(),
3022×
        }
3032×
    }
304
}
305
306
/// Set one level's settings, within `reach`.
30714×
pub async fn apply_policy(app: &Shared, me: &accounts::Account, reach: &Reach, f: &PolicyForm) -> sqlx::Result<Outcome> {
30814×
    let key = f.key.trim().to_string();
30914×
    let label = match (f.scope.as_str(), reach) {
31014×
        ("site", Reach::Site) if key.is_empty() => "the site".to_string(),
3119×
        ("owner", Reach::Org(o)) if key != *o => return Ok(Err("An organisation sets its own runners' updates only.".into())),
312
        ("owner", _) => {
3131×
            let known = accounts::account_by_name(&app.db, &key).await?.is_some() || accounts::org_by_name(&app.db, &key).await?.is_some();
3141×
            if !known {
3150×
                return Ok(Err(format!("There is no account or organisation {key:?}.")));
3161×
            }
3171×
            format!("owner {key}")
318
        }
3197×
        ("group", _) => match key.parse().ok() {
3203×
            Some(id) => match rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) {
3213×
                Some(g) => format!("group {}", g.name),
3220×
                None => return Ok(Err("That test group is gone.".into())),
323
            },
3240×
            None => return Ok(Err("That test group is gone.".into())),
325
        },
3264×
        ("runner", _) => match key.parse().ok() {
3274×
            Some(id) => match runners::get(&app.db, id).await?.filter(|r| reach.holds_runner(r)) {
3283×
                Some(r) => format!("runner {}", r.name),
3291×
                None => return Ok(Err("That runner is gone.".into())),
330
            },
3310×
            None => return Ok(Err("That runner is gone.".into())),
332
        },
3331×
        _ => return Ok(Err("Choose the site, an owner, a test group or a runner.".into())),
334
    };
33516×
    let tri = |v: &str| match v {
33616×
        "on" => Some(true),
3379×
        "off" => Some(false),
3387×
        _ => None,
33916×
    };
34011×
    let channel = f.channel.trim().to_string();
34111×
    if !channel.is_empty() && !ru::valid_channel(&channel) {
3422×
        return Ok(Err(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}.")));
3439×
    }
3449×
    let window = match ru::parse_window(&f.window) {
3458×
        Ok(w) => w,
3461×
        Err(why) => return Ok(Err(format!("{why}."))),
347
    };
3488×
    let p = Policy {
3498×
        scope: f.scope.clone(),
3508×
        key,
3518×
        updates: tri(&f.updates),
3528×
        channel: Some(channel).filter(|c| !c.is_empty()),
3538×
        window_from: window.map(|w| w.0 as i64),
3548×
        window_to: window.map(|w| w.1 as i64),
3558×
        dry_run: tri(&f.dry_run),
356
    };
3578×
    rdb::set_policy(&app.db, &p).await?;
3588×
    let words = PolicyView::of(&p, label.clone()).summary();
3598×
    accounts::audit(&app.db, Some(me), "runner.updates.policy", &label, &words).await.ok();
3608×
    Ok(Ok(format!("Saved for {label}: {words}.")))
36114×
}
362
363
/// `POST /admin/runners/updates/policy`: one level's settings.
3646×
pub async fn save_policy(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PolicyForm>) -> Result<Page<UpdatesPage>, Error> {
3656×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
3666×
    let me = require_admin(&viewer, &shell)?;
3676×
    csrf(&viewer, &f.csrf_token, &shell)?;
3686×
    let outcome = apply_policy(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
3696×
    page_after(&app, shell, outcome).await
3706×
}
371
372
#[derive(Deserialize)]
373
pub struct GroupForm {
374
    #[serde(rename = "_csrf")]
375
    pub(crate) csrf_token: String,
376
    #[serde(default)]
377
    owner: String,
378
    #[serde(default)]
379
    name: String,
380
    /// `percent` or `chosen`.
381
    #[serde(default)]
382
    mode: String,
383
    #[serde(default)]
384
    percent: String,
385
    #[serde(default)]
386
    runner: Vec<i64>,
387
}
388
389
/// The percentage a form gives, or why it cannot be one.
3904×
fn percent_of(f: &GroupForm) -> Result<Option<i64>, String> {
3914×
    if f.mode != "percent" {
3922×
        return Ok(None);
3932×
    }
3942×
    match f.percent.trim().trim_end_matches('%').parse::<i64>() {
3952×
        Ok(p) if (1..=100).contains(&p) => Ok(Some(p)),
3961×
        _ => Err("A percentage is a whole number from 1 to 100.".into()),
397
    }
3984×
}
399
400
/// The chosen runners a form names that `owner`'s group may hold.
4012×
async fn chosen_in_reach(app: &Shared, owner: &str, f: &GroupForm) -> sqlx::Result<Vec<i64>> {
4022×
    let all = runners::list(&app.db).await?;
4036×
    Ok(f.runner.iter().copied().filter(|id| all.iter().any(|r| r.id == *id && (owner.is_empty() || r.scope_owner == owner))).collect())
4042×
}
405
406
/// Make a test group, within `reach` (an organisation's are its own).
4073×
pub async fn apply_create_group(app: &Shared, me: &accounts::Account, reach: &Reach, f: &GroupForm) -> sqlx::Result<Outcome> {
4083×
    let name = f.name.trim().to_string();
4093×
    if name.is_empty() || name.len() > 60 {
4100×
        return Ok(Err("A test group needs a name of up to 60 characters.".into()));
4113×
    }
4123×
    let owner = match reach {
4132×
        Reach::Site => f.owner.trim().to_string(),
4141×
        Reach::Org(o) => o.clone(),
415
    };
4163×
    if !owner.is_empty()
4171×
        && accounts::account_by_name(&app.db, &owner).await?.is_none()
4181×
        && accounts::org_by_name(&app.db, &owner).await?.is_none()
419
    {
4200×
        return Ok(Err(format!("There is no account or organisation {owner:?}.")));
4213×
    }
4223×
    let percent = match percent_of(f) {
4232×
        Ok(p) => p,
4241×
        Err(why) => return Ok(Err(why)),
425
    };
4262×
    if rdb::groups(&app.db).await?.iter().any(|g| g.owner == owner && g.name == name) {
4270×
        return Ok(Err(format!("There is already a test group {name:?} for those runners.")));
4282×
    }
4292×
    let chosen = chosen_in_reach(app, &owner, f).await?;
4302×
    let g = rdb::create_group(&app.db, &owner, &name, percent).await?;
4312×
    if percent.is_none() {
4322×
        rdb::set_members(&app.db, g.id, &chosen).await?;
4330×
    }
4342×
    let what = match percent {
4350×
        Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }),
4362×
        None => format!("{} chosen runners", chosen.len()),
437
    };
4382×
    accounts::audit(&app.db, Some(me), "runner.updates.group", &name, &format!("created: {what}")).await.ok();
4392×
    Ok(Ok(format!("Test group {name} made: {what}. Give it its settings below.")))
4403×
}
441
442
/// `POST /admin/runners/updates/groups`: a new test group.
4432×
pub async fn create_group(
4442×
    State(app): State<Shared>,
4452×
    viewer: Viewer,
4462×
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
4472×
) -> Result<Page<UpdatesPage>, Error> {
4482×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
4492×
    let me = require_admin(&viewer, &shell)?;
4502×
    csrf(&viewer, &f.csrf_token, &shell)?;
4512×
    let outcome = apply_create_group(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
4522×
    page_after(&app, shell, outcome).await
4532×
}
454
455
/// Set who is in test group `id`, within `reach`.
4561×
pub async fn apply_save_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64, f: &GroupForm) -> sqlx::Result<Outcome> {
4571×
    let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else {
4580×
        return Ok(Err("That test group is gone.".into()));
459
    };
4601×
    let percent = match percent_of(f) {
4611×
        Ok(p) => p,
4620×
        Err(why) => return Ok(Err(why)),
463
    };
4641×
    let chosen = if percent.is_some() { vec![] } else { chosen_in_reach(app, &g.owner, f).await? };
4651×
    rdb::set_percent(&app.db, id, percent).await?;
4661×
    rdb::set_members(&app.db, id, &chosen).await?;
4671×
    let what = match percent {
4681×
        Some(p) => format!("{p}% of its runners"),
4690×
        None => format!("{} chosen runners", chosen.len()),
470
    };
4711×
    accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, &what).await.ok();
4721×
    Ok(Ok(format!("Test group {} is now {what}.", g.name)))
4731×
}
474
475
/// `POST /admin/runners/updates/groups/{id}`: who is in a test group.
4761×
pub async fn save_group(
4771×
    State(app): State<Shared>,
4781×
    viewer: Viewer,
4791×
    Path(id): Path<i64>,
4801×
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
4811×
) -> Result<Page<UpdatesPage>, Error> {
4821×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
4831×
    let me = require_admin(&viewer, &shell)?;
4841×
    csrf(&viewer, &f.csrf_token, &shell)?;
4851×
    let outcome = apply_save_group(&app, &me, &Reach::Site, id, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
4861×
    page_after(&app, shell, outcome).await
4871×
}
488
489
#[derive(Deserialize)]
490
pub struct CsrfOnly {
491
    #[serde(rename = "_csrf")]
492
    pub(crate) csrf_token: String,
493
}
494
495
/// Remove test group `id`, within `reach`.
4962×
pub async fn apply_delete_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64) -> sqlx::Result<Outcome> {
4972×
    let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else {
4981×
        return Ok(Err("That test group is gone.".into()));
499
    };
5001×
    rdb::delete_group(&app.db, id).await?;
5011×
    accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, "removed").await.ok();
5021×
    Ok(Ok(format!("Test group {} removed; its runners follow the levels above it.", g.name)))
5032×
}
504
505
/// `POST /admin/runners/updates/groups/{id}/delete`.
5061×
pub async fn delete_group(
5071×
    State(app): State<Shared>,
5081×
    viewer: Viewer,
5091×
    Path(id): Path<i64>,
5101×
    Form(f): Form<CsrfOnly>,
5111×
) -> Result<Page<UpdatesPage>, Error> {
5121×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
5131×
    let me = require_admin(&viewer, &shell)?;
5141×
    csrf(&viewer, &f.csrf_token, &shell)?;
5151×
    let outcome = apply_delete_group(&app, &me, &Reach::Site, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5161×
    page_after(&app, shell, outcome).await
5171×
}
518
519
#[derive(Deserialize)]
520
pub struct LiftForm {
521
    #[serde(rename = "_csrf")]
522
    csrf_token: String,
523
    version: String,
524
}
525
526
/// `POST /admin/runners/updates/holds/lift`: offer a held build again (#326).
5272×
pub async fn lift_hold(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LiftForm>) -> Result<Page<UpdatesPage>, Error> {
5282×
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
5292×
    let me = require_admin(&viewer, &shell)?;
5302×
    csrf(&viewer, &f.csrf_token, &shell)?;
5312×
    let v = f.version.trim();
5322×
    if !rdb::lift(&app.db, v).await.map_err(|e| Error::from_db(shell.clone(), e))? {
5331×
        return page(&app, shell, None, Some(format!("flow {v} is not held."))).await;
5341×
    }
5351×
    accounts::audit(&app.db, Some(&me), "runner.update.lifted", v, "offered again").await.ok();
5361×
    page(&app, shell, Some(format!("flow {v} is offered again where the settings say so.")), None).await
5372×
}