OXYGENHUB
# b6dfcb61cf5e oxygenhub / crates / hub / src / web / runner_updates.rs
15.7 KiB · Rust · last changed Runner updates: policy, test groups and the offer 10 days ago source blame history raw
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>,
127
    notice: Option<String>,
128
    error: Option<String>,
129
}
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();
136
    let policy = |scope: &str, key: &str| {
137
        levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy {
138
            scope: scope.into(),
139
            key: key.into(),
140
            ..Policy::default()
141
        })
142
    };
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
    owners.sort_by(|a, b| a.key.cmp(&b.key));
146
    let groups = levels
147
        .groups
148
        .iter()
149
        .map(|g| GroupView {
150
            g: g.clone(),
151
            reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) },
152
            members: all.iter().filter(|r| levels.member(g, r)).map(|r| r.name.clone()).collect(),
153
            chosen: levels.chosen.iter().filter(|(gid, _)| *gid == g.id).map(|(_, r)| *r).collect(),
154
            policy: PolicyView::of(&policy("group", &g.id.to_string()), format!("group {}", g.name)),
155
        })
156
        .collect();
157
    let hour = time::OffsetDateTime::now_utc().hour();
158
    let mut views = vec![];
159
    for r in all {
160
        let effective = levels.resolve(&r);
161
        let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await.map_err(e)?;
162
        views.push(RunnerView {
163
            groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(),
164
            policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)),
165
            effective,
166
            offer,
167
            r,
168
        });
169
    }
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
}
181
182
pub async fn show(State(app): State<Shared>, viewer: Viewer) -> Result<Page<UpdatesPage>, Error> {
183
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
184
    require_admin(&viewer, &shell)?;
185
    page(&app, shell, None, None).await
186
}
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.
196
pub async fn save_source(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SourceForm>) -> Result<Page<UpdatesPage>, Error> {
197
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
198
    let me = require_admin(&viewer, &shell)?;
199
    csrf(&viewer, &f.csrf_token, &shell)?;
200
    let repo = f.repo.trim().trim_matches('/').to_string();
201
    if !repo.is_empty() {
202
        let found = match repo.split_once('/') {
203
            Some((o, n)) => crate::db::repos::get(&app.db, o, n).await.map_err(|e| Error::from_db(shell.clone(), e))?,
204
            None => None,
205
        };
206
        if found.is_none() {
207
            return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await;
208
        }
209
    }
210
    accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?;
211
    accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok();
212
    let n = if repo.is_empty() {
213
        "Saved: no repository, so no runner is offered a build.".into()
214
    } else {
215
        format!("Saved: runner builds come from {repo}'s releases.")
216
    };
217
    page(&app, shell, Some(n), None).await
218
}
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.
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);
243
    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();
249
            if !known {
250
                return page(&app, shell, None, Some(format!("There is no account or organisation {key:?}."))).await;
251
            }
252
            format!("owner {key}")
253
        }
254
        "group" => match key.parse().ok().map(|id| rdb::group(&app.db, id)) {
255
            Some(g) => match g.await.map_err(e)? {
256
                Some(g) => format!("group {}", g.name),
257
                None => return page(&app, shell, None, Some("That test group is gone.".into())).await,
258
            },
259
            None => return page(&app, shell, None, Some("That test group is gone.".into())).await,
260
        },
261
        "runner" => match key.parse().ok().map(|id| runners::get(&app.db, id)) {
262
            Some(r) => match r.await.map_err(e)? {
263
                Some(r) => format!("runner {}", r.name),
264
                None => return page(&app, shell, None, Some("That runner is gone.".into())).await,
265
            },
266
            None => return page(&app, shell, None, Some("That runner is gone.".into())).await,
267
        },
268
        _ => return page(&app, shell, None, Some("Choose the site, an owner, a test group or a runner.".into())).await,
269
    };
270
    let tri = |v: &str| match v {
271
        "on" => Some(true),
272
        "off" => Some(false),
273
        _ => None,
274
    };
275
    let channel = f.channel.trim().to_string();
276
    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;
278
    }
279
    let window = match ru::parse_window(&f.window) {
280
        Ok(w) => w,
281
        Err(why) => return page(&app, shell, None, Some(format!("{why}."))).await,
282
    };
283
    let p = Policy {
284
        scope: f.scope.clone(),
285
        key,
286
        updates: tri(&f.updates),
287
        channel: Some(channel).filter(|c| !c.is_empty()),
288
        window_from: window.map(|w| w.0 as i64),
289
        window_to: window.map(|w| w.1 as i64),
290
        dry_run: tri(&f.dry_run),
291
    };
292
    rdb::set_policy(&app.db, &p).await.map_err(e)?;
293
    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
296
}
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.
316
fn percent_of(f: &GroupForm) -> Result<Option<i64>, String> {
317
    if f.mode != "percent" {
318
        return Ok(None);
319
    }
320
    match f.percent.trim().trim_end_matches('%').parse::<i64>() {
321
        Ok(p) if (1..=100).contains(&p) => Ok(Some(p)),
322
        _ => Err("A percentage is a whole number from 1 to 100.".into()),
323
    }
324
}
325
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);
336
    let name = f.name.trim().to_string();
337
    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;
339
    }
340
    let owner = f.owner.trim().to_string();
341
    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()
344
    {
345
        return page(&app, shell, None, Some(format!("There is no account or organisation {owner:?}."))).await;
346
    }
347
    let percent = match percent_of(&f) {
348
        Ok(p) => p,
349
        Err(why) => return page(&app, shell, None, Some(why)).await,
350
    };
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;
353
    }
354
    let g = rdb::create_group(&app.db, &owner, &name, percent).await.map_err(e)?;
355
    if percent.is_none() {
356
        rdb::set_members(&app.db, g.id, &f.runner).await.map_err(e)?;
357
    }
358
    let what = match percent {
359
        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()),
361
    };
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
364
}
365
366
/// `POST /admin/runners/updates/groups/{id}`: who is in a test group.
367
pub async fn save_group(
368
    State(app): State<Shared>,
369
    viewer: Viewer,
370
    Path(id): Path<i64>,
371
    axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>,
372
) -> Result<Page<UpdatesPage>, Error> {
373
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
374
    let me = require_admin(&viewer, &shell)?;
375
    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;
379
    };
380
    let percent = match percent_of(&f) {
381
        Ok(p) => p,
382
        Err(why) => return page(&app, shell, None, Some(why)).await,
383
    };
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)?;
386
    let what = match percent {
387
        Some(p) => format!("{p}% of its runners"),
388
        None => format!("{} chosen runners", f.runner.len()),
389
    };
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
392
}
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`.
401
pub async fn delete_group(
402
    State(app): State<Shared>,
403
    viewer: Viewer,
404
    Path(id): Path<i64>,
405
    Form(f): Form<CsrfOnly>,
406
) -> Result<Page<UpdatesPage>, Error> {
407
    let shell = Shell::new(&app, "/admin/runners/updates", &viewer);
408
    let me = require_admin(&viewer, &shell)?;
409
    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
417
}