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