//! Admin โ†’ Runners โ†’ Updates (oxygenhub#322): where runner builds come //! from, and who takes them: the site's, each owner's, each test group's //! and each runner's settings, the nearest winning; test groups of chosen //! runners or a percentage of them. use super::settings::csrf; use super::{filters, Error, Page, Shell}; use crate::auth::Viewer; use crate::db::runner_updates::{self as rdb, Group, Policy}; use crate::db::{accounts, runners}; use crate::runner_updates::{self as ru, Levels}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, State}; use flow_core::api::UpdatePolicy; use serde::Deserialize; fn require_admin(viewer: &Viewer, shell: &Shell) -> Result { match viewer.account() { Some(a) if a.is_admin => Ok(a.clone()), _ => Err(Error::not_found(shell.clone())), } } /// One level's settings as its form shows them: `inherit`, `on` or `off` /// for the switches, blank for an inherited channel or window. pub struct PolicyView { pub scope: String, pub key: String, pub label: String, pub updates: &'static str, pub channel: String, pub window: String, pub dry_run: &'static str, } impl PolicyView { fn of(p: &Policy, label: String) -> PolicyView { let tri = |b: Option| match b { Some(true) => "on", Some(false) => "off", None => "inherit", }; PolicyView { scope: p.scope.clone(), key: p.key.clone(), label, updates: tri(p.updates), channel: p.channel.clone().unwrap_or_default(), window: match (p.window_from, p.window_to) { (Some(a), Some(b)) => format!("{a:02}-{b:02}"), _ => String::new(), }, dry_run: tri(p.dry_run), } } /// The settings in words, for a table. pub fn summary(&self) -> String { let mut s = vec![]; if self.updates != "inherit" { s.push(format!("updates {}", self.updates)); } if !self.channel.is_empty() { s.push(format!("channel {}", self.channel)); } if !self.window.is_empty() { s.push(format!("window {} UTC", self.window)); } if self.dry_run != "inherit" { s.push(format!("dry run {}", self.dry_run)); } if s.is_empty() { "inherits everything".into() } else { s.join(", ") } } } pub struct GroupView { pub g: Group, /// Whom it can hold, in words. pub reach: String, /// Its members now, by name. pub members: Vec, pub chosen: Vec, pub policy: PolicyView, } pub struct RunnerView { pub r: runners::Runner, pub groups: Vec, pub effective: UpdatePolicy, /// What the hub would offer it now. pub offer: String, pub policy: PolicyView, } impl RunnerView { /// Its effective settings, each with where it came from. pub fn effective_words(&self) -> String { let e = &self.effective; let mut s = vec![format!("updates {} ({})", if e.updates { "on" } else { "off" }, e.from[0])]; if e.updates { s.push(format!("channel {} ({})", e.channel, e.from[1])); if let Some((a, b)) = e.window { s.push(format!("window {a:02}โ€“{b:02} UTC ({})", e.from[2])); } if e.dry_run { s.push(format!("dry run ({})", e.from[3])); } } s.join(" ยท ") } } #[derive(Template)] #[template(path = "admin_runner_updates.html")] pub struct UpdatesPage { shell: Shell, repo: String, site: PolicyView, owners: Vec, groups: Vec, runners: Vec, notice: Option, error: Option, } async fn page(app: &Shared, shell: Shell, notice: Option, error: Option) -> Result, Error> { let e = |e| Error::from_db(shell.clone(), e); let levels = Levels::load(&app.db).await.map_err(e)?; let all = runners::list(&app.db).await.map_err(e)?; let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default(); let policy = |scope: &str, key: &str| { levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy { scope: scope.into(), key: key.into(), ..Policy::default() }) }; let mut owners: Vec = levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect(); owners.sort_by(|a, b| a.key.cmp(&b.key)); let groups = levels .groups .iter() .map(|g| GroupView { g: g.clone(), reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) }, members: all.iter().filter(|r| levels.member(g, r)).map(|r| r.name.clone()).collect(), chosen: levels.chosen.iter().filter(|(gid, _)| *gid == g.id).map(|(_, r)| *r).collect(), policy: PolicyView::of(&policy("group", &g.id.to_string()), format!("group {}", g.name)), }) .collect(); let hour = time::OffsetDateTime::now_utc().hour(); let mut views = vec![]; for r in all { let effective = levels.resolve(&r); let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await.map_err(e)?; views.push(RunnerView { groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(), policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)), effective, offer, r, }); } Ok(Page(UpdatesPage { site: PolicyView::of(&policy("site", ""), "the site".into()), shell, repo, owners, groups, runners: views, notice, error, })) } pub async fn show(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/admin/runners/updates", &viewer); require_admin(&viewer, &shell)?; page(&app, shell, None, None).await } #[derive(Deserialize)] pub struct SourceForm { #[serde(rename = "_csrf")] csrf_token: String, repo: String, } /// `POST /admin/runners/updates/source`: the repository whose releases carry runner builds. pub async fn save_source(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/admin/runners/updates", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let repo = f.repo.trim().trim_matches('/').to_string(); if !repo.is_empty() { let found = match repo.split_once('/') { Some((o, n)) => crate::db::repos::get(&app.db, o, n).await.map_err(|e| Error::from_db(shell.clone(), e))?, None => None, }; if found.is_none() { return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await; } } accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok(); let n = if repo.is_empty() { "Saved: no repository, so no runner is offered a build.".into() } else { format!("Saved: runner builds come from {repo}'s releases.") }; page(&app, shell, Some(n), None).await } #[derive(Deserialize)] pub struct PolicyForm { #[serde(rename = "_csrf")] csrf_token: String, scope: String, #[serde(default)] key: String, #[serde(default)] updates: String, #[serde(default)] channel: String, #[serde(default)] window: String, #[serde(default)] dry_run: String, } /// `POST /admin/runners/updates/policy`: one level's settings. pub async fn save_policy(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/admin/runners/updates", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let e = |e| Error::from_db(shell.clone(), e); let key = f.key.trim().to_string(); let label = match f.scope.as_str() { "site" if key.is_empty() => "the site".to_string(), "owner" => { let known = accounts::account_by_name(&app.db, &key).await.map_err(e)?.is_some() || accounts::org_by_name(&app.db, &key).await.map_err(e)?.is_some(); if !known { return page(&app, shell, None, Some(format!("There is no account or organisation {key:?}."))).await; } format!("owner {key}") } "group" => match key.parse().ok().map(|id| rdb::group(&app.db, id)) { Some(g) => match g.await.map_err(e)? { Some(g) => format!("group {}", g.name), None => return page(&app, shell, None, Some("That test group is gone.".into())).await, }, None => return page(&app, shell, None, Some("That test group is gone.".into())).await, }, "runner" => match key.parse().ok().map(|id| runners::get(&app.db, id)) { Some(r) => match r.await.map_err(e)? { Some(r) => format!("runner {}", r.name), None => return page(&app, shell, None, Some("That runner is gone.".into())).await, }, None => return page(&app, shell, None, Some("That runner is gone.".into())).await, }, _ => return page(&app, shell, None, Some("Choose the site, an owner, a test group or a runner.".into())).await, }; let tri = |v: &str| match v { "on" => Some(true), "off" => Some(false), _ => None, }; let channel = f.channel.trim().to_string(); if !channel.is_empty() && !ru::valid_channel(&channel) { return page(&app, shell, None, Some(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}."))).await; } let window = match ru::parse_window(&f.window) { Ok(w) => w, Err(why) => return page(&app, shell, None, Some(format!("{why}."))).await, }; let p = Policy { scope: f.scope.clone(), key, updates: tri(&f.updates), channel: Some(channel).filter(|c| !c.is_empty()), window_from: window.map(|w| w.0 as i64), window_to: window.map(|w| w.1 as i64), dry_run: tri(&f.dry_run), }; rdb::set_policy(&app.db, &p).await.map_err(e)?; let words = PolicyView::of(&p, label.clone()).summary(); accounts::audit(&app.db, Some(&me), "runner.updates.policy", &label, &words).await.ok(); page(&app, shell, Some(format!("Saved for {label}: {words}.")), None).await } #[derive(Deserialize)] pub struct GroupForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] owner: String, #[serde(default)] name: String, /// `percent` or `chosen`. #[serde(default)] mode: String, #[serde(default)] percent: String, #[serde(default)] runner: Vec, } /// The percentage a form gives, or why it cannot be one. fn percent_of(f: &GroupForm) -> Result, String> { if f.mode != "percent" { return Ok(None); } match f.percent.trim().trim_end_matches('%').parse::() { Ok(p) if (1..=100).contains(&p) => Ok(Some(p)), _ => Err("A percentage is a whole number from 1 to 100.".into()), } } /// `POST /admin/runners/updates/groups`: a new test group. pub async fn create_group( State(app): State, viewer: Viewer, axum_extra::extract::Form(f): axum_extra::extract::Form, ) -> Result, Error> { let shell = Shell::new(&app, "/admin/runners/updates", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let e = |e| Error::from_db(shell.clone(), e); let name = f.name.trim().to_string(); if name.is_empty() || name.len() > 60 { return page(&app, shell, None, Some("A test group needs a name of up to 60 characters.".into())).await; } let owner = f.owner.trim().to_string(); if !owner.is_empty() && accounts::account_by_name(&app.db, &owner).await.map_err(e)?.is_none() && accounts::org_by_name(&app.db, &owner).await.map_err(e)?.is_none() { return page(&app, shell, None, Some(format!("There is no account or organisation {owner:?}."))).await; } let percent = match percent_of(&f) { Ok(p) => p, Err(why) => return page(&app, shell, None, Some(why)).await, }; if rdb::groups(&app.db).await.map_err(e)?.iter().any(|g| g.owner == owner && g.name == name) { return page(&app, shell, None, Some(format!("There is already a test group {name:?} for those runners."))).await; } let g = rdb::create_group(&app.db, &owner, &name, percent).await.map_err(e)?; if percent.is_none() { rdb::set_members(&app.db, g.id, &f.runner).await.map_err(e)?; } let what = match percent { Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }), None => format!("{} chosen runners", f.runner.len()), }; accounts::audit(&app.db, Some(&me), "runner.updates.group", &name, &format!("created: {what}")).await.ok(); page(&app, shell, Some(format!("Test group {name} made: {what}. Give it its settings below.")), None).await } /// `POST /admin/runners/updates/groups/{id}`: who is in a test group. pub async fn save_group( State(app): State, viewer: Viewer, Path(id): Path, axum_extra::extract::Form(f): axum_extra::extract::Form, ) -> Result, Error> { let shell = Shell::new(&app, "/admin/runners/updates", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let e = |e| Error::from_db(shell.clone(), e); let Some(g) = rdb::group(&app.db, id).await.map_err(e)? else { return page(&app, shell, None, Some("That test group is gone.".into())).await; }; let percent = match percent_of(&f) { Ok(p) => p, Err(why) => return page(&app, shell, None, Some(why)).await, }; rdb::set_percent(&app.db, id, percent).await.map_err(e)?; rdb::set_members(&app.db, id, if percent.is_some() { &[] } else { &f.runner }).await.map_err(e)?; let what = match percent { Some(p) => format!("{p}% of its runners"), None => format!("{} chosen runners", f.runner.len()), }; accounts::audit(&app.db, Some(&me), "runner.updates.group", &g.name, &what).await.ok(); page(&app, shell, Some(format!("Test group {} is now {what}.", g.name)), None).await } #[derive(Deserialize)] pub struct CsrfOnly { #[serde(rename = "_csrf")] csrf_token: String, } /// `POST /admin/runners/updates/groups/{id}/delete`. pub async fn delete_group( State(app): State, viewer: Viewer, Path(id): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/admin/runners/updates", &viewer); let me = require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let e = |e| Error::from_db(shell.clone(), e); let Some(g) = rdb::group(&app.db, id).await.map_err(e)? else { return page(&app, shell, None, Some("That test group is gone.".into())).await; }; rdb::delete_group(&app.db, id).await.map_err(e)?; accounts::audit(&app.db, Some(&me), "runner.updates.group", &g.name, "removed").await.ok(); page(&app, shell, Some(format!("Test group {} removed; its runners follow the levels above it.", g.name)), None).await }