Settings → Runners for repository admins and organisation owners (refs #41, part 2b) 7974adb0be66
A repository's admins and an organisation's owners see the runners that may take its jobs and what each serves, make a registration token for a runner of their own scope, and remove such runners while they run nothing. Site runners stay the site administrators'.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 02:48 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
7974adb0be66458030ee0f2094ba198f12cca1d1765f5c0e03d6816bfcedc1e1- Depends on
- 18e381ea88c2 19bb31ff1f5c 3f4b0ea6eabf 42fa95540468 74be97962ed8 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e 9756c67d3aec a160b98fb922 b6b7e4cf6b24 d23464c0ac60
- Depended on by
- dc5c64296b0a 5714347a6233 c7067c7bda56 8dcaeeb848c6 a7aa76ac52d7 76f93eaefe45 c8684d44d549 5d90e0e045e2 1870e4fa9808 7edaff01d506 8ddebaa7e856 1f78655a0466 4ac01a732552 2a83f5303458 e71846aa07b9 c4c87f481d05
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 16 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified crates/hub/src/db/runners.rs +22 −0
| … | 169 unchanged lines |
||
| 170 | 170 | Ok(()) |
|
| 171 | 171 | } |
|
| 172 | 172 | ||
| 173 | + | /// The runners that may take a repository's jobs: the site's, its owner's |
|
| 174 | + | /// and its own (oxygenhub#41). |
|
| 175 | + | pub async fn serving(pool: &Pool, owner: &str, repo_id: i64) -> sqlx::Result<Vec<Runner>> { |
|
| 176 | + | sqlx::query_as( |
|
| 177 | + | "SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) \ |
|
| 178 | + | OR (scope_owner = ? AND scope_repo_id IS NULL) OR scope_repo_id = ? ORDER BY name", |
|
| 179 | + | ) |
|
| 180 | + | .bind(owner) |
|
| 181 | + | .bind(repo_id) |
|
| 182 | + | .fetch_all(pool) |
|
| 183 | + | .await |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// The runners that serve an owner: the site's, and those scoped to the |
|
| 187 | + | /// owner or one of its repositories. |
|
| 188 | + | pub async fn serving_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Runner>> { |
|
| 189 | + | sqlx::query_as("SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) OR scope_owner = ? ORDER BY name") |
|
| 190 | + | .bind(owner) |
|
| 191 | + | .fetch_all(pool) |
|
| 192 | + | .await |
|
| 193 | + | } |
|
| 194 | + | ||
| 173 | 195 | /// Change the repositories a runner serves. |
|
| 174 | 196 | pub async fn set_scope(pool: &Pool, id: i64, scope: &Scope) -> sqlx::Result<()> { |
|
| 175 | 197 | sqlx::query("UPDATE flow_runners SET scope_owner = ?, scope_repo_id = ? WHERE id = ?") |
|
| … | 112 unchanged lines |
modified crates/hub/src/web/mod.rs +4 −0
| … | 76 unchanged lines |
||
| 77 | 77 | .route("/orgs/{name}/settings/members", post(orgs::member)) |
|
| 78 | 78 | .route("/orgs/{name}/settings/teams", post(orgs::team)) |
|
| 79 | 79 | .route("/orgs/{name}/settings/hooks", post(orgs::hooks)) |
|
| 80 | + | .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token)) |
|
| 81 | + | .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove)) |
|
| 80 | 82 | .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries)) |
|
| 81 | 83 | .route("/orgs/{name}/settings/hooks/{id}/redeliver/{delivery}", post(orgs::redeliver)) |
|
| 82 | 84 | .route("/orgs/{name}/settings/avatar", post(orgs::avatar).layer(DefaultBodyLimit::max(avatar::MAX_BYTES + (64 << 10)))) |
|
| … | 97 unchanged lines |
||
| 180 | 182 | .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack)) |
|
| 181 | 183 | .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror)) |
|
| 182 | 184 | .route("/{owner}/{name}/settings/flow", post(repo_settings::flow)) |
|
| 185 | + | .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token)) |
|
| 186 | + | .route("/{owner}/{name}/settings/runners/{id}/remove", post(repo_settings::runner_remove)) |
|
| 183 | 187 | .route("/{owner}/{name}/issues", get(issues::list)) |
|
| 184 | 188 | .route("/{owner}/{name}/issues/new", get(issues::new_form).post(issues::create)) |
|
| 185 | 189 | .route("/{owner}/{name}/issue/{number}", get(issues::show)) |
|
| … | 413 unchanged lines |
modified crates/hub/src/web/orgs.rs +62 −1
| … | 63 unchanged lines |
||
| 64 | 64 | hooks: Vec<super::webhooks::HookRow>, |
|
| 65 | 65 | notice: Option<String>, |
|
| 66 | 66 | error: Option<String>, |
|
| 67 | + | /// Runners serving the organisation: (runner, what it serves, whether |
|
| 68 | + | /// it is the organisation's own). |
|
| 69 | + | runners: Vec<(crate::db::runners::Runner, String, bool)>, |
|
| 70 | + | /// A registration token just made, shown once. |
|
| 71 | + | runner_token: Option<String>, |
|
| 67 | 72 | } |
|
| 68 | 73 | ||
| 69 | 74 | async fn org_page( |
|
| … | 11 unchanged lines |
||
| 81 | 86 | } |
|
| 82 | 87 | let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 83 | 88 | let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 84 | - | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error })) |
|
| 89 | + | let mut runners = Vec::new(); |
|
| 90 | + | for r in crate::db::runners::serving_owner(&app.db, &org.name).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 91 | + | let own = r.scope_owner == org.name; |
|
| 92 | + | let serves = match r.scope_repo_id { |
|
| 93 | + | Some(id) => crate::db::repos::get_by_id(&app.db, id).await.ok().flatten().map(|p| p.slug()).unwrap_or_default(), |
|
| 94 | + | None if own => "the organisation's repositories".into(), |
|
| 95 | + | None => "the whole site".into(), |
|
| 96 | + | }; |
|
| 97 | + | runners.push((r, serves, own)); |
|
| 98 | + | } |
|
| 99 | + | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None })) |
|
| 85 | 100 | } |
|
| 86 | 101 | ||
| 87 | 102 | async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> { |
|
| … | 208 unchanged lines |
||
| 296 | 311 | let scope = super::webhooks::Scope::Org { id: org.id, name: org.name }; |
|
| 297 | 312 | super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 298 | 313 | super::webhooks::deliveries_page(&app, shell, &scope, None, id).await |
|
| 314 | + | } |
|
| 315 | + | ||
| 316 | + | /// `POST /orgs/{name}/settings/runners/token`: a one-time registration |
|
| 317 | + | /// token for a runner that serves the organisation's repositories |
|
| 318 | + | /// (oxygenhub#41). |
|
| 319 | + | pub async fn runner_token( |
|
| 320 | + | State(app): State<Shared>, |
|
| 321 | + | viewer: Viewer, |
|
| 322 | + | Path(name): Path<String>, |
|
| 323 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 324 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 325 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 326 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 327 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 328 | + | let token = format!("flowreg_{}", crate::auth::random_hex(24)); |
|
| 329 | + | let scope = crate::db::runners::Scope { owner: org.name.clone(), repo_id: None }; |
|
| 330 | + | crate::db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope) |
|
| 331 | + | .await |
|
| 332 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 333 | + | accounts::audit(&app.db, Some(&me), "flow.runner.token", &org.name, "registration token for the organisation").await.ok(); |
|
| 334 | + | let mut page = org_page(&app, shell, org, Some("Copy the token now; it is not shown again.".into()), None).await?; |
|
| 335 | + | page.0.runner_token = Some(token); |
|
| 336 | + | Ok(page) |
|
| 337 | + | } |
|
| 338 | + | ||
| 339 | + | /// `POST /orgs/{name}/settings/runners/{id}/remove`: remove a runner scoped |
|
| 340 | + | /// to the organisation or one of its repositories, when it runs nothing. |
|
| 341 | + | pub async fn runner_remove( |
|
| 342 | + | State(app): State<Shared>, |
|
| 343 | + | viewer: Viewer, |
|
| 344 | + | Path((name, id)): Path<(String, i64)>, |
|
| 345 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 346 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 347 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 348 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 349 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 350 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 351 | + | let Some(r) = crate::db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_owner == org.name) else { |
|
| 352 | + | return Err(Error::not_found(shell)); |
|
| 353 | + | }; |
|
| 354 | + | if crate::db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) { |
|
| 355 | + | return org_page(&app, shell, org, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await; |
|
| 356 | + | } |
|
| 357 | + | crate::db::runners::remove(&app.db, id).await.map_err(e)?; |
|
| 358 | + | accounts::audit(&app.db, Some(&me), "flow.runner.removed", &r.name, &org.name).await.ok(); |
|
| 359 | + | org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await |
|
| 299 | 360 | } |
modified crates/hub/src/web/repo_settings.rs +69 −0
| … | 94 unchanged lines |
||
| 95 | 95 | site_admin: bool, |
|
| 96 | 96 | notice: Option<String>, |
|
| 97 | 97 | error: Option<String>, |
|
| 98 | + | /// Runners that may take this repository's jobs: (runner, what it |
|
| 99 | + | /// serves, whether it is this repository's own). |
|
| 100 | + | runners: Vec<(db::runners::Runner, String, bool)>, |
|
| 101 | + | /// A registration token just made, shown once. |
|
| 102 | + | runner_token: Option<String>, |
|
| 98 | 103 | } |
|
| 99 | 104 | ||
| 100 | 105 | async fn settings_page( |
|
| … | 17 unchanged lines |
||
| 118 | 123 | let flow_default = app.config.flow.allows(&row.owner, &row.name); |
|
| 119 | 124 | let flow_enabled = crate::flow::enabled(app, &row); |
|
| 120 | 125 | let site_admin = shell.is_admin; |
|
| 126 | + | let runners = db::runners::serving(&app.db, &row.owner, row.id) |
|
| 127 | + | .await |
|
| 128 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 129 | + | .into_iter() |
|
| 130 | + | .map(|r| { |
|
| 131 | + | let own = r.scope_repo_id == Some(row.id); |
|
| 132 | + | let serves = if own { |
|
| 133 | + | "this repository".to_string() |
|
| 134 | + | } else if r.scope_owner.is_empty() { |
|
| 135 | + | "the whole site".to_string() |
|
| 136 | + | } else { |
|
| 137 | + | format!("{}'s repositories", r.scope_owner) |
|
| 138 | + | }; |
|
| 139 | + | (r, serves, own) |
|
| 140 | + | }) |
|
| 141 | + | .collect(); |
|
| 121 | 142 | Ok(Page(RepoSettingsPage { |
|
| 143 | + | runners, |
|
| 144 | + | runner_token: None, |
|
| 122 | 145 | shell, |
|
| 123 | 146 | repo, |
|
| 124 | 147 | row, |
|
| … | 322 unchanged lines |
||
| 447 | 470 | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 448 | 471 | let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." }; |
|
| 449 | 472 | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 473 | + | } |
|
| 474 | + | ||
| 475 | + | /// `POST /{owner}/{name}/settings/runners/token`: a one-time registration |
|
| 476 | + | /// token for a runner that serves this repository only (oxygenhub#41). |
|
| 477 | + | pub async fn runner_token( |
|
| 478 | + | State(app): State<Shared>, |
|
| 479 | + | viewer: Viewer, |
|
| 480 | + | Path((owner, name)): Path<(String, String)>, |
|
| 481 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 482 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 483 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 484 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 485 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 486 | + | let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); |
|
| 487 | + | let token = format!("flowreg_{}", crate::auth::random_hex(24)); |
|
| 488 | + | let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) }; |
|
| 489 | + | db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope) |
|
| 490 | + | .await |
|
| 491 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 492 | + | accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok(); |
|
| 493 | + | let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?; |
|
| 494 | + | page.0.runner_token = Some(token); |
|
| 495 | + | Ok(page) |
|
| 496 | + | } |
|
| 497 | + | ||
| 498 | + | /// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this |
|
| 499 | + | /// repository's own runners, when it is running nothing. |
|
| 500 | + | pub async fn runner_remove( |
|
| 501 | + | State(app): State<Shared>, |
|
| 502 | + | viewer: Viewer, |
|
| 503 | + | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 504 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 505 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 506 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 507 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 508 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 509 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 510 | + | let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else { |
|
| 511 | + | return Err(Error::not_found(shell)); |
|
| 512 | + | }; |
|
| 513 | + | if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) { |
|
| 514 | + | return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await; |
|
| 515 | + | } |
|
| 516 | + | db::runners::remove(&app.db, id).await.map_err(e)?; |
|
| 517 | + | accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok(); |
|
| 518 | + | settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await |
|
| 450 | 519 | } |
modified crates/hub/tests/flow.rs +80 −0
| … | 1159 unchanged lines |
||
| 1160 | 1160 | assert_eq!(claim(&h.app).await.unwrap()["repo"], "andy/other"); |
|
| 1161 | 1161 | assert_eq!(claim_as(only_demo).await, None, "still nothing of its own"); |
|
| 1162 | 1162 | } |
|
| 1163 | + | ||
| 1164 | + | /// Settings → Runners (oxygenhub#41, part 2b): a repository's admin adds a |
|
| 1165 | + | /// runner for that repository alone, sees which runners serve it, and |
|
| 1166 | + | /// removes only its own. |
|
| 1167 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1168 | + | async fn repository_admins_add_and_remove_their_own_runners() { |
|
| 1169 | + | use oxy_core::identity::SecretKey; |
|
| 1170 | + | let h = hub().await; |
|
| 1171 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 1172 | + | .oneshot( |
|
| 1173 | + | Request::post("/register") |
|
| 1174 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 1175 | + | .body(Body::from("username=andy&email=andy%40example.com&password=correct-horse-battery")) |
|
| 1176 | + | .unwrap(), |
|
| 1177 | + | ) |
|
| 1178 | + | .await |
|
| 1179 | + | .unwrap(); |
|
| 1180 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 1181 | + | create(&h.app, "andy", "demo").await; |
|
| 1182 | + | let demo = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 1183 | + | let web = |method: &'static str, path: String, body: Option<String>| { |
|
| 1184 | + | let (app, cookie) = (h.app.clone(), cookie.clone()); |
|
| 1185 | + | async move { |
|
| 1186 | + | let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie); |
|
| 1187 | + | if body.is_some() { |
|
| 1188 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 1189 | + | } |
|
| 1190 | + | let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap(); |
|
| 1191 | + | let st = res.status(); |
|
| 1192 | + | (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
|
| 1193 | + | } |
|
| 1194 | + | }; |
|
| 1195 | + | let csrf_of = |page: &str| { |
|
| 1196 | + | let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 1197 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 1198 | + | }; |
|
| 1199 | + | let (st, page) = web("GET", "/andy/demo/settings".into(), None).await; |
|
| 1200 | + | assert_eq!(st, StatusCode::OK); |
|
| 1201 | + | assert!(page.contains("<h2>Runners</h2>"), "the owner sees the section"); |
|
| 1202 | + | let (_, page) = web("POST", "/andy/demo/settings/runners/token".into(), Some(format!("_csrf={}", csrf_of(&page)))).await; |
|
| 1203 | + | let i = page.find("--token ").unwrap() + "--token ".len(); |
|
| 1204 | + | let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect(); |
|
| 1205 | + | let identity = SecretKey::generate().identity("mine", "", 1); |
|
| 1206 | + | let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0", "labels": [], "max_jobs": 1 }); |
|
| 1207 | + | let reg = serde_json::json!({ "token": token, "name": "mine", "identity": hex::encode(identity.encode()), "info": info }); |
|
| 1208 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await; |
|
| 1209 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1210 | + | let mine = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "mine").unwrap(); |
|
| 1211 | + | assert_eq!(mine.scope(), db::runners::Scope { owner: "andy".into(), repo_id: Some(demo.id) }); |
|
| 1212 | + | // The site's runner (hub.toml, from its first claim) serves it too, but is not the owner's to remove. |
|
| 1213 | + | claim(&h.app).await; |
|
| 1214 | + | let site = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "test-1").unwrap(); |
|
| 1215 | + | let (_, page) = web("GET", "/andy/demo/settings".into(), None).await; |
|
| 1216 | + | assert!(page.contains("<strong>mine</strong>") && page.contains("this repository"), "{page}"); |
|
| 1217 | + | assert!(page.contains("<strong>test-1</strong>") && page.contains("the whole site"), "{page}"); |
|
| 1218 | + | let csrf = csrf_of(&page); |
|
| 1219 | + | let (st, _) = web("POST", format!("/andy/demo/settings/runners/{}/remove", site.id), Some(format!("_csrf={csrf}"))).await; |
|
| 1220 | + | assert_eq!(st, StatusCode::NOT_FOUND, "not the repository's own"); |
|
| 1221 | + | let (_, page) = web("POST", format!("/andy/demo/settings/runners/{}/remove", mine.id), Some(format!("_csrf={csrf}"))).await; |
|
| 1222 | + | assert!(page.contains("mine removed"), "{page}"); |
|
| 1223 | + | assert!(db::runners::get(&h.app.db, mine.id).await.unwrap().is_none()); |
|
| 1224 | + | ||
| 1225 | + | // The same for an organisation's owner. |
|
| 1226 | + | let (_, page) = web("GET", "/orgs/new".into(), None).await; |
|
| 1227 | + | web("POST", "/orgs/new".into(), Some(format!("_csrf={}&name=acme&display_name=Acme", csrf_of(&page)))).await; |
|
| 1228 | + | let (st, page) = web("GET", "/orgs/acme/settings".into(), None).await; |
|
| 1229 | + | assert_eq!(st, StatusCode::OK); |
|
| 1230 | + | let (_, page) = web("POST", "/orgs/acme/settings/runners/token".into(), Some(format!("_csrf={}", csrf_of(&page)))).await; |
|
| 1231 | + | let i = page.find("--token ").unwrap() + "--token ".len(); |
|
| 1232 | + | let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect(); |
|
| 1233 | + | let identity = SecretKey::generate().identity("acme-runner", "", 1); |
|
| 1234 | + | let reg = serde_json::json!({ "token": token, "name": "acme-runner", "identity": hex::encode(identity.encode()), "info": info }); |
|
| 1235 | + | assert_eq!(call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await.0, StatusCode::OK); |
|
| 1236 | + | let acme = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "acme-runner").unwrap(); |
|
| 1237 | + | assert_eq!(acme.scope(), db::runners::Scope { owner: "acme".into(), repo_id: None }); |
|
| 1238 | + | let (_, page) = web("GET", "/orgs/acme/settings".into(), None).await; |
|
| 1239 | + | assert!(page.contains("<strong>acme-runner</strong>") && page.contains("the organisation's repositories"), "{page}"); |
|
| 1240 | + | let (_, page) = web("POST", format!("/orgs/acme/settings/runners/{}/remove", acme.id), Some(format!("_csrf={}", csrf_of(&page)))).await; |
|
| 1241 | + | assert!(page.contains("acme-runner removed"), "{page}"); |
|
| 1242 | + | } |
modified docs/spec.md +1 −1
| … | 447 unchanged lines |
||
| 448 | 448 | - On the runner, `flow runner register https://hub --token …` sends the runner's signing identity, architecture, OS, version and labels (`POST /api/v1/flow/runners/register`) and gets its own credential (`flowrunner_…`, stored hashed). |
|
| 449 | 449 | - The runner's key is bound at registration: receipts from that runner signed by another key are not kept. |
|
| 450 | 450 | - Runners configured with a `hub.toml` token still work. They are tracked from their first request under the name they claim with, and their key is bound on their first signed receipt. |
|
| 451 | - | - **Scopes** (oxygenhub#41): a runner serves the whole site, one owner's repositories (a user or an organisation), or one repository. The registration token carries the scope (typed as empty, `owner` or `owner/repository` when it is made) and the runner gets it; an administrator can change it on the Runners page. A claim returns only queued jobs of repositories in the runner's scope, filtered before the queue is cut short, so a scoped runner never receives another scope's jobs and never waits behind them. `hub.toml` runners serve the whole site. Changes are audited (`flow.runner.scope`). |
|
| 451 | + | - **Scopes** (oxygenhub#41): a runner serves the whole site, one owner's repositories (a user or an organisation), or one repository. The registration token carries the scope (typed as empty, `owner` or `owner/repository` when it is made) and the runner gets it; an administrator can change it on the Runners page. A claim returns only queued jobs of repositories in the runner's scope, filtered before the queue is cut short, so a scoped runner never receives another scope's jobs and never waits behind them. `hub.toml` runners serve the whole site. Changes are audited (`flow.runner.scope`). **Settings → Runners** on a repository (its admins) and on an organisation (its owners) lists the runners that may take its jobs, with what each serves; there they make a registration token for a runner of their own scope (the repository, or the organisation's repositories) and remove such runners when they run nothing. Only site administrators manage site runners. |
|
| 452 | 452 | - **Each claim** carries the runner's description and marks it seen. |
|
| 453 | 453 | - **The Runners page** lists each runner: online (seen in the last 90 s), offline, draining or disabled; what it serves; signing key; machine; labels; capacity; jobs running now; and when it was last seen. |
|
| 454 | 454 | - **Actions:** |
|
| … | 177 unchanged lines |
modified templates/org_settings.html +12 −0
| … | 59 unchanged lines |
||
| 60 | 60 | <div class="choice"><button type="submit" name="action" value="create">CREATE TEAM</button> <button type="submit" name="action" value="add">ADD MEMBER</button> <button type="submit" name="action" value="remove">REMOVE MEMBER</button></div> |
|
| 61 | 61 | </form> |
|
| 62 | 62 | </section> |
|
| 63 | + | <section class="panel" id="runners"> |
|
| 64 | + | <h2>Runners</h2> |
|
| 65 | + | <p class="small">The OxygenFlow runners that may take this organisation's jobs: the site's, the organisation's, and those of its repositories.</p> |
|
| 66 | + | {% if let Some(t) = runner_token %}<p class="note">Register a runner for the organisation with:</p><pre class="snippet">flow runner register {{ shell.base_url }} --token {{ t }} --name NAME</pre>{% endif %} |
|
| 67 | + | {% if runners.is_empty() %}<p class="mute small">None yet: runs wait until a runner that serves them is added.</p>{% else %} |
|
| 68 | + | <div class="table"><table> |
|
| 69 | + | <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Machine</th><th></th></tr> |
|
| 70 | + | {% for (r, serves, own) in runners %}<tr><td><strong>{{ r.name }}</strong></td><td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td><td class="small">{{ serves }}</td><td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }}{% endif %}</td> |
|
| 71 | + | <td>{% if *own %}<form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/{{ r.id }}/remove" onsubmit="return confirm('Remove {{ r.name }}? Its credential stops working.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form>{% endif %}</td></tr>{% endfor %} |
|
| 72 | + | </table></div>{% endif %} |
|
| 73 | + | <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> |
|
| 74 | + | </section> |
|
| 63 | 75 | {% 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.") %} |
|
| 64 | 76 | </div> |
|
| 65 | 77 | </div> |
|
| … | 1 unchanged lines |
modified templates/repo_settings.html +12 −0
| … | 75 unchanged lines |
||
| 76 | 76 | {% endif %} |
|
| 77 | 77 | </form> |
|
| 78 | 78 | {% if role == crate::auth::Role::Admin %} |
|
| 79 | + | <section class="panel" id="runners"> |
|
| 80 | + | <h2>Runners</h2> |
|
| 81 | + | <p class="small">The OxygenFlow runners that may take this repository's jobs: the site's, {{ row.owner }}'s, and this repository's own.</p> |
|
| 82 | + | {% if let Some(t) = runner_token %}<p class="note">Register a runner for this repository with:</p><pre class="snippet">flow runner register {{ shell.base_url }} --token {{ t }} --name NAME</pre>{% endif %} |
|
| 83 | + | {% if runners.is_empty() %}<p class="mute small">None yet: runs wait until a runner that serves this repository is added.</p>{% else %} |
|
| 84 | + | <div class="table"><table> |
|
| 85 | + | <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Machine</th><th></th></tr> |
|
| 86 | + | {% for (r, serves, own) in runners %}<tr><td><strong>{{ r.name }}</strong></td><td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td><td class="small">{{ serves }}</td><td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }}{% endif %}</td> |
|
| 87 | + | <td>{% if *own %}<form class="tiny" method="post" action="{{ repo.url }}/settings/runners/{{ r.id }}/remove" onsubmit="return confirm('Remove {{ r.name }}? Its credential stops working.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form>{% endif %}</td></tr>{% endfor %} |
|
| 88 | + | </table></div>{% endif %} |
|
| 89 | + | <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form> |
|
| 90 | + | </section> |
|
| 79 | 91 | {% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %} |
|
| 80 | 92 | <section class="panel" id="collaborators"> |
|
| 81 | 93 | <h2>Collaborators</h2> |
|
| … | 30 unchanged lines |