OXYGENHUB
◆ v0.9.22 oxygenhub / crates / hub / src / web / runner_updates.rs
22.5 KiB · Rust · last changed Runner updates in waves: a later wave takes a version once the waves before it proved it; an error stops it (refs andymccall/oxygenflow#84) 8 days ago source blame history raw
8501fd58 Andy McCall ↶ 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
18
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
19
    match viewer.account() {
20
        Some(a) if a.is_admin => Ok(a.clone()),
21
        _ => Err(Error::not_found(shell.clone())),
22
    }
23
}
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 {
38
    fn of(p: &Policy, label: String) -> PolicyView {
39
        let tri = |b: Option<bool>| match b {
40
            Some(true) => "on",
41
            Some(false) => "off",
42
            None => "inherit",
43
        };
44
        PolicyView {
45
            scope: p.scope.clone(),
46
            key: p.key.clone(),
47
            label,
48
            updates: tri(p.updates),
49
            channel: p.channel.clone().unwrap_or_default(),
50
            window: match (p.window_from, p.window_to) {
51
                (Some(a), Some(b)) => format!("{a:02}-{b:02}"),
52
                _ => String::new(),
53
            },
54
            dry_run: tri(p.dry_run),
55
        }
56
    }
57
58
    /// The settings in words, for a table.
59
    pub fn summary(&self) -> String {
60
        let mut s = vec![];
61
        if self.updates != "inherit" {
62
            s.push(format!("updates {}", self.updates));
63
        }
64
        if !self.channel.is_empty() {
65
            s.push(format!("channel {}", self.channel));
66
        }
67
        if !self.window.is_empty() {
68
            s.push(format!("window {} UTC", self.window));
69
        }
70
        if self.dry_run != "inherit" {
71
            s.push(format!("dry run {}", self.dry_run));
72
        }
73
        if s.is_empty() {
74
            "inherits everything".into()
75
        } else {
76
            s.join(", ")
77
        }
78
    }
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.
102
    pub fn effective_words(&self) -> String {
103
        let e = &self.effective;
104
        let mut s = vec![format!("updates {} ({})", if e.updates { "on" } else { "off" }, e.from[0])];
105
        if e.updates {
106
            s.push(format!("channel {} ({})", e.channel, e.from[1]));
107
            if let Some((a, b)) = e.window {
108
                s.push(format!("window {a:02}–{b:02} UTC ({})", e.from[2]));
109
            }
110
            if e.dry_run {
111
                s.push(format!("dry run ({})", e.from[3]));
112
            }
113
        }
114
        s.join(" · ")
115
    }
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>,
faf5246c Andy McCall ↶ 127
    /// Builds held back after rolling back on a runner (#326).
128
    holds: Vec<rdb::Hold>,
1ec29691 Andy McCall ↶ 129
    /// Proving a version in a wave (oxygenflow#84).
130
    soak_jobs: i64,
131
    soak_hours: i64,
8501fd58 Andy McCall ↶ 132
    notice: Option<String>,
133
    error: Option<String>,
134
}
135
dc5c6429 Andy McCall ↶ 136
/// Who changes runner updates: the site's administrators anything; an
137
/// organisation's owners their organisation's setting, its test groups and
138
/// its runners (Andy: an organisation tries a build on some of its runners
139
/// before the rest).
140
#[derive(Clone, Debug, PartialEq, Eq)]
141
pub enum Reach {
142
    Site,
143
    Org(String),
144
}
145
146
impl Reach {
147
    fn holds_runner(&self, r: &runners::Runner) -> bool {
148
        match self {
149
            Reach::Site => true,
150
            Reach::Org(o) => r.scope_owner == *o,
151
        }
152
    }
153
154
    fn holds_group(&self, g: &Group) -> bool {
155
        match self {
156
            Reach::Site => true,
157
            Reach::Org(o) => g.owner == *o,
158
        }
159
    }
160
}
161
162
/// What a page shows of runner updates, within `reach`.
163
pub struct UpdateViews {
164
    pub site: PolicyView,
165
    pub owners: Vec<PolicyView>,
166
    pub groups: Vec<GroupView>,
167
    pub runners: Vec<RunnerView>,
168
}
169
170
pub async fn views(app: &Shared, reach: &Reach) -> sqlx::Result<UpdateViews> {
171
    let levels = Levels::load(&app.db).await?;
172
    let all = runners::list(&app.db).await?;
8501fd58 Andy McCall ↶ 173
    let policy = |scope: &str, key: &str| {
174
        levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy {
175
            scope: scope.into(),
176
            key: key.into(),
177
            ..Policy::default()
178
        })
dc5c6429 Andy McCall ↶ 179
    };
180
    let mut owners: Vec<PolicyView> = match reach {
181
        Reach::Site => {
182
            levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect()
183
        }
184
        Reach::Org(o) => vec![PolicyView::of(&policy("owner", o), format!("owner {o}"))],
8501fd58 Andy McCall ↶ 185
    };
186
    owners.sort_by(|a, b| a.key.cmp(&b.key));
187
    let groups = levels
188
        .groups
189
        .iter()
dc5c6429 Andy McCall ↶ 190
        .filter(|g| reach.holds_group(g))
8501fd58 Andy McCall ↶ 191
        .map(|g| GroupView {
192
            g: g.clone(),
193
            reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) },
194
            members: all.iter().filter(|r| levels.member(g, r)).map(|r| r.name.clone()).collect(),
195
            chosen: levels.chosen.iter().filter(|(gid, _)| *gid == g.id).map(|(_, r)| *r).collect(),
196
            policy: PolicyView::of(&policy("group", &g.id.to_string()), format!("group {}", g.name)),
197
        })
198
        .collect();
199
    let hour = time::OffsetDateTime::now_utc().hour();
dc5c6429 Andy McCall ↶ 200
    let mut out = vec![];
201
    for r in all.into_iter().filter(|r| reach.holds_runner(r)) {
8501fd58 Andy McCall ↶ 202
        let effective = levels.resolve(&r);
1ec29691 Andy McCall ↶ 203
        let (_, offer) = ru::offer(&app.db, &levels, &r, &effective, hour, crate::db::now_ms(), false).await?;
dc5c6429 Andy McCall ↶ 204
        out.push(RunnerView {
8501fd58 Andy McCall ↶ 205
            groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(),
206
            policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)),
207
            effective,
208
            offer,
209
            r,
210
        });
dc5c6429 Andy McCall ↶ 211
    }
212
    Ok(UpdateViews { site: PolicyView::of(&policy("site", ""), "the site".into()), owners, groups, runners: out })
213
}
214
215
async fn page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<UpdatesPage>, Error> {
216
    let e = |e| Error::from_db(shell.clone(), e);
217
    let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default();
218
    let v = views(app, &Reach::Site).await.map_err(e)?;
faf5246c Andy McCall ↶ 219
    let holds = rdb::holds(&app.db).await.map_err(e)?;
1ec29691 Andy McCall ↶ 220
    let number = |v: Option<String>, d: i64| v.and_then(|s| s.trim().parse::<i64>().ok()).filter(|n| *n > 0).unwrap_or(d);
221
    let soak_jobs = number(accounts::setting(&app.db, ru::SETTING_SOAK_JOBS).await.map_err(e)?, ru::SOAK_JOBS);
222
    let soak_hours = number(accounts::setting(&app.db, ru::SETTING_SOAK_HOURS).await.map_err(e)?, ru::SOAK_HOURS);
223
    Ok(Page(UpdatesPage {
224
        shell,
225
        repo,
226
        site: v.site,
227
        owners: v.owners,
228
        groups: v.groups,
229
        runners: v.runners,
230
        holds,
231
        soak_jobs,
232
        soak_hours,
233
        notice,
234
        error,
235
    }))
dc5c6429 Andy McCall ↶ 236
}
237
238
/// What a change did, to say on the page: `Ok` a notice, `Err` what was wrong.
239
pub type Outcome = Result<String, String>;
240
241
/// [`page`] saying what `outcome` did.
242
async fn page_after(app: &Shared, shell: Shell, outcome: Outcome) -> Result<Page<UpdatesPage>, Error> {
243
    match outcome {
244
        Ok(n) => page(app, shell, Some(n), None).await,
245
        Err(why) => page(app, shell, None, Some(why)).await,
8501fd58 Andy McCall ↶ 246
    }
247
}
248
249
pub async fn show(State(app): State<Shared>, viewer: Viewer) -> Result<Page<UpdatesPage>, Error> {
250
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
251
    require_admin(&viewer, &shell)?;
252
    page(&app, shell, None, None).await
253
}
254
255
#[derive(Deserialize)]
256
pub struct SourceForm {
257
    #[serde(rename = "_csrf")]
258
    csrf_token: String,
259
    repo: String,
1ec29691 Andy McCall ↶ 260
    #[serde(default)]
261
    soak_jobs: String,
262
    #[serde(default)]
263
    soak_hours: String,
8501fd58 Andy McCall ↶ 264
}
265
266
/// `POST /admin/runners/updates/source`: the repository whose releases carry runner builds.
267
pub async fn save_source(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SourceForm>) -> Result<Page<UpdatesPage>, Error> {
268
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
269
    let me = require_admin(&viewer, &shell)?;
270
    csrf(&viewer, &f.csrf_token, &shell)?;
271
    let repo = f.repo.trim().trim_matches('/').to_string();
272
    if !repo.is_empty() {
273
        let found = match repo.split_once('/') {
274
            Some((o, n)) => crate::db::repos::get(&app.db, o, n).await.map_err(|e| Error::from_db(shell.clone(), e))?,
275
            None => None,
276
        };
277
        if found.is_none() {
278
            return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await;
279
        }
280
    }
1ec29691 Andy McCall ↶ 281
    // Waves (oxygenflow#84): blank keeps the default.
282
    for (key, value, max, what) in [
283
        (ru::SETTING_SOAK_JOBS, &f.soak_jobs, 1000, "jobs each runner of a wave finishes"),
284
        (ru::SETTING_SOAK_HOURS, &f.soak_hours, 720, "hours a wave soaks"),
285
    ] {
286
        let v = value.trim();
287
        if !v.is_empty() && !v.parse::<i64>().is_ok_and(|n| (1..=max).contains(&n)) {
288
            return page(&app, shell, None, Some(format!("The {what} is a whole number from 1 to {max}."))).await;
289
        }
290
        accounts::set_setting(&app.db, key, v).await.map_err(|e| Error::from_db(shell.clone(), e))?;
291
    }
8501fd58 Andy McCall ↶ 292
    accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?;
293
    accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok();
294
    let n = if repo.is_empty() {
295
        "Saved: no repository, so no runner is offered a build.".into()
296
    } else {
297
        format!("Saved: runner builds come from {repo}'s releases.")
298
    };
299
    page(&app, shell, Some(n), None).await
300
}
301
302
#[derive(Deserialize)]
303
pub struct PolicyForm {
304
    #[serde(rename = "_csrf")]
dc5c6429 Andy McCall ↶ 305
    pub(crate) csrf_token: String,
8501fd58 Andy McCall ↶ 306
    scope: String,
307
    #[serde(default)]
308
    key: String,
309
    #[serde(default)]
310
    updates: String,
311
    #[serde(default)]
312
    channel: String,
313
    #[serde(default)]
314
    window: String,
315
    #[serde(default)]
316
    dry_run: String,
317
}
318
9bbca80f Andy McCall ↶ 319
impl PolicyForm {
320
    /// The form an API setting stands for.
321
    pub fn of(u: &hub_api::UpdateSetting) -> PolicyForm {
322
        let tri = |b: Option<bool>| match b {
323
            Some(true) => "on",
324
            Some(false) => "off",
325
            None => "inherit",
326
        };
327
        PolicyForm {
328
            csrf_token: String::new(),
329
            scope: u.scope.clone(),
330
            key: u.key.clone(),
331
            updates: tri(u.updates).into(),
332
            channel: u.channel.clone().unwrap_or_default(),
333
            window: u.window.clone().unwrap_or_default(),
334
            dry_run: tri(u.dry_run).into(),
335
        }
336
    }
337
}
338
dc5c6429 Andy McCall ↶ 339
/// Set one level's settings, within `reach`.
340
pub async fn apply_policy(app: &Shared, me: &accounts::Account, reach: &Reach, f: &PolicyForm) -> sqlx::Result<Outcome> {
8501fd58 Andy McCall ↶ 341
    let key = f.key.trim().to_string();
dc5c6429 Andy McCall ↶ 342
    let label = match (f.scope.as_str(), reach) {
343
        ("site", Reach::Site) if key.is_empty() => "the site".to_string(),
344
        ("owner", Reach::Org(o)) if key != *o => return Ok(Err("An organisation sets its own runners' updates only.".into())),
345
        ("owner", _) => {
346
            let known = accounts::account_by_name(&app.db, &key).await?.is_some() || accounts::org_by_name(&app.db, &key).await?.is_some();
8501fd58 Andy McCall ↶ 347
            if !known {
dc5c6429 Andy McCall ↶ 348
                return Ok(Err(format!("There is no account or organisation {key:?}.")));
8501fd58 Andy McCall ↶ 349
            }
350
            format!("owner {key}")
351
        }
dc5c6429 Andy McCall ↶ 352
        ("group", _) => match key.parse().ok() {
353
            Some(id) => match rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) {
8501fd58 Andy McCall ↶ 354
                Some(g) => format!("group {}", g.name),
dc5c6429 Andy McCall ↶ 355
                None => return Ok(Err("That test group is gone.".into())),
8501fd58 Andy McCall ↶ 356
            },
dc5c6429 Andy McCall ↶ 357
            None => return Ok(Err("That test group is gone.".into())),
8501fd58 Andy McCall ↶ 358
        },
dc5c6429 Andy McCall ↶ 359
        ("runner", _) => match key.parse().ok() {
360
            Some(id) => match runners::get(&app.db, id).await?.filter(|r| reach.holds_runner(r)) {
8501fd58 Andy McCall ↶ 361
                Some(r) => format!("runner {}", r.name),
dc5c6429 Andy McCall ↶ 362
                None => return Ok(Err("That runner is gone.".into())),
8501fd58 Andy McCall ↶ 363
            },
dc5c6429 Andy McCall ↶ 364
            None => return Ok(Err("That runner is gone.".into())),
8501fd58 Andy McCall ↶ 365
        },
dc5c6429 Andy McCall ↶ 366
        _ => return Ok(Err("Choose the site, an owner, a test group or a runner.".into())),
8501fd58 Andy McCall ↶ 367
    };
368
    let tri = |v: &str| match v {
369
        "on" => Some(true),
370
        "off" => Some(false),
371
        _ => None,
372
    };
373
    let channel = f.channel.trim().to_string();
374
    if !channel.is_empty() && !ru::valid_channel(&channel) {
dc5c6429 Andy McCall ↶ 375
        return Ok(Err(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}.")));
8501fd58 Andy McCall ↶ 376
    }
377
    let window = match ru::parse_window(&f.window) {
378
        Ok(w) => w,
dc5c6429 Andy McCall ↶ 379
        Err(why) => return Ok(Err(format!("{why}."))),
8501fd58 Andy McCall ↶ 380
    };
381
    let p = Policy {
382
        scope: f.scope.clone(),
383
        key,
384
        updates: tri(&f.updates),
385
        channel: Some(channel).filter(|c| !c.is_empty()),
386
        window_from: window.map(|w| w.0 as i64),
387
        window_to: window.map(|w| w.1 as i64),
388
        dry_run: tri(&f.dry_run),
389
    };
dc5c6429 Andy McCall ↶ 390
    rdb::set_policy(&app.db, &p).await?;
8501fd58 Andy McCall ↶ 391
    let words = PolicyView::of(&p, label.clone()).summary();
dc5c6429 Andy McCall ↶ 392
    accounts::audit(&app.db, Some(me), "runner.updates.policy", &label, &words).await.ok();
393
    Ok(Ok(format!("Saved for {label}: {words}.")))
394
}
395
396
/// `POST /admin/runners/updates/policy`: one level's settings.
397
pub async fn save_policy(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PolicyForm>) -> Result<Page<UpdatesPage>, Error> {
398
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
399
    let me = require_admin(&viewer, &shell)?;
400
    csrf(&viewer, &f.csrf_token, &shell)?;
401
    let outcome = apply_policy(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
402
    page_after(&app, shell, outcome).await
8501fd58 Andy McCall ↶ 403
}
404
405
#[derive(Deserialize)]
406
pub struct GroupForm {
407
    #[serde(rename = "_csrf")]
dc5c6429 Andy McCall ↶ 408
    pub(crate) csrf_token: String,
8501fd58 Andy McCall ↶ 409
    #[serde(default)]
410
    owner: String,
411
    #[serde(default)]
412
    name: String,
413
    /// `percent` or `chosen`.
414
    #[serde(default)]
415
    mode: String,
416
    #[serde(default)]
417
    percent: String,
418
    #[serde(default)]
419
    runner: Vec<i64>,
1ec29691 Andy McCall ↶ 420
    /// Its wave (oxygenflow#84), 1 to 9; blank for none.
421
    #[serde(default)]
422
    wave: String,
423
}
424
425
/// The wave a form gives, or why it cannot be one.
426
fn wave_of(f: &GroupForm) -> Result<Option<i64>, String> {
427
    let w = f.wave.trim();
428
    if w.is_empty() {
429
        return Ok(None);
430
    }
431
    match w.parse::<i64>() {
432
        Ok(n) if (1..=9).contains(&n) => Ok(Some(n)),
433
        _ => Err("A wave is a whole number from 1 to 9, or blank for after every wave.".into()),
434
    }
8501fd58 Andy McCall ↶ 435
}
436
437
/// The percentage a form gives, or why it cannot be one.
438
fn percent_of(f: &GroupForm) -> Result<Option<i64>, String> {
439
    if f.mode != "percent" {
440
        return Ok(None);
441
    }
442
    match f.percent.trim().trim_end_matches('%').parse::<i64>() {
443
        Ok(p) if (1..=100).contains(&p) => Ok(Some(p)),
444
        _ => Err("A percentage is a whole number from 1 to 100.".into()),
445
    }
446
}
447
dc5c6429 Andy McCall ↶ 448
/// The chosen runners a form names that `owner`'s group may hold.
449
async fn chosen_in_reach(app: &Shared, owner: &str, f: &GroupForm) -> sqlx::Result<Vec<i64>> {
450
    let all = runners::list(&app.db).await?;
451
    Ok(f.runner.iter().copied().filter(|id| all.iter().any(|r| r.id == *id && (owner.is_empty() || r.scope_owner == owner))).collect())
452
}
453
454
/// Make a test group, within `reach` (an organisation's are its own).
455
pub async fn apply_create_group(app: &Shared, me: &accounts::Account, reach: &Reach, f: &GroupForm) -> sqlx::Result<Outcome> {
8501fd58 Andy McCall ↶ 456
    let name = f.name.trim().to_string();
457
    if name.is_empty() || name.len() > 60 {
dc5c6429 Andy McCall ↶ 458
        return Ok(Err("A test group needs a name of up to 60 characters.".into()));
8501fd58 Andy McCall ↶ 459
    }
dc5c6429 Andy McCall ↶ 460
    let owner = match reach {
461
        Reach::Site => f.owner.trim().to_string(),
462
        Reach::Org(o) => o.clone(),
463
    };
8501fd58 Andy McCall ↶ 464
    if !owner.is_empty()
dc5c6429 Andy McCall ↶ 465
        && accounts::account_by_name(&app.db, &owner).await?.is_none()
466
        && accounts::org_by_name(&app.db, &owner).await?.is_none()
8501fd58 Andy McCall ↶ 467
    {
dc5c6429 Andy McCall ↶ 468
        return Ok(Err(format!("There is no account or organisation {owner:?}.")));
8501fd58 Andy McCall ↶ 469
    }
dc5c6429 Andy McCall ↶ 470
    let percent = match percent_of(f) {
8501fd58 Andy McCall ↶ 471
        Ok(p) => p,
dc5c6429 Andy McCall ↶ 472
        Err(why) => return Ok(Err(why)),
8501fd58 Andy McCall ↶ 473
    };
dc5c6429 Andy McCall ↶ 474
    if rdb::groups(&app.db).await?.iter().any(|g| g.owner == owner && g.name == name) {
475
        return Ok(Err(format!("There is already a test group {name:?} for those runners.")));
8501fd58 Andy McCall ↶ 476
    }
1ec29691 Andy McCall ↶ 477
    let wave = match wave_of(f) {
478
        Ok(w) => w,
479
        Err(why) => return Ok(Err(why)),
480
    };
dc5c6429 Andy McCall ↶ 481
    let chosen = chosen_in_reach(app, &owner, f).await?;
482
    let g = rdb::create_group(&app.db, &owner, &name, percent).await?;
1ec29691 Andy McCall ↶ 483
    rdb::set_wave(&app.db, g.id, wave).await?;
8501fd58 Andy McCall ↶ 484
    if percent.is_none() {
dc5c6429 Andy McCall ↶ 485
        rdb::set_members(&app.db, g.id, &chosen).await?;
8501fd58 Andy McCall ↶ 486
    }
1ec29691 Andy McCall ↶ 487
    let mut what = match percent {
8501fd58 Andy McCall ↶ 488
        Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }),
dc5c6429 Andy McCall ↶ 489
        None => format!("{} chosen runners", chosen.len()),
8501fd58 Andy McCall ↶ 490
    };
1ec29691 Andy McCall ↶ 491
    if let Some(w) = wave {
492
        what.push_str(&format!(", wave {w}"));
493
    }
dc5c6429 Andy McCall ↶ 494
    accounts::audit(&app.db, Some(me), "runner.updates.group", &name, &format!("created: {what}")).await.ok();
495
    Ok(Ok(format!("Test group {name} made: {what}. Give it its settings below.")))
8501fd58 Andy McCall ↶ 496
}
497
dc5c6429 Andy McCall ↶ 498
/// `POST /admin/runners/updates/groups`: a new test group.
499
pub async fn create_group(
8501fd58 Andy McCall ↶ 500
    State(app): State<Shared>,
501
    viewer: Viewer,
502
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
503
) -> Result<Page<UpdatesPage>, Error> {
504
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
505
    let me = require_admin(&viewer, &shell)?;
506
    csrf(&viewer, &f.csrf_token, &shell)?;
dc5c6429 Andy McCall ↶ 507
    let outcome = apply_create_group(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
508
    page_after(&app, shell, outcome).await
509
}
510
511
/// Set who is in test group `id`, within `reach`.
512
pub async fn apply_save_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64, f: &GroupForm) -> sqlx::Result<Outcome> {
513
    let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else {
514
        return Ok(Err("That test group is gone.".into()));
8501fd58 Andy McCall ↶ 515
    };
dc5c6429 Andy McCall ↶ 516
    let percent = match percent_of(f) {
8501fd58 Andy McCall ↶ 517
        Ok(p) => p,
dc5c6429 Andy McCall ↶ 518
        Err(why) => return Ok(Err(why)),
8501fd58 Andy McCall ↶ 519
    };
1ec29691 Andy McCall ↶ 520
    let wave = match wave_of(f) {
521
        Ok(w) => w,
522
        Err(why) => return Ok(Err(why)),
523
    };
dc5c6429 Andy McCall ↶ 524
    let chosen = if percent.is_some() { vec![] } else { chosen_in_reach(app, &g.owner, f).await? };
525
    rdb::set_percent(&app.db, id, percent).await?;
526
    rdb::set_members(&app.db, id, &chosen).await?;
1ec29691 Andy McCall ↶ 527
    rdb::set_wave(&app.db, id, wave).await?;
528
    let mut what = match percent {
8501fd58 Andy McCall ↶ 529
        Some(p) => format!("{p}% of its runners"),
dc5c6429 Andy McCall ↶ 530
        None => format!("{} chosen runners", chosen.len()),
8501fd58 Andy McCall ↶ 531
    };
1ec29691 Andy McCall ↶ 532
    if let Some(w) = wave {
533
        what.push_str(&format!(" in wave {w}"));
534
    }
dc5c6429 Andy McCall ↶ 535
    accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, &what).await.ok();
536
    Ok(Ok(format!("Test group {} is now {what}.", g.name)))
8501fd58 Andy McCall ↶ 537
}
538
dc5c6429 Andy McCall ↶ 539
/// `POST /admin/runners/updates/groups/{id}`: who is in a test group.
540
pub async fn save_group(
541
    State(app): State<Shared>,
542
    viewer: Viewer,
543
    Path(id): Path<i64>,
544
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
545
) -> Result<Page<UpdatesPage>, Error> {
546
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
547
    let me = require_admin(&viewer, &shell)?;
548
    csrf(&viewer, &f.csrf_token, &shell)?;
549
    let outcome = apply_save_group(&app, &me, &Reach::Site, id, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
550
    page_after(&app, shell, outcome).await
551
}
552
8501fd58 Andy McCall ↶ 553
#[derive(Deserialize)]
554
pub struct CsrfOnly {
555
    #[serde(rename = "_csrf")]
dc5c6429 Andy McCall ↶ 556
    pub(crate) csrf_token: String,
8501fd58 Andy McCall ↶ 557
}
558
dc5c6429 Andy McCall ↶ 559
/// Remove test group `id`, within `reach`.
560
pub async fn apply_delete_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64) -> sqlx::Result<Outcome> {
561
    let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else {
562
        return Ok(Err("That test group is gone.".into()));
563
    };
564
    rdb::delete_group(&app.db, id).await?;
565
    accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, "removed").await.ok();
566
    Ok(Ok(format!("Test group {} removed; its runners follow the levels above it.", g.name)))
567
}
568
8501fd58 Andy McCall ↶ 569
/// `POST /admin/runners/updates/groups/{id}/delete`.
570
pub async fn delete_group(
571
    State(app): State<Shared>,
572
    viewer: Viewer,
573
    Path(id): Path<i64>,
574
    Form(f): Form<CsrfOnly>,
575
) -> Result<Page<UpdatesPage>, Error> {
576
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
577
    let me = require_admin(&viewer, &shell)?;
578
    csrf(&viewer, &f.csrf_token, &shell)?;
dc5c6429 Andy McCall ↶ 579
    let outcome = apply_delete_group(&app, &me, &Reach::Site, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
580
    page_after(&app, shell, outcome).await
8501fd58 Andy McCall ↶ 581
}
faf5246c Andy McCall ↶ 582
583
#[derive(Deserialize)]
584
pub struct LiftForm {
585
    #[serde(rename = "_csrf")]
586
    csrf_token: String,
587
    version: String,
588
}
589
590
/// `POST /admin/runners/updates/holds/lift`: offer a held build again (#326).
591
pub async fn lift_hold(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LiftForm>) -> Result<Page<UpdatesPage>, Error> {
592
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
593
    let me = require_admin(&viewer, &shell)?;
594
    csrf(&viewer, &f.csrf_token, &shell)?;
595
    let v = f.version.trim();
596
    if !rdb::lift(&app.db, v).await.map_err(|e| Error::from_db(shell.clone(), e))? {
597
        return page(&app, shell, None, Some(format!("flow {v} is not held."))).await;
598
    }
599
    accounts::audit(&app.db, Some(&me), "runner.update.lifted", v, "offered again").await.ok();
600
    page(&app, shell, Some(format!("flow {v} is offered again where the settings say so.")), None).await
601
}