Runner updates for an organisation's own runners dc5c64296b0a
An organisation's owners set, in its Settings, the organisation's update setting, test groups of its runners (chosen or a percentage) and each runner's own; the admin page and the organisation's share one set of rules by reach, so nothing of the site's or another owner's. refs #322
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7f243bbb638d
- When
- · 2026-10-26 04:17 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
dc5c64296b0a8f0bd3fea78d05081ac1a0bc8452d320e0f10315d8f4f76c6ab4- Depends on
- 155cab142b63 19bb31ff1f5c 42fa95540468 4ac01a732552 7974adb0be66 8501fd5815b9 8fb35df7d66e 9756c67d3aec b5668108487f b6dfcb61cf5e d23464c0ac60 d7050703d2f4
- Depended on by
- 42c77f6515c2 1ec29691b67a 9bbca80f777a faf5246c900e
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 64 atoms, 1 tree operation, 8 files
Browse the tree at this change
added templates/runner_update_fields.html +7 −0
| 1 | + | {# One level's update settings (oxygenhub#322); `top` is the site, which inherits from nothing. #} |
|
| 2 | + | {% macro policy_fields(p, top) %} |
|
| 3 | + | <label class="pol">Updates <select name="updates">{% if !top %}<option value="inherit"{% if p.updates == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.updates == "off" || (top && p.updates == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.updates == "on" %} selected{% endif %}>on</option></select></label> |
|
| 4 | + | <label class="pol">Channel <input type="text" name="channel" value="{{ p.channel }}" placeholder="{% if top %}stable{% else %}inherit{% endif %}" size="9" list="update-channels"></label> |
|
| 5 | + | <label class="pol">Window, UTC <input type="text" name="window" value="{{ p.window }}" placeholder="{% if top %}any time{% else %}inherit{% endif %}" size="7" title="Hours in which updates may start, from-to, e.g. 02-05"></label> |
|
| 6 | + | <label class="pol">Dry run <select name="dry_run">{% if !top %}<option value="inherit"{% if p.dry_run == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.dry_run == "off" || (top && p.dry_run == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.dry_run == "on" %} selected{% endif %}>on</option></select></label> |
|
| 7 | + | {% endmacro %} |
modified CHANGELOG.md +1 −1
| … | 4 unchanged lines |
||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | 7 | - **A WASM job's placement names no image (#323):** a job on the WASM tier was placed saying its runner "fetches the image from the hub"; it has none, so the line now starts with what the runner is doing (andymccall/windows-runner-demo run #1). |
|
| 8 | - | - **Runner updates: policy, test groups and the offer (#322):** Admin › Runners › Updates sets where runner builds come from (a repository whose releases carry `flow-linux-ARCH` files, and `flow-windows-x86_64.exe` for Windows runners) and who takes them. Updates are off unless chosen; the site, an owner, a test group and a runner may each set updates on or off, a channel (`stable`, `canary` or a version), a window of hours (UTC) and dry run, and the nearest setting wins, each setting on its own. A test group is chosen runners or a percentage of the site's (or an owner's) runners, by a stable hash of each runner's id, so a share is the same machines every rollout and 10% sits inside 50%. `GET /api/v1/flow/runner/update` tells a runner its policy, where each setting came from, and the build to install (its BLAKE3, to check the download against); `POST` takes what the runner did, shown on the page and in the audit log. Pins OxygenFlow's main. |
|
| 8 | + | - **Runner updates: policy, test groups and the offer (#322):** Admin › Runners › Updates sets where runner builds come from (a repository whose releases carry `flow-linux-ARCH` files, and `flow-windows-x86_64.exe` for Windows runners) and who takes them. Updates are off unless chosen; the site, an owner, a test group and a runner may each set updates on or off, a channel (`stable`, `canary` or a version), a window of hours (UTC) and dry run, and the nearest setting wins, each setting on its own. A test group is chosen runners or a percentage of the site's (or an owner's) runners, by a stable hash of each runner's id, so a share is the same machines every rollout and 10% sits inside 50%. `GET /api/v1/flow/runner/update` tells a runner its policy, where each setting came from, and the build to install (its BLAKE3, to check the download against); `POST` takes what the runner did, shown on the page and in the audit log. An organisation's owners set the same for their own runners in the organisation's Settings › Runner updates: the organisation's setting, test groups of its runners (chosen, or a percentage), and each runner's own; nothing of the site's or another owner's. Pins OxygenFlow's main. |
|
| 9 | 9 | ||
| 10 | 10 | ## 0.9.5 (2026-10-26) |
|
| 11 | 11 | ||
| … | 334 unchanged lines |
modified crates/hub/src/web/mod.rs +4 −0
| … | 143 unchanged lines |
||
| 144 | 144 | .route("/orgs/{name}/settings/storage", post(orgs::storage)) |
|
| 145 | 145 | .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token)) |
|
| 146 | 146 | .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove)) |
|
| 147 | + | .route("/orgs/{name}/settings/runners/updates/policy", post(orgs::runner_updates_policy)) |
|
| 148 | + | .route("/orgs/{name}/settings/runners/updates/groups", post(orgs::runner_updates_group_new)) |
|
| 149 | + | .route("/orgs/{name}/settings/runners/updates/groups/{id}", post(orgs::runner_updates_group)) |
|
| 150 | + | .route("/orgs/{name}/settings/runners/updates/groups/{id}/delete", post(orgs::runner_updates_group_delete)) |
|
| 147 | 151 | .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries)) |
|
| 148 | 152 | .route("/orgs/{name}/settings/hooks/{id}/redeliver/{delivery}", post(orgs::redeliver)) |
|
| 149 | 153 | .route("/orgs/{name}/settings/avatar", post(orgs::avatar).layer(DefaultBodyLimit::max(avatar::MAX_BYTES + (64 << 10)))) |
|
| … | 808 unchanged lines |
modified crates/hub/src/web/orgs.rs +80 −0
| … | 67 unchanged lines |
||
| 68 | 68 | /// Runners serving the organisation: (runner, what it serves, whether |
|
| 69 | 69 | /// it is the organisation's own). |
|
| 70 | 70 | runners: Vec<(crate::db::runners::Runner, String, bool)>, |
|
| 71 | + | /// Its runners' updates (oxygenhub#322): its setting, test groups and runners. |
|
| 72 | + | updates: super::runner_updates::UpdateViews, |
|
| 71 | 73 | /// A registration token just made, shown once. |
|
| 72 | 74 | runner_token: Option<String>, |
|
| 73 | 75 | /// The organisation's AI assistants and prompt (#97). |
|
| … | 21 unchanged lines |
||
| 95 | 97 | } |
|
| 96 | 98 | let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 97 | 99 | let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 100 | + | let updates = super::runner_updates::views(app, &super::runner_updates::Reach::Org(org.name.clone())) |
|
| 101 | + | .await |
|
| 102 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 98 | 103 | let mut runners = Vec::new(); |
|
| 99 | 104 | for r in crate::db::runners::serving_owner(&app.db, &org.name).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 100 | 105 | let own = r.scope_owner == org.name; |
|
| … | 18 unchanged lines |
||
| 119 | 124 | notice, |
|
| 120 | 125 | error, |
|
| 121 | 126 | runners, |
|
| 127 | + | updates, |
|
| 122 | 128 | runner_token: None, |
|
| 123 | 129 | assistants, |
|
| 124 | 130 | variables, |
|
| … | 367 unchanged lines |
||
| 492 | 498 | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 493 | 499 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 494 | 500 | Ok(axum::Json(super::assistants::models(&app, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))?)) |
|
| 501 | + | } |
|
| 502 | + | ||
| 503 | + | /// `POST /orgs/{name}/settings/runners/updates/policy`: the organisation's, |
|
| 504 | + | /// one of its test groups' or one of its runners' update settings. |
|
| 505 | + | pub async fn runner_updates_policy( |
|
| 506 | + | State(app): State<Shared>, |
|
| 507 | + | viewer: Viewer, |
|
| 508 | + | Path(name): Path<String>, |
|
| 509 | + | Form(f): Form<super::runner_updates::PolicyForm>, |
|
| 510 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 511 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 512 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 513 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 514 | + | let reach = super::runner_updates::Reach::Org(org.name.clone()); |
|
| 515 | + | let outcome = super::runner_updates::apply_policy(&app, &me, &reach, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 516 | + | org_page_after(&app, shell, org, outcome).await |
|
| 517 | + | } |
|
| 518 | + | ||
| 519 | + | /// `POST /orgs/{name}/settings/runners/updates/groups`: a test group of the organisation's runners. |
|
| 520 | + | pub async fn runner_updates_group_new( |
|
| 521 | + | State(app): State<Shared>, |
|
| 522 | + | viewer: Viewer, |
|
| 523 | + | Path(name): Path<String>, |
|
| 524 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<super::runner_updates::GroupForm>, |
|
| 525 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 526 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 527 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 528 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 529 | + | let reach = super::runner_updates::Reach::Org(org.name.clone()); |
|
| 530 | + | let outcome = super::runner_updates::apply_create_group(&app, &me, &reach, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 531 | + | org_page_after(&app, shell, org, outcome).await |
|
| 532 | + | } |
|
| 533 | + | ||
| 534 | + | /// `POST /orgs/{name}/settings/runners/updates/groups/{id}`: who is in one of its test groups. |
|
| 535 | + | pub async fn runner_updates_group( |
|
| 536 | + | State(app): State<Shared>, |
|
| 537 | + | viewer: Viewer, |
|
| 538 | + | Path((name, id)): Path<(String, i64)>, |
|
| 539 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<super::runner_updates::GroupForm>, |
|
| 540 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 541 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 542 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 543 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 544 | + | let reach = super::runner_updates::Reach::Org(org.name.clone()); |
|
| 545 | + | let outcome = super::runner_updates::apply_save_group(&app, &me, &reach, id, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 546 | + | org_page_after(&app, shell, org, outcome).await |
|
| 547 | + | } |
|
| 548 | + | ||
| 549 | + | /// `POST /orgs/{name}/settings/runners/updates/groups/{id}/delete`. |
|
| 550 | + | pub async fn runner_updates_group_delete( |
|
| 551 | + | State(app): State<Shared>, |
|
| 552 | + | viewer: Viewer, |
|
| 553 | + | Path((name, id)): Path<(String, i64)>, |
|
| 554 | + | Form(f): Form<super::runner_updates::CsrfOnly>, |
|
| 555 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 556 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 557 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 558 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 559 | + | let reach = super::runner_updates::Reach::Org(org.name.clone()); |
|
| 560 | + | let outcome = super::runner_updates::apply_delete_group(&app, &me, &reach, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 561 | + | org_page_after(&app, shell, org, outcome).await |
|
| 562 | + | } |
|
| 563 | + | ||
| 564 | + | /// The settings page saying what a change did. |
|
| 565 | + | async fn org_page_after( |
|
| 566 | + | app: &Shared, |
|
| 567 | + | shell: Shell, |
|
| 568 | + | org: accounts::Organisation, |
|
| 569 | + | outcome: super::runner_updates::Outcome, |
|
| 570 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 571 | + | match outcome { |
|
| 572 | + | Ok(n) => org_page(app, shell, org, Some(n), None).await, |
|
| 573 | + | Err(why) => org_page(app, shell, org, None, Some(why)).await, |
|
| 574 | + | } |
|
| 495 | 575 | } |
modified crates/hub/src/web/runner_updates.rs +171 −94
| … | 127 unchanged lines |
||
| 128 | 128 | error: Option<String>, |
|
| 129 | 129 | } |
|
| 130 | 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(); |
|
| 131 | + | /// Who changes runner updates: the site's administrators anything; an |
|
| 132 | + | /// organisation's owners their organisation's setting, its test groups and |
|
| 133 | + | /// its runners (Andy: an organisation tries a build on some of its runners |
|
| 134 | + | /// before the rest). |
|
| 135 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 136 | + | pub enum Reach { |
|
| 137 | + | Site, |
|
| 138 | + | Org(String), |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | impl Reach { |
|
| 142 | + | fn holds_runner(&self, r: &runners::Runner) -> bool { |
|
| 143 | + | match self { |
|
| 144 | + | Reach::Site => true, |
|
| 145 | + | Reach::Org(o) => r.scope_owner == *o, |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | fn holds_group(&self, g: &Group) -> bool { |
|
| 150 | + | match self { |
|
| 151 | + | Reach::Site => true, |
|
| 152 | + | Reach::Org(o) => g.owner == *o, |
|
| 153 | + | } |
|
| 154 | + | } |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | /// What a page shows of runner updates, within `reach`. |
|
| 158 | + | pub struct UpdateViews { |
|
| 159 | + | pub site: PolicyView, |
|
| 160 | + | pub owners: Vec<PolicyView>, |
|
| 161 | + | pub groups: Vec<GroupView>, |
|
| 162 | + | pub runners: Vec<RunnerView>, |
|
| 163 | + | } |
|
| 164 | + | ||
| 165 | + | pub async fn views(app: &Shared, reach: &Reach) -> sqlx::Result<UpdateViews> { |
|
| 166 | + | let levels = Levels::load(&app.db).await?; |
|
| 167 | + | let all = runners::list(&app.db).await?; |
|
| 136 | 168 | let policy = |scope: &str, key: &str| { |
|
| 137 | 169 | levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy { |
|
| 138 | 170 | scope: scope.into(), |
|
| 139 | 171 | key: key.into(), |
|
| 140 | 172 | ..Policy::default() |
|
| 141 | 173 | }) |
|
| 174 | + | }; |
|
| 175 | + | let mut owners: Vec<PolicyView> = match reach { |
|
| 176 | + | Reach::Site => { |
|
| 177 | + | levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect() |
|
| 178 | + | } |
|
| 179 | + | Reach::Org(o) => vec![PolicyView::of(&policy("owner", o), format!("owner {o}"))], |
|
| 142 | 180 | }; |
|
| 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 | 181 | owners.sort_by(|a, b| a.key.cmp(&b.key)); |
|
| 146 | 182 | let groups = levels |
|
| 147 | 183 | .groups |
|
| 148 | 184 | .iter() |
|
| 185 | + | .filter(|g| reach.holds_group(g)) |
|
| 149 | 186 | .map(|g| GroupView { |
|
| 150 | 187 | g: g.clone(), |
|
| 151 | 188 | reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) }, |
|
| … | 3 unchanged lines |
||
| 155 | 192 | }) |
|
| 156 | 193 | .collect(); |
|
| 157 | 194 | let hour = time::OffsetDateTime::now_utc().hour(); |
|
| 158 | - | let mut views = vec![]; |
|
| 159 | - | for r in all { |
|
| 195 | + | let mut out = vec![]; |
|
| 196 | + | for r in all.into_iter().filter(|r| reach.holds_runner(r)) { |
|
| 160 | 197 | let effective = levels.resolve(&r); |
|
| 161 | - | let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await.map_err(e)?; |
|
| 162 | - | views.push(RunnerView { |
|
| 198 | + | let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await?; |
|
| 199 | + | out.push(RunnerView { |
|
| 163 | 200 | groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(), |
|
| 164 | 201 | policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)), |
|
| 165 | 202 | effective, |
|
| 166 | 203 | offer, |
|
| 167 | 204 | r, |
|
| 168 | 205 | }); |
|
| 206 | + | } |
|
| 207 | + | Ok(UpdateViews { site: PolicyView::of(&policy("site", ""), "the site".into()), owners, groups, runners: out }) |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | async fn page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<UpdatesPage>, Error> { |
|
| 211 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 212 | + | let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default(); |
|
| 213 | + | let v = views(app, &Reach::Site).await.map_err(e)?; |
|
| 214 | + | Ok(Page(UpdatesPage { shell, repo, site: v.site, owners: v.owners, groups: v.groups, runners: v.runners, notice, error })) |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | /// What a change did, to say on the page: `Ok` a notice, `Err` what was wrong. |
|
| 218 | + | pub type Outcome = Result<String, String>; |
|
| 219 | + | ||
| 220 | + | /// [`page`] saying what `outcome` did. |
|
| 221 | + | async fn page_after(app: &Shared, shell: Shell, outcome: Outcome) -> Result<Page<UpdatesPage>, Error> { |
|
| 222 | + | match outcome { |
|
| 223 | + | Ok(n) => page(app, shell, Some(n), None).await, |
|
| 224 | + | Err(why) => page(app, shell, None, Some(why)).await, |
|
| 169 | 225 | } |
|
| 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 | 226 | } |
|
| 181 | 227 | ||
| 182 | 228 | pub async fn show(State(app): State<Shared>, viewer: Viewer) -> Result<Page<UpdatesPage>, Error> { |
|
| … | 37 unchanged lines |
||
| 220 | 266 | #[derive(Deserialize)] |
|
| 221 | 267 | pub struct PolicyForm { |
|
| 222 | 268 | #[serde(rename = "_csrf")] |
|
| 223 | - | csrf_token: String, |
|
| 269 | + | pub(crate) csrf_token: String, |
|
| 224 | 270 | scope: String, |
|
| 225 | 271 | #[serde(default)] |
|
| 226 | 272 | key: String, |
|
| … | 7 unchanged lines |
||
| 234 | 280 | dry_run: String, |
|
| 235 | 281 | } |
|
| 236 | 282 | ||
| 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); |
|
| 283 | + | /// Set one level's settings, within `reach`. |
|
| 284 | + | pub async fn apply_policy(app: &Shared, me: &accounts::Account, reach: &Reach, f: &PolicyForm) -> sqlx::Result<Outcome> { |
|
| 243 | 285 | 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(); |
|
| 286 | + | let label = match (f.scope.as_str(), reach) { |
|
| 287 | + | ("site", Reach::Site) if key.is_empty() => "the site".to_string(), |
|
| 288 | + | ("owner", Reach::Org(o)) if key != *o => return Ok(Err("An organisation sets its own runners' updates only.".into())), |
|
| 289 | + | ("owner", _) => { |
|
| 290 | + | let known = accounts::account_by_name(&app.db, &key).await?.is_some() || accounts::org_by_name(&app.db, &key).await?.is_some(); |
|
| 249 | 291 | if !known { |
|
| 250 | - | return page(&app, shell, None, Some(format!("There is no account or organisation {key:?}."))).await; |
|
| 292 | + | return Ok(Err(format!("There is no account or organisation {key:?}."))); |
|
| 251 | 293 | } |
|
| 252 | 294 | format!("owner {key}") |
|
| 253 | 295 | } |
|
| 254 | - | "group" => match key.parse().ok().map(|id| rdb::group(&app.db, id)) { |
|
| 255 | - | Some(g) => match g.await.map_err(e)? { |
|
| 296 | + | ("group", _) => match key.parse().ok() { |
|
| 297 | + | Some(id) => match rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) { |
|
| 256 | 298 | Some(g) => format!("group {}", g.name), |
|
| 257 | - | None => return page(&app, shell, None, Some("That test group is gone.".into())).await, |
|
| 299 | + | None => return Ok(Err("That test group is gone.".into())), |
|
| 258 | 300 | }, |
|
| 259 | - | None => return page(&app, shell, None, Some("That test group is gone.".into())).await, |
|
| 301 | + | None => return Ok(Err("That test group is gone.".into())), |
|
| 260 | 302 | }, |
|
| 261 | - | "runner" => match key.parse().ok().map(|id| runners::get(&app.db, id)) { |
|
| 262 | - | Some(r) => match r.await.map_err(e)? { |
|
| 303 | + | ("runner", _) => match key.parse().ok() { |
|
| 304 | + | Some(id) => match runners::get(&app.db, id).await?.filter(|r| reach.holds_runner(r)) { |
|
| 263 | 305 | Some(r) => format!("runner {}", r.name), |
|
| 264 | - | None => return page(&app, shell, None, Some("That runner is gone.".into())).await, |
|
| 306 | + | None => return Ok(Err("That runner is gone.".into())), |
|
| 265 | 307 | }, |
|
| 266 | - | None => return page(&app, shell, None, Some("That runner is gone.".into())).await, |
|
| 308 | + | None => return Ok(Err("That runner is gone.".into())), |
|
| 267 | 309 | }, |
|
| 268 | - | _ => return page(&app, shell, None, Some("Choose the site, an owner, a test group or a runner.".into())).await, |
|
| 310 | + | _ => return Ok(Err("Choose the site, an owner, a test group or a runner.".into())), |
|
| 269 | 311 | }; |
|
| 270 | 312 | let tri = |v: &str| match v { |
|
| 271 | 313 | "on" => Some(true), |
|
| … | 2 unchanged lines |
||
| 274 | 316 | }; |
|
| 275 | 317 | let channel = f.channel.trim().to_string(); |
|
| 276 | 318 | 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; |
|
| 319 | + | return Ok(Err(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}."))); |
|
| 278 | 320 | } |
|
| 279 | 321 | let window = match ru::parse_window(&f.window) { |
|
| 280 | 322 | Ok(w) => w, |
|
| 281 | - | Err(why) => return page(&app, shell, None, Some(format!("{why}."))).await, |
|
| 323 | + | Err(why) => return Ok(Err(format!("{why}."))), |
|
| 282 | 324 | }; |
|
| 283 | 325 | let p = Policy { |
|
| 284 | 326 | scope: f.scope.clone(), |
|
| … | 4 unchanged lines |
||
| 289 | 331 | window_to: window.map(|w| w.1 as i64), |
|
| 290 | 332 | dry_run: tri(&f.dry_run), |
|
| 291 | 333 | }; |
|
| 292 | - | rdb::set_policy(&app.db, &p).await.map_err(e)?; |
|
| 334 | + | rdb::set_policy(&app.db, &p).await?; |
|
| 293 | 335 | 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 |
|
| 336 | + | accounts::audit(&app.db, Some(me), "runner.updates.policy", &label, &words).await.ok(); |
|
| 337 | + | Ok(Ok(format!("Saved for {label}: {words}."))) |
|
| 338 | + | } |
|
| 339 | + | ||
| 340 | + | /// `POST /admin/runners/updates/policy`: one level's settings. |
|
| 341 | + | pub async fn save_policy(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PolicyForm>) -> Result<Page<UpdatesPage>, Error> { |
|
| 342 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 343 | + | let me = require_admin(&viewer, &shell)?; |
|
| 344 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 345 | + | let outcome = apply_policy(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 346 | + | page_after(&app, shell, outcome).await |
|
| 296 | 347 | } |
|
| 297 | 348 | ||
| 298 | 349 | #[derive(Deserialize)] |
|
| 299 | 350 | pub struct GroupForm { |
|
| 300 | 351 | #[serde(rename = "_csrf")] |
|
| 301 | - | csrf_token: String, |
|
| 352 | + | pub(crate) csrf_token: String, |
|
| 302 | 353 | #[serde(default)] |
|
| 303 | 354 | owner: String, |
|
| 304 | 355 | #[serde(default)] |
|
| … | 18 unchanged lines |
||
| 323 | 374 | } |
|
| 324 | 375 | } |
|
| 325 | 376 | ||
| 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); |
|
| 377 | + | /// The chosen runners a form names that `owner`'s group may hold. |
|
| 378 | + | async fn chosen_in_reach(app: &Shared, owner: &str, f: &GroupForm) -> sqlx::Result<Vec<i64>> { |
|
| 379 | + | let all = runners::list(&app.db).await?; |
|
| 380 | + | Ok(f.runner.iter().copied().filter(|id| all.iter().any(|r| r.id == *id && (owner.is_empty() || r.scope_owner == owner))).collect()) |
|
| 381 | + | } |
|
| 382 | + | ||
| 383 | + | /// Make a test group, within `reach` (an organisation's are its own). |
|
| 384 | + | pub async fn apply_create_group(app: &Shared, me: &accounts::Account, reach: &Reach, f: &GroupForm) -> sqlx::Result<Outcome> { |
|
| 336 | 385 | let name = f.name.trim().to_string(); |
|
| 337 | 386 | 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; |
|
| 387 | + | return Ok(Err("A test group needs a name of up to 60 characters.".into())); |
|
| 339 | 388 | } |
|
| 340 | - | let owner = f.owner.trim().to_string(); |
|
| 389 | + | let owner = match reach { |
|
| 390 | + | Reach::Site => f.owner.trim().to_string(), |
|
| 391 | + | Reach::Org(o) => o.clone(), |
|
| 392 | + | }; |
|
| 341 | 393 | 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() |
|
| 394 | + | && accounts::account_by_name(&app.db, &owner).await?.is_none() |
|
| 395 | + | && accounts::org_by_name(&app.db, &owner).await?.is_none() |
|
| 344 | 396 | { |
|
| 345 | - | return page(&app, shell, None, Some(format!("There is no account or organisation {owner:?}."))).await; |
|
| 397 | + | return Ok(Err(format!("There is no account or organisation {owner:?}."))); |
|
| 346 | 398 | } |
|
| 347 | - | let percent = match percent_of(&f) { |
|
| 399 | + | let percent = match percent_of(f) { |
|
| 348 | 400 | Ok(p) => p, |
|
| 349 | - | Err(why) => return page(&app, shell, None, Some(why)).await, |
|
| 401 | + | Err(why) => return Ok(Err(why)), |
|
| 350 | 402 | }; |
|
| 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; |
|
| 403 | + | if rdb::groups(&app.db).await?.iter().any(|g| g.owner == owner && g.name == name) { |
|
| 404 | + | return Ok(Err(format!("There is already a test group {name:?} for those runners."))); |
|
| 353 | 405 | } |
|
| 354 | - | let g = rdb::create_group(&app.db, &owner, &name, percent).await.map_err(e)?; |
|
| 406 | + | let chosen = chosen_in_reach(app, &owner, f).await?; |
|
| 407 | + | let g = rdb::create_group(&app.db, &owner, &name, percent).await?; |
|
| 355 | 408 | if percent.is_none() { |
|
| 356 | - | rdb::set_members(&app.db, g.id, &f.runner).await.map_err(e)?; |
|
| 409 | + | rdb::set_members(&app.db, g.id, &chosen).await?; |
|
| 357 | 410 | } |
|
| 358 | 411 | let what = match percent { |
|
| 359 | 412 | 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()), |
|
| 413 | + | None => format!("{} chosen runners", chosen.len()), |
|
| 361 | 414 | }; |
|
| 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 |
|
| 415 | + | accounts::audit(&app.db, Some(me), "runner.updates.group", &name, &format!("created: {what}")).await.ok(); |
|
| 416 | + | Ok(Ok(format!("Test group {name} made: {what}. Give it its settings below."))) |
|
| 364 | 417 | } |
|
| 365 | 418 | ||
| 366 | - | /// `POST /admin/runners/updates/groups/{id}`: who is in a test group. |
|
| 367 | - | pub async fn save_group( |
|
| 419 | + | /// `POST /admin/runners/updates/groups`: a new test group. |
|
| 420 | + | pub async fn create_group( |
|
| 368 | 421 | State(app): State<Shared>, |
|
| 369 | 422 | viewer: Viewer, |
|
| 370 | - | Path(id): Path<i64>, |
|
| 371 | 423 | axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>, |
|
| 372 | 424 | ) -> Result<Page<UpdatesPage>, Error> { |
|
| 373 | 425 | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 374 | 426 | let me = require_admin(&viewer, &shell)?; |
|
| 375 | 427 | 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; |
|
| 428 | + | let outcome = apply_create_group(&app, &me, &Reach::Site, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 429 | + | page_after(&app, shell, outcome).await |
|
| 430 | + | } |
|
| 431 | + | ||
| 432 | + | /// Set who is in test group `id`, within `reach`. |
|
| 433 | + | pub async fn apply_save_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64, f: &GroupForm) -> sqlx::Result<Outcome> { |
|
| 434 | + | let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else { |
|
| 435 | + | return Ok(Err("That test group is gone.".into())); |
|
| 379 | 436 | }; |
|
| 380 | - | let percent = match percent_of(&f) { |
|
| 437 | + | let percent = match percent_of(f) { |
|
| 381 | 438 | Ok(p) => p, |
|
| 382 | - | Err(why) => return page(&app, shell, None, Some(why)).await, |
|
| 439 | + | Err(why) => return Ok(Err(why)), |
|
| 383 | 440 | }; |
|
| 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)?; |
|
| 441 | + | let chosen = if percent.is_some() { vec![] } else { chosen_in_reach(app, &g.owner, f).await? }; |
|
| 442 | + | rdb::set_percent(&app.db, id, percent).await?; |
|
| 443 | + | rdb::set_members(&app.db, id, &chosen).await?; |
|
| 386 | 444 | let what = match percent { |
|
| 387 | 445 | Some(p) => format!("{p}% of its runners"), |
|
| 388 | - | None => format!("{} chosen runners", f.runner.len()), |
|
| 446 | + | None => format!("{} chosen runners", chosen.len()), |
|
| 389 | 447 | }; |
|
| 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 |
|
| 448 | + | accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, &what).await.ok(); |
|
| 449 | + | Ok(Ok(format!("Test group {} is now {what}.", g.name))) |
|
| 392 | 450 | } |
|
| 393 | 451 | ||
| 452 | + | /// `POST /admin/runners/updates/groups/{id}`: who is in a test group. |
|
| 453 | + | pub async fn save_group( |
|
| 454 | + | State(app): State<Shared>, |
|
| 455 | + | viewer: Viewer, |
|
| 456 | + | Path(id): Path<i64>, |
|
| 457 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>, |
|
| 458 | + | ) -> Result<Page<UpdatesPage>, Error> { |
|
| 459 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 460 | + | let me = require_admin(&viewer, &shell)?; |
|
| 461 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 462 | + | let outcome = apply_save_group(&app, &me, &Reach::Site, id, &f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 463 | + | page_after(&app, shell, outcome).await |
|
| 464 | + | } |
|
| 465 | + | ||
| 394 | 466 | #[derive(Deserialize)] |
|
| 395 | 467 | pub struct CsrfOnly { |
|
| 396 | 468 | #[serde(rename = "_csrf")] |
|
| 397 | - | csrf_token: String, |
|
| 469 | + | pub(crate) csrf_token: String, |
|
| 398 | 470 | } |
|
| 399 | 471 | ||
| 472 | + | /// Remove test group `id`, within `reach`. |
|
| 473 | + | pub async fn apply_delete_group(app: &Shared, me: &accounts::Account, reach: &Reach, id: i64) -> sqlx::Result<Outcome> { |
|
| 474 | + | let Some(g) = rdb::group(&app.db, id).await?.filter(|g| reach.holds_group(g)) else { |
|
| 475 | + | return Ok(Err("That test group is gone.".into())); |
|
| 476 | + | }; |
|
| 477 | + | rdb::delete_group(&app.db, id).await?; |
|
| 478 | + | accounts::audit(&app.db, Some(me), "runner.updates.group", &g.name, "removed").await.ok(); |
|
| 479 | + | Ok(Ok(format!("Test group {} removed; its runners follow the levels above it.", g.name))) |
|
| 480 | + | } |
|
| 481 | + | ||
| 400 | 482 | /// `POST /admin/runners/updates/groups/{id}/delete`. |
|
| 401 | 483 | pub async fn delete_group( |
|
| 402 | 484 | State(app): State<Shared>, |
|
| … | 4 unchanged lines |
||
| 407 | 489 | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 408 | 490 | let me = require_admin(&viewer, &shell)?; |
|
| 409 | 491 | 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 |
|
| 492 | + | let outcome = apply_delete_group(&app, &me, &Reach::Site, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 493 | + | page_after(&app, shell, outcome).await |
|
| 417 | 494 | } |
modified crates/hub/tests/flow.rs +100 −0
| … | 6733 unchanged lines |
||
| 6734 | 6734 | let placed = db::flow::job(&h.app.db, id).await.unwrap().unwrap().placed; |
|
| 6735 | 6735 | assert!(placed.starts_with("idle") && !placed.contains("image"), "{placed}"); |
|
| 6736 | 6736 | } |
|
| 6737 | + | ||
| 6738 | + | /// oxygenhub#322: an organisation's owners set their own runners' updates, |
|
| 6739 | + | /// test groups of their own runners, and nothing beyond them. |
|
| 6740 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 6741 | + | async fn an_organisation_sets_its_own_runners_updates() { |
|
| 6742 | + | use oxy_core::identity::SecretKey; |
|
| 6743 | + | let h = hub().await; |
|
| 6744 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 6745 | + | .oneshot( |
|
| 6746 | + | Request::post("/register") |
|
| 6747 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 6748 | + | .body(Body::from("username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 6749 | + | .unwrap(), |
|
| 6750 | + | ) |
|
| 6751 | + | .await |
|
| 6752 | + | .unwrap(); |
|
| 6753 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 6754 | + | let web = |method: &'static str, path: String, body: Option<String>| { |
|
| 6755 | + | let (app, cookie) = (h.app.clone(), cookie.clone()); |
|
| 6756 | + | async move { |
|
| 6757 | + | let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie); |
|
| 6758 | + | if body.is_some() { |
|
| 6759 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 6760 | + | } |
|
| 6761 | + | let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap(); |
|
| 6762 | + | let st = res.status(); |
|
| 6763 | + | (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
|
| 6764 | + | } |
|
| 6765 | + | }; |
|
| 6766 | + | let csrf_of = |page: &str| { |
|
| 6767 | + | let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 6768 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 6769 | + | }; |
|
| 6770 | + | let (_, page) = web("GET", "/orgs/new".into(), None).await; |
|
| 6771 | + | let csrf = csrf_of(&page); |
|
| 6772 | + | web("POST", "/orgs/new".into(), Some(format!("_csrf={csrf}&name=acme&display_name=Acme"))).await; |
|
| 6773 | + | // Two of acme's runners, and the site's (hub.toml's, from its first claim). |
|
| 6774 | + | let mut creds = vec![]; |
|
| 6775 | + | for name in ["a1", "a2"] { |
|
| 6776 | + | let (_, page) = web("POST", "/orgs/acme/settings/runners/token".into(), Some(format!("_csrf={csrf}"))).await; |
|
| 6777 | + | let i = page.find("--token ").unwrap() + "--token ".len(); |
|
| 6778 | + | let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect(); |
|
| 6779 | + | let identity = SecretKey::generate().identity(name, "", 1); |
|
| 6780 | + | let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0", "max_jobs": 1 }); |
|
| 6781 | + | let reg = serde_json::json!({ "token": token, "name": name, "identity": hex::encode(identity.encode()), "info": info }); |
|
| 6782 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await; |
|
| 6783 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 6784 | + | creds.push(serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string()); |
|
| 6785 | + | } |
|
| 6786 | + | claim(&h.app).await; |
|
| 6787 | + | let all = db::runners::list(&h.app.db).await.unwrap(); |
|
| 6788 | + | let id = |n: &str| all.iter().find(|r| r.name == n).unwrap().id; |
|
| 6789 | + | let set = |form: String| web("POST", "/orgs/acme/settings/runners/updates/policy".into(), Some(format!("_csrf={csrf}&{form}"))); |
|
| 6790 | + | ||
| 6791 | + | let (_, page) = set("scope=owner&key=acme&updates=on".into()).await; |
|
| 6792 | + | assert!(page.contains("Saved for owner acme: updates on."), "{page}"); |
|
| 6793 | + | let (_, page) = set("scope=site&updates=on".into()).await; |
|
| 6794 | + | assert!(page.contains("Choose the site, an owner, a test group or a runner."), "not the site's: {page}"); |
|
| 6795 | + | let (_, page) = set("scope=owner&key=andy&updates=on".into()).await; |
|
| 6796 | + | assert!(page.contains("An organisation sets its own runners"), "{page}"); |
|
| 6797 | + | let (_, page) = set(format!("scope=runner&key={}&updates=on", id("test-1"))).await; |
|
| 6798 | + | assert!(page.contains("That runner is gone."), "the site's runner is not acme's: {page}"); |
|
| 6799 | + | ||
| 6800 | + | // A test group of acme's runners; a runner not acme's is left out. |
|
| 6801 | + | let (_, page) = web( |
|
| 6802 | + | "POST", |
|
| 6803 | + | "/orgs/acme/settings/runners/updates/groups".into(), |
|
| 6804 | + | Some(format!("_csrf={csrf}&name=canary&owner=&mode=chosen&runner={}&runner={}", id("a1"), id("test-1"))), |
|
| 6805 | + | ) |
|
| 6806 | + | .await; |
|
| 6807 | + | assert!(page.contains("Test group canary made: 1 chosen runners"), "{page}"); |
|
| 6808 | + | let canary = db::runner_updates::groups(&h.app.db).await.unwrap().remove(0); |
|
| 6809 | + | assert_eq!(canary.owner, "acme", "an organisation's groups are its own"); |
|
| 6810 | + | let (_, page) = set(format!("scope=group&key={}&channel=canary", canary.id)).await; |
|
| 6811 | + | assert!(page.contains("Saved for group canary: channel canary."), "{page}"); |
|
| 6812 | + | let (_, page) = set(format!("scope=runner&key={}&updates=off", id("a2"))).await; |
|
| 6813 | + | assert!(page.contains("Saved for runner a2: updates off."), "{page}"); |
|
| 6814 | + | ||
| 6815 | + | // A site group is not the organisation's to change. |
|
| 6816 | + | let site = db::runner_updates::create_group(&h.app.db, "", "everyone", Some(100)).await.unwrap(); |
|
| 6817 | + | let (_, page) = |
|
| 6818 | + | web("POST", format!("/orgs/acme/settings/runners/updates/groups/{}/delete", site.id), Some(format!("_csrf={csrf}"))).await; |
|
| 6819 | + | assert!(page.contains("That test group is gone."), "{page}"); |
|
| 6820 | + | assert!(db::runner_updates::group(&h.app.db, site.id).await.unwrap().is_some()); |
|
| 6821 | + | ||
| 6822 | + | // The page shows it; each runner gets its policy. |
|
| 6823 | + | let (_, page) = web("GET", "/orgs/acme/settings".into(), None).await; |
|
| 6824 | + | assert!(page.contains("id=\"runner-updates\"") && page.contains(">canary <small"), "{page}"); |
|
| 6825 | + | assert!(page.contains("updates on (owner acme)") && page.contains("updates off (runner)"), "{page}"); |
|
| 6826 | + | let ask = |cred: String| { |
|
| 6827 | + | let app = h.app.clone(); |
|
| 6828 | + | async move { |
|
| 6829 | + | let (_, body) = call(&app, "GET", "/api/v1/flow/runner/update", Some(&cred), None).await; |
|
| 6830 | + | serde_json::from_str::<flow_core::api::UpdateAnswer>(&body).unwrap().policy |
|
| 6831 | + | } |
|
| 6832 | + | }; |
|
| 6833 | + | let a1 = ask(creds[0].clone()).await; |
|
| 6834 | + | assert!(a1.updates && a1.channel == "canary" && a1.from[..2] == ["owner acme".to_string(), "group canary".to_string()], "{a1:?}"); |
|
| 6835 | + | assert!(!ask(creds[1].clone()).await.updates, "a2's own setting wins"); |
|
| 6836 | + | } |
modified templates/admin_runner_updates.html +7 −12
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "admin_nav.html" as an %} |
|
| 3 | - | {# One level's update settings (oxygenhub#322); `top` is the site, which inherits from nothing. #} |
|
| 4 | - | {% macro policy_fields(p, top) %} |
|
| 5 | - | <label class="pol">Updates <select name="updates">{% if !top %}<option value="inherit"{% if p.updates == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.updates == "off" || (top && p.updates == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.updates == "on" %} selected{% endif %}>on</option></select></label> |
|
| 6 | - | <label class="pol">Channel <input type="text" name="channel" value="{{ p.channel }}" placeholder="{% if top %}stable{% else %}inherit{% endif %}" size="9" list="update-channels"></label> |
|
| 7 | - | <label class="pol">Window, UTC <input type="text" name="window" value="{{ p.window }}" placeholder="{% if top %}any time{% else %}inherit{% endif %}" size="7" title="Hours in which updates may start, from-to, e.g. 02-05"></label> |
|
| 8 | - | <label class="pol">Dry run <select name="dry_run">{% if !top %}<option value="inherit"{% if p.dry_run == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.dry_run == "off" || (top && p.dry_run == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.dry_run == "on" %} selected{% endif %}>on</option></select></label> |
|
| 9 | - | {% endmacro %} |
|
| 3 | + | {% import "runner_update_fields.html" as ruf %} |
|
| 4 | + | ||
| 10 | 5 | {% block title %}Runner updates · Administration{% endblock %} |
|
| 11 | 6 | {% block content %} |
|
| 12 | 7 | <div class="head"><h1>Runners <small><a href="/admin">Administration</a></small></h1></div> |
|
| … | 21 unchanged lines |
||
| 34 | 29 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="site"> |
|
| 35 | 30 | <h2>The site</h2> |
|
| 36 | 31 | <p class="small mute">What every runner does unless a nearer level says otherwise: {{ site.summary() }}.</p> |
|
| 37 | - | <div class="pol-row">{% call policy_fields(site, true) %}</div> |
|
| 32 | + | <div class="pol-row">{% call ruf::policy_fields(site, true) %}</div> |
|
| 38 | 33 | <button type="submit">Save</button> |
|
| 39 | 34 | </form> |
|
| 40 | 35 | <section class="panel" id="owners"> |
|
| … | 3 unchanged lines |
||
| 44 | 39 | {% for o in owners %} |
|
| 45 | 40 | <form class="pol-form" method="post" action="/admin/runners/updates/policy"> |
|
| 46 | 41 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="owner"><input type="hidden" name="key" value="{{ o.key }}"> |
|
| 47 | - | <strong>{{ o.key }}</strong>{% call policy_fields(o, false) %}<button type="submit">Save</button> |
|
| 42 | + | <strong>{{ o.key }}</strong>{% call ruf::policy_fields(o, false) %}<button type="submit">Save</button> |
|
| 48 | 43 | </form> |
|
| 49 | 44 | {% endfor %} |
|
| 50 | 45 | <form class="pol-form" method="post" action="/admin/runners/updates/policy"> |
|
| 51 | 46 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="owner"> |
|
| 52 | - | <label class="pol">Owner <input type="text" name="key" size="12" placeholder="account or org" required></label>{% call policy_fields(site, false) %}<button type="submit">Add</button> |
|
| 47 | + | <label class="pol">Owner <input type="text" name="key" size="12" placeholder="account or org" required></label>{% call ruf::policy_fields(site, false) %}<button type="submit">Add</button> |
|
| 53 | 48 | </form> |
|
| 54 | 49 | </section> |
|
| 55 | 50 | <section class="panel" id="groups"> |
|
| … | 6 unchanged lines |
||
| 62 | 57 | <p class="small">{% if gv.members.is_empty() %}<span class="mute">No runners in it.</span>{% else %}{% for m in gv.members %}<span class="lbl">{{ m }}</span> {% endfor %}{% endif %}</p> |
|
| 63 | 58 | <form class="pol-form" method="post" action="/admin/runners/updates/policy"> |
|
| 64 | 59 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}"> |
|
| 65 | - | {% call policy_fields(gv.policy, false) %}<button type="submit">Save settings</button> |
|
| 60 | + | {% call ruf::policy_fields(gv.policy, false) %}<button type="submit">Save settings</button> |
|
| 66 | 61 | </form> |
|
| 67 | 62 | <details><summary class="small">Who is in it</summary> |
|
| 68 | 63 | <form method="post" action="/admin/runners/updates/groups/{{ g.id }}"> |
|
| … | 35 unchanged lines |
||
| 104 | 99 | <td class="small">{% if r.update_note.is_empty() %}<span class="mute">nothing yet</span>{% else %}{{ r.update_note }}{% if let Some(t) = r.update_ms %} <time class="mute" title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}{% endif %}</td> |
|
| 105 | 100 | <td><form class="pol-form tiny" method="post" action="/admin/runners/updates/policy"> |
|
| 106 | 101 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="runner"><input type="hidden" name="key" value="{{ r.id }}"> |
|
| 107 | - | {% call policy_fields(rv.policy, false) %}<button type="submit">set</button></form></td> |
|
| 102 | + | {% call ruf::policy_fields(rv.policy, false) %}<button type="submit">set</button></form></td> |
|
| 108 | 103 | </tr> |
|
| 109 | 104 | {% endfor %} |
|
| 110 | 105 | </table></div> |
|
| … | 5 unchanged lines |
modified templates/org_settings.html +54 −0
| … | 2 unchanged lines |
||
| 3 | 3 | {% import "assistants_panel.html" as ai %} |
|
| 4 | 4 | {% import "variables_panel.html" as va %} |
|
| 5 | 5 | {% import "secrets_panel.html" as se %} |
|
| 6 | + | {% import "runner_update_fields.html" as ruf %} |
|
| 6 | 7 | {% block title %}{{ org.name }} settings{% endblock %} |
|
| 7 | 8 | {% block content %} |
|
| 8 | 9 | <div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div> |
|
| … | 82 unchanged lines |
||
| 91 | 92 | </table></div>{% endif %} |
|
| 92 | 93 | <form method="post" action="/orgs/{{ org.name }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for the organisation</button></form> |
|
| 93 | 94 | </section> |
|
| 95 | + | <section class="panel" id="runner-updates"> |
|
| 96 | + | <h2>Runner updates</h2> |
|
| 97 | + | <p class="small">The organisation's own runners can update themselves: off unless chosen. Try a build on a test group first (runners you pick, or a percentage of them, the same machines every time), then turn updates on for the rest. A runner's own setting wins over a group's, a group's over the organisation's, the organisation's over the site's.</p> |
|
| 98 | + | {% let ou = updates.owners[0] %} |
|
| 99 | + | <form class="pol-form" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/policy"> |
|
| 100 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="owner"><input type="hidden" name="key" value="{{ org.name }}"> |
|
| 101 | + | <strong>The organisation</strong>{% call ruf::policy_fields(ou, false) %}<button type="submit">Save</button> |
|
| 102 | + | </form> |
|
| 103 | + | <h3>Test groups</h3> |
|
| 104 | + | {% if updates.groups.is_empty() %}<p class="mute small">None yet.</p>{% endif %} |
|
| 105 | + | {% for gv in updates.groups %}{% let g = gv.g %} |
|
| 106 | + | <div class="card wide update-group"> |
|
| 107 | + | <h3>{{ g.name }} <small class="mute">{% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now</small></h3> |
|
| 108 | + | <p class="small">{% for m in gv.members %}<span class="lbl">{{ m }}</span> {% endfor %}</p> |
|
| 109 | + | <form class="pol-form" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/policy"> |
|
| 110 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}"> |
|
| 111 | + | {% call ruf::policy_fields(gv.policy, false) %}<button type="submit">Save settings</button> |
|
| 112 | + | </form> |
|
| 113 | + | <details><summary class="small">Who is in it</summary> |
|
| 114 | + | <form method="post" action="/orgs/{{ org.name }}/settings/runners/updates/groups/{{ g.id }}"> |
|
| 115 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 116 | + | <label class="pol"><input type="radio" name="mode" value="percent"{% if g.percent.is_some() %} checked{% endif %}> a percentage: <input type="number" name="percent" min="1" max="100" value="{% if let Some(p) = g.percent %}{{ p }}{% endif %}">%</label> |
|
| 117 | + | <label class="pol"><input type="radio" name="mode" value="chosen"{% if g.percent.is_none() %} checked{% endif %}> these runners:</label> |
|
| 118 | + | <div class="pol-row">{% for rv in updates.runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"{% if gv.chosen.contains(rv.r.id) %} checked{% endif %}> {{ rv.r.name }}</label>{% endfor %}</div> |
|
| 119 | + | <button type="submit">Save members</button> |
|
| 120 | + | </form> |
|
| 121 | + | <form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/groups/{{ g.id }}/delete" data-confirm="Remove the test group {{ g.name }}?"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove group</button></form> |
|
| 122 | + | </details> |
|
| 123 | + | </div> |
|
| 124 | + | {% endfor %} |
|
| 125 | + | <form class="card wide" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/groups"> |
|
| 126 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 127 | + | <h3>New test group</h3> |
|
| 128 | + | <label class="pol">Name <input type="text" name="name" size="14" required placeholder="canary"></label> |
|
| 129 | + | <label class="pol"><input type="radio" name="mode" value="percent"> a percentage of the organisation's runners: <input type="number" name="percent" min="1" max="100">%</label> |
|
| 130 | + | <label class="pol"><input type="radio" name="mode" value="chosen" checked> these runners:</label> |
|
| 131 | + | <div class="pol-row">{% for rv in updates.runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"> {{ rv.r.name }}</label>{% endfor %}</div> |
|
| 132 | + | <button type="submit">Make group</button> |
|
| 133 | + | </form> |
|
| 134 | + | {% if !updates.runners.is_empty() %} |
|
| 135 | + | <h3>The organisation's runners</h3> |
|
| 136 | + | <div class="table"><table> |
|
| 137 | + | <tr><th>Runner</th><th>Runs</th><th>Its policy</th><th>Now</th><th>Last said</th><th>Its own settings</th></tr> |
|
| 138 | + | {% for rv in updates.runners %}{% let r = rv.r %} |
|
| 139 | + | <tr><td><strong>{{ r.name }}</strong>{% for g in rv.groups %} <span class="lbl">{{ g }}</span>{% endfor %}</td> |
|
| 140 | + | <td class="small">{% if r.version.is_empty() %}<span class="mute">unknown</span>{% else %}flow {{ r.version }}{% endif %}</td> |
|
| 141 | + | <td class="small">{{ rv.effective_words() }}</td><td class="small">{{ rv.offer }}</td> |
|
| 142 | + | <td class="small">{% if r.update_note.is_empty() %}<span class="mute">nothing yet</span>{% else %}{{ r.update_note }}{% endif %}</td> |
|
| 143 | + | <td><form class="pol-form tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/updates/policy"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="runner"><input type="hidden" name="key" value="{{ r.id }}">{% call ruf::policy_fields(rv.policy, false) %}<button type="submit">set</button></form></td></tr> |
|
| 144 | + | {% endfor %} |
|
| 145 | + | </table></div> |
|
| 146 | + | {% endif %} |
|
| 147 | + | </section> |
|
| 94 | 148 | {% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %} |
|
| 95 | 149 | {% call va::panel("/orgs/" ~ org.name ~ "/settings", variables, shell.csrf, "Jobs of every repository of the organisation get these, beside the site's and each repository's own.") %} |
|
| 96 | 150 | {% call se::panel("/orgs/" ~ org.name ~ "/settings", secrets, shell.csrf, "Jobs of every repository of the organisation may use these, beside the site's and each repository's own.") %} |
|
| … | 4 unchanged lines |