Runners serve the site, an owner or one repository (refs #41, part 2) 74be97962ed8
A registration token carries a scope (typed when it is made: empty for the whole site, an owner, or owner/repository) and the runner it registers gets it; administrators can change a runner's scope on the Runners page, which shows what each serves. A claim returns only queued jobs of repositories in the runner's scope, filtered in the query before the queue is cut short, so a scoped runner never sees another scope's jobs. hub.toml runners serve the whole site. Audited as flow.runner.scope.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 01:11 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
74be97962ed8cc548b9dad48f78252098803a8b6b4000d013d769a304f53105c- Depends on
- 42fa95540468 76bc5a058351 93be9ce4653e 941abdd6bb4c b6b7e4cf6b24 d23464c0ac60
- Depended on by
- 03bfb945f5c1 107eaba52c9d 55673c650986 e9b9619cd857 5d90e0e045e2 417184c73f4e 1870e4fa9808 a46d15e72101 501d8b268dd4 3556d08e17f9 7f5f6c056d8f c4c87f481d05 7974adb0be66 b66e70b7b6c7
- 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
- 36 atoms, 1 tree operation, 9 files
Browse the tree at this change
added crates/hub/migrations/0028_runner_scopes.sql +8 −0
| 1 | + | -- Runner scopes (oxygenhub#41, part 2): a runner takes jobs from the whole |
|
| 2 | + | -- site (both empty), from one owner's repositories (scope_owner), or from |
|
| 3 | + | -- one repository (scope_repo_id, with its owner). Registration tokens carry |
|
| 4 | + | -- the scope the runner gets. |
|
| 5 | + | ALTER TABLE flow_runners ADD COLUMN scope_owner TEXT NOT NULL DEFAULT ''; |
|
| 6 | + | ALTER TABLE flow_runners ADD COLUMN scope_repo_id INTEGER; |
|
| 7 | + | ALTER TABLE flow_runner_registrations ADD COLUMN scope_owner TEXT NOT NULL DEFAULT ''; |
|
| 8 | + | ALTER TABLE flow_runner_registrations ADD COLUMN scope_repo_id INTEGER; |
modified crates/hub/src/db/flow.rs +20 −3
| … | 257 unchanged lines |
||
| 258 | 258 | } |
|
| 259 | 259 | ||
| 260 | 260 | pub async fn claim(pool: &Pool, runtimes: &[String], runner: &str, token_hash: &str) -> sqlx::Result<Option<Job>> { |
|
| 261 | - | claim_within(pool, runtimes, runner, token_hash, Room::default()).await |
|
| 261 | + | claim_within(pool, runtimes, runner, token_hash, Room::default(), &Default::default()).await |
|
| 262 | 262 | } |
|
| 263 | 263 | ||
| 264 | 264 | /// The oldest runnable job that fits in `room`. |
|
| 265 | - | pub async fn claim_within(pool: &Pool, runtimes: &[String], runner: &str, token_hash: &str, room: Room) -> sqlx::Result<Option<Job>> { |
|
| 265 | + | /// The first queued job a runner with these tiers, this room and this |
|
| 266 | + | /// scope may take, now running under `runner`. A scoped runner never sees |
|
| 267 | + | /// another scope's jobs (oxygenhub#41). |
|
| 268 | + | pub async fn claim_within( |
|
| 269 | + | pool: &Pool, |
|
| 270 | + | runtimes: &[String], |
|
| 271 | + | runner: &str, |
|
| 272 | + | token_hash: &str, |
|
| 273 | + | room: Room, |
|
| 274 | + | scope: &super::runners::Scope, |
|
| 275 | + | ) -> sqlx::Result<Option<Job>> { |
|
| 266 | 276 | let mut tx = super::begin_write(pool).await?; |
|
| 267 | 277 | let queued: Vec<Job> = sqlx::query_as( |
|
| 268 | - | "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id WHERE j.status = 'queued' AND r.status IN ('queued', 'running') ORDER BY j.run_id, j.depth, j.id LIMIT 200", |
|
| 278 | + | "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories rep ON rep.id = r.repo_id \ |
|
| 279 | + | WHERE j.status = 'queued' AND r.status IN ('queued', 'running') \ |
|
| 280 | + | AND (? = '' OR rep.owner = ?) AND (? IS NULL OR r.repo_id = ?) \ |
|
| 281 | + | ORDER BY j.run_id, j.depth, j.id LIMIT 200", |
|
| 269 | 282 | ) |
|
| 283 | + | .bind(&scope.owner) |
|
| 284 | + | .bind(&scope.owner) |
|
| 285 | + | .bind(scope.repo_id) |
|
| 286 | + | .bind(scope.repo_id) |
|
| 270 | 287 | .fetch_all(&mut *tx) |
|
| 271 | 288 | .await?; |
|
| 272 | 289 | for j in queued { |
|
| … | 226 unchanged lines |
modified crates/hub/src/db/runners.rs +56 −15
| … | 22 unchanged lines |
||
| 23 | 23 | pub created_ms: i64, |
|
| 24 | 24 | pub created_by: String, |
|
| 25 | 25 | pub last_seen_ms: Option<i64>, |
|
| 26 | + | /// The owner whose repositories it serves; empty for the whole site. |
|
| 27 | + | pub scope_owner: String, |
|
| 28 | + | /// The one repository it serves, when it serves one. |
|
| 29 | + | pub scope_repo_id: Option<i64>, |
|
| 26 | 30 | } |
|
| 27 | 31 | ||
| 32 | + | /// Which repositories' jobs a runner may take (oxygenhub#41). |
|
| 33 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 34 | + | pub struct Scope { |
|
| 35 | + | /// Empty: any owner. |
|
| 36 | + | pub owner: String, |
|
| 37 | + | /// `None`: any of the owner's repositories. |
|
| 38 | + | pub repo_id: Option<i64>, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | impl Scope { |
|
| 42 | + | pub fn is_site(&self) -> bool { |
|
| 43 | + | self.owner.is_empty() && self.repo_id.is_none() |
|
| 44 | + | } |
|
| 45 | + | } |
|
| 46 | + | ||
| 28 | 47 | /// A runner that has not polled for this long is shown offline. |
|
| 29 | 48 | pub const ONLINE_MS: i64 = 90_000; |
|
| 30 | 49 | ||
| … | 15 unchanged lines |
||
| 46 | 65 | pub fn label_list(&self) -> Vec<&str> { |
|
| 47 | 66 | self.labels.split(',').filter(|l| !l.is_empty()).collect() |
|
| 48 | 67 | } |
|
| 68 | + | ||
| 69 | + | pub fn scope(&self) -> Scope { |
|
| 70 | + | Scope { owner: self.scope_owner.clone(), repo_id: self.scope_repo_id } |
|
| 71 | + | } |
|
| 49 | 72 | } |
|
| 50 | 73 | ||
| 51 | 74 | pub async fn by_token(pool: &Pool, token_hash: &str) -> sqlx::Result<Option<Runner>> { |
|
| … | 78 unchanged lines |
||
| 130 | 153 | sqlx::query_scalar("SELECT key FROM flow_runners WHERE key != ''").fetch_all(pool).await |
|
| 131 | 154 | } |
|
| 132 | 155 | ||
| 133 | - | /// A one-time registration token, valid for a day. |
|
| 134 | - | pub async fn add_registration(pool: &Pool, token_hash: &str, by: &str) -> sqlx::Result<()> { |
|
| 156 | + | /// A one-time registration token, valid for a day, for a runner of `scope`. |
|
| 157 | + | pub async fn add_registration(pool: &Pool, token_hash: &str, by: &str, scope: &Scope) -> sqlx::Result<()> { |
|
| 135 | 158 | let now = now_ms(); |
|
| 136 | - | sqlx::query("INSERT INTO flow_runner_registrations (token_hash, created_by, created_ms, expires_ms) VALUES (?, ?, ?, ?)") |
|
| 137 | - | .bind(token_hash) |
|
| 138 | - | .bind(by) |
|
| 139 | - | .bind(now) |
|
| 140 | - | .bind(now + 24 * 3_600_000) |
|
| 159 | + | sqlx::query( |
|
| 160 | + | "INSERT INTO flow_runner_registrations (token_hash, created_by, created_ms, expires_ms, scope_owner, scope_repo_id) VALUES (?, ?, ?, ?, ?, ?)", |
|
| 161 | + | ) |
|
| 162 | + | .bind(token_hash) |
|
| 163 | + | .bind(by) |
|
| 164 | + | .bind(now) |
|
| 165 | + | .bind(now + 24 * 3_600_000) |
|
| 166 | + | .bind(&scope.owner) |
|
| 167 | + | .bind(scope.repo_id) |
|
| 168 | + | .execute(pool) |
|
| 169 | + | .await?; |
|
| 170 | + | Ok(()) |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | /// Change the repositories a runner serves. |
|
| 174 | + | pub async fn set_scope(pool: &Pool, id: i64, scope: &Scope) -> sqlx::Result<()> { |
|
| 175 | + | sqlx::query("UPDATE flow_runners SET scope_owner = ?, scope_repo_id = ? WHERE id = ?") |
|
| 176 | + | .bind(&scope.owner) |
|
| 177 | + | .bind(scope.repo_id) |
|
| 178 | + | .bind(id) |
|
| 141 | 179 | .execute(pool) |
|
| 142 | 180 | .await?; |
|
| 143 | 181 | Ok(()) |
|
| … | 21 unchanged lines |
||
| 165 | 203 | ) -> sqlx::Result<Option<Runner>> { |
|
| 166 | 204 | let mut tx = super::begin_write(pool).await?; |
|
| 167 | 205 | let now = now_ms(); |
|
| 168 | - | let who: Option<String> = |
|
| 169 | - | sqlx::query_scalar("SELECT created_by FROM flow_runner_registrations WHERE token_hash = ? AND used_ms IS NULL AND expires_ms > ?") |
|
| 170 | - | .bind(registration) |
|
| 171 | - | .bind(now) |
|
| 172 | - | .fetch_optional(&mut *tx) |
|
| 173 | - | .await?; |
|
| 174 | - | let Some(who) = who else { return Ok(None) }; |
|
| 206 | + | let found: Option<(String, String, Option<i64>)> = sqlx::query_as( |
|
| 207 | + | "SELECT created_by, scope_owner, scope_repo_id FROM flow_runner_registrations WHERE token_hash = ? AND used_ms IS NULL AND expires_ms > ?", |
|
| 208 | + | ) |
|
| 209 | + | .bind(registration) |
|
| 210 | + | .bind(now) |
|
| 211 | + | .fetch_optional(&mut *tx) |
|
| 212 | + | .await?; |
|
| 213 | + | let Some((who, scope_owner, scope_repo_id)) = found else { return Ok(None) }; |
|
| 175 | 214 | let id: i64 = sqlx::query_scalar( |
|
| 176 | - | "INSERT INTO flow_runners (name, token_hash, source, key, key_name, created_ms, created_by) VALUES (?, ?, 'registered', ?, ?, ?, ?) RETURNING id", |
|
| 215 | + | "INSERT INTO flow_runners (name, token_hash, source, key, key_name, created_ms, created_by, scope_owner, scope_repo_id) VALUES (?, ?, 'registered', ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 177 | 216 | ) |
|
| 178 | 217 | .bind(name) |
|
| 179 | 218 | .bind(token_hash) |
|
| … | 1 unchanged lines |
||
| 181 | 220 | .bind(key_name) |
|
| 182 | 221 | .bind(now) |
|
| 183 | 222 | .bind(if who.is_empty() { by_default } else { &who }) |
|
| 223 | + | .bind(scope_owner) |
|
| 224 | + | .bind(scope_repo_id) |
|
| 184 | 225 | .fetch_one(&mut *tx) |
|
| 185 | 226 | .await?; |
|
| 186 | 227 | sqlx::query("UPDATE flow_runner_registrations SET used_ms = ?, runner_id = ? WHERE token_hash = ?") |
|
| … | 60 unchanged lines |
modified crates/hub/src/flow.rs +1 −1
| … | 238 unchanged lines |
||
| 239 | 239 | let runner = if me.source == "registered" { me.name.clone() } else { claimed }; |
|
| 240 | 240 | let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32)); |
|
| 241 | 241 | let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus }; |
|
| 242 | - | let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room).await? else { |
|
| 242 | + | let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope()).await? else { |
|
| 243 | 243 | return Ok(StatusCode::NO_CONTENT.into_response()); |
|
| 244 | 244 | }; |
|
| 245 | 245 | db::runners::set_job_runner(&app.db, job.id, me.id).await?; |
|
| … | 407 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 93 unchanged lines |
||
| 94 | 94 | .route("/admin/runners/token", post(runners::new_token)) |
|
| 95 | 95 | .route("/admin/runners/{id}", get(runners::show)) |
|
| 96 | 96 | .route("/admin/runners/{id}/state", post(runners::set_state)) |
|
| 97 | + | .route("/admin/runners/{id}/scope", post(runners::set_scope)) |
|
| 97 | 98 | .route("/admin/runners/{id}/remove", post(runners::remove)) |
|
| 98 | 99 | .nest("/api/v1", crate::api::router()) |
|
| 99 | 100 | .route("/{owner}", get(home::owner)) |
|
| … | 498 unchanged lines |
modified crates/hub/src/web/runners.rs +84 −6
| … | 23 unchanged lines |
||
| 24 | 24 | pub r: runners::Runner, |
|
| 25 | 25 | /// Jobs it is running now. |
|
| 26 | 26 | pub running: i64, |
|
| 27 | + | /// The repositories it serves, in words. |
|
| 28 | + | pub scope: String, |
|
| 27 | 29 | } |
|
| 28 | 30 | ||
| 31 | + | /// A scope in words: the whole site, an owner's repositories, or one |
|
| 32 | + | /// repository. |
|
| 33 | + | async fn scope_words(app: &Shared, s: &runners::Scope) -> String { |
|
| 34 | + | match s.repo_id { |
|
| 35 | + | Some(id) => match db::repos::get_by_id(&app.db, id).await.ok().flatten() { |
|
| 36 | + | Some(r) => format!("{}/{}", r.owner, r.name), |
|
| 37 | + | None => "a removed repository".into(), |
|
| 38 | + | }, |
|
| 39 | + | None if s.owner.is_empty() => "the whole site".into(), |
|
| 40 | + | None => format!("{}'s repositories", s.owner), |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | /// A scope as typed: empty for the whole site, an owner, or |
|
| 45 | + | /// `owner/repository`. |
|
| 46 | + | async fn parse_scope(app: &Shared, text: &str) -> Result<runners::Scope, String> { |
|
| 47 | + | let text = text.trim().trim_matches('/'); |
|
| 48 | + | if text.is_empty() { |
|
| 49 | + | return Ok(runners::Scope::default()); |
|
| 50 | + | } |
|
| 51 | + | if let Some((owner, name)) = text.split_once('/') { |
|
| 52 | + | return match db::repos::get(&app.db, owner, name).await { |
|
| 53 | + | Ok(Some(r)) => Ok(runners::Scope { owner: r.owner.clone(), repo_id: Some(r.id) }), |
|
| 54 | + | _ => Err(format!("There is no repository {text}.")), |
|
| 55 | + | }; |
|
| 56 | + | } |
|
| 57 | + | let user = accounts::account_by_name(&app.db, text).await.ok().flatten().is_some(); |
|
| 58 | + | let org = accounts::org_by_name(&app.db, text).await.ok().flatten().is_some(); |
|
| 59 | + | if user || org { |
|
| 60 | + | Ok(runners::Scope { owner: text.to_string(), repo_id: None }) |
|
| 61 | + | } else { |
|
| 62 | + | Err(format!("There is no user or organisation {text}.")) |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | ||
| 29 | 66 | #[derive(Template)] |
|
| 30 | 67 | #[template(path = "admin_runners.html")] |
|
| 31 | 68 | pub struct RunnersPage { |
|
| … | 19 unchanged lines |
||
| 51 | 88 | let list = runners::list(&app.db).await.map_err(e)?; |
|
| 52 | 89 | let running: HashMap<i64, i64> = runners::running(&app.db).await.map_err(e)?.into_iter().collect(); |
|
| 53 | 90 | let pending = runners::pending_registrations(&app.db).await.map_err(e)?; |
|
| 54 | - | let rows = list.into_iter().map(|r| RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), r }).collect(); |
|
| 91 | + | let mut rows = Vec::with_capacity(list.len()); |
|
| 92 | + | for r in list { |
|
| 93 | + | let scope = scope_words(app, &r.scope()).await; |
|
| 94 | + | rows.push(RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), scope, r }); |
|
| 95 | + | } |
|
| 55 | 96 | Ok(Page(RunnersPage { hub: app.config.base_url().to_string(), shell, runners: rows, pending, new_token, notice, error })) |
|
| 56 | 97 | } |
|
| 57 | 98 | ||
| … | 6 unchanged lines |
||
| 64 | 105 | ||
| 65 | 106 | #[derive(Deserialize)] |
|
| 66 | 107 | pub struct CsrfOnly { |
|
| 108 | + | #[serde(rename = "_csrf")] |
|
| 109 | + | csrf_token: String, |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | #[derive(Deserialize)] |
|
| 113 | + | pub struct ScopeForm { |
|
| 67 | 114 | #[serde(rename = "_csrf")] |
|
| 68 | 115 | csrf_token: String, |
|
| 116 | + | /// Empty for the whole site, an owner, or `owner/repository`. |
|
| 117 | + | #[serde(default)] |
|
| 118 | + | scope: String, |
|
| 69 | 119 | } |
|
| 70 | 120 | ||
| 71 | - | /// `POST /admin/runners/token`: a one-time registration token, shown once. |
|
| 72 | - | pub async fn new_token(State(app): State<Shared>, viewer: Viewer, Form(f): Form<CsrfOnly>) -> Result<Page<RunnersPage>, Error> { |
|
| 121 | + | /// `POST /admin/runners/token`: a one-time registration token, shown once, |
|
| 122 | + | /// for a runner of the scope given. |
|
| 123 | + | pub async fn new_token(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ScopeForm>) -> Result<Page<RunnersPage>, Error> { |
|
| 73 | 124 | let shell = Shell::new(&app, "/admin/runners", &viewer); |
|
| 74 | 125 | let me = require_admin(&viewer, &shell)?; |
|
| 75 | 126 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 127 | + | let scope = match parse_scope(&app, &f.scope).await { |
|
| 128 | + | Ok(s) => s, |
|
| 129 | + | Err(e) => return page(&app, shell, None, None, Some(e)).await, |
|
| 130 | + | }; |
|
| 76 | 131 | let token = format!("flowreg_{}", crate::auth::random_hex(24)); |
|
| 77 | - | runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username) |
|
| 132 | + | runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope) |
|
| 78 | 133 | .await |
|
| 79 | 134 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 80 | - | accounts::audit(&app.db, Some(&me), "flow.runner.token", "", "registration token created").await.ok(); |
|
| 81 | - | page(&app, shell, Some(token), None, None).await |
|
| 135 | + | let words = scope_words(&app, &scope).await; |
|
| 136 | + | accounts::audit(&app.db, Some(&me), "flow.runner.token", "", &format!("registration token created for {words}")).await.ok(); |
|
| 137 | + | page(&app, shell, Some(token), Some(format!("The runner it registers will take jobs from {words}.")), None).await |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | /// `POST /admin/runners/{id}/scope`: change the repositories a runner serves. |
|
| 141 | + | pub async fn set_scope( |
|
| 142 | + | State(app): State<Shared>, |
|
| 143 | + | viewer: Viewer, |
|
| 144 | + | Path(id): Path<i64>, |
|
| 145 | + | Form(f): Form<ScopeForm>, |
|
| 146 | + | ) -> Result<Page<RunnersPage>, Error> { |
|
| 147 | + | let shell = Shell::new(&app, "/admin/runners", &viewer); |
|
| 148 | + | let me = require_admin(&viewer, &shell)?; |
|
| 149 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 150 | + | let r = |
|
| 151 | + | runners::get(&app.db, id).await.map_err(|e| Error::from_db(shell.clone(), e))?.ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 152 | + | let scope = match parse_scope(&app, &f.scope).await { |
|
| 153 | + | Ok(s) => s, |
|
| 154 | + | Err(e) => return page(&app, shell, None, None, Some(e)).await, |
|
| 155 | + | }; |
|
| 156 | + | runners::set_scope(&app.db, id, &scope).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 157 | + | let words = scope_words(&app, &scope).await; |
|
| 158 | + | accounts::audit(&app.db, Some(&me), "flow.runner.scope", &r.name, &words).await.ok(); |
|
| 159 | + | page(&app, shell, None, Some(format!("{} now takes jobs from {words}.", r.name)), None).await |
|
| 82 | 160 | } |
|
| 83 | 161 | ||
| 84 | 162 | #[derive(Deserialize)] |
|
| … | 113 unchanged lines |
modified crates/hub/tests/flow.rs +49 −0
| … | 1110 unchanged lines |
||
| 1111 | 1111 | assert!(page.contains("Removed plex."), "{page}"); |
|
| 1112 | 1112 | assert_eq!(claim_as(credential).await.0, StatusCode::UNAUTHORIZED); |
|
| 1113 | 1113 | } |
|
| 1114 | + | ||
| 1115 | + | /// Runner scopes (oxygenhub#41): a runner registered for one repository, or |
|
| 1116 | + | /// one owner, takes only those jobs; a site runner takes any. |
|
| 1117 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1118 | + | async fn scoped_runners_take_only_their_scope() { |
|
| 1119 | + | use oxy_core::identity::SecretKey; |
|
| 1120 | + | let h = hub().await; |
|
| 1121 | + | create(&h.app, "andy", "demo").await; |
|
| 1122 | + | create(&h.app, "andy", "other").await; |
|
| 1123 | + | let register = |scope: db::runners::Scope, name: &'static str| { |
|
| 1124 | + | let app = h.app.clone(); |
|
| 1125 | + | async move { |
|
| 1126 | + | let token = format!("flowreg_{name}"); |
|
| 1127 | + | db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), "admin", &scope).await.unwrap(); |
|
| 1128 | + | let identity = SecretKey::generate().identity(name, "", 1); |
|
| 1129 | + | let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0", "labels": [], "max_jobs": 1 }); |
|
| 1130 | + | let reg = serde_json::json!({ "token": token, "name": name, "identity": hex::encode(identity.encode()), "info": info }); |
|
| 1131 | + | let (st, body) = call(&app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await; |
|
| 1132 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1133 | + | serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string() |
|
| 1134 | + | } |
|
| 1135 | + | }; |
|
| 1136 | + | let demo = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 1137 | + | let only_demo = register(db::runners::Scope { owner: "andy".into(), repo_id: Some(demo.id) }, "demo-runner").await; |
|
| 1138 | + | let carols = register(db::runners::Scope { owner: "carol".into(), repo_id: None }, "carol-runner").await; |
|
| 1139 | + | let andys = register(db::runners::Scope { owner: "andy".into(), repo_id: None }, "andy-runner").await; |
|
| 1140 | + | let claim_as = |who: String| { |
|
| 1141 | + | let app = h.app.clone(); |
|
| 1142 | + | async move { |
|
| 1143 | + | let body = serde_json::json!({ "runner": "x", "runtimes": ["microvm"] }).to_string(); |
|
| 1144 | + | let (st, body) = call(&app, "POST", "/api/v1/flow/claim", Some(&who), Some(body)).await; |
|
| 1145 | + | (st == StatusCode::OK).then(|| serde_json::from_str::<serde_json::Value>(&body).unwrap()["repo"].as_str().unwrap().to_string()) |
|
| 1146 | + | } |
|
| 1147 | + | }; |
|
| 1148 | + | // Jobs queued in andy/other only. |
|
| 1149 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1150 | + | let c = client(&format!("{}/andy/other", h.base), work.path()); |
|
| 1151 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 1152 | + | c.snapshot("t").unwrap(); |
|
| 1153 | + | c.commit("pipeline", &[]).unwrap(); |
|
| 1154 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1155 | + | assert!(!runs_of(&h.app, "andy", "other").await.is_empty()); |
|
| 1156 | + | assert_eq!(claim_as(only_demo.clone()).await, None, "nothing of andy/demo is queued"); |
|
| 1157 | + | assert_eq!(claim_as(carols).await, None, "nothing of carol's"); |
|
| 1158 | + | assert_eq!(claim_as(andys).await.as_deref(), Some("andy/other"), "the owner's runner takes its repositories' jobs"); |
|
| 1159 | + | // The site runner (hub.toml) takes any. |
|
| 1160 | + | assert_eq!(claim(&h.app).await.unwrap()["repo"], "andy/other"); |
|
| 1161 | + | assert_eq!(claim_as(only_demo).await, None, "still nothing of its own"); |
|
| 1162 | + | } |
modified docs/spec.md +2 −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 | 452 | - **Each claim** carries the runner's description and marks it seen. |
|
| 452 | - | - **The Runners page** lists each runner: online (seen in the last 90 s), offline, draining or disabled; signing key; machine; labels; capacity; jobs running now; and when it was last seen. |
|
| 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. |
|
| 453 | 454 | - **Actions:** |
|
| 454 | 455 | - **Drain:** the runner finishes its jobs and claims return nothing. |
|
| 455 | 456 | - **Disable:** its requests are refused. |
|
| … | 175 unchanged lines |
modified templates/admin_runners.html +8 −2
| … | 16 unchanged lines |
||
| 17 | 17 | <section class="panel"> |
|
| 18 | 18 | {% if runners.is_empty() %}<p class="mute">No runners yet. Create a registration token below and run <code>flow runner register</code> on the runner.</p>{% else %} |
|
| 19 | 19 | <div class="table"><table> |
|
| 20 | - | <tr><th>Runner</th><th>Status</th><th>Signing key</th><th>Machine</th><th>Labels</th><th>Capacity</th><th>Running</th><th>Last seen</th><th></th></tr> |
|
| 20 | + | <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Signing key</th><th>Machine</th><th>Labels</th><th>Capacity</th><th>Running</th><th>Last seen</th><th></th></tr> |
|
| 21 | 21 | {% for row in runners %}{% let r = row.r %} |
|
| 22 | 22 | <tr> |
|
| 23 | 23 | <td><a href="/admin/runners/{{ r.id }}"><strong>{{ r.name }}</strong></a>{% if r.source == "config" %} <span class="tag" title="Configured with a hub.toml token">hub.toml</span>{% endif %}</td> |
|
| 24 | 24 | <td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td> |
|
| 25 | + | <td class="small">{{ row.scope }} |
|
| 26 | + | <form class="tiny" method="post" action="/admin/runners/{{ r.id }}/scope" title="Empty for the whole site, an owner, or owner/repository"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="scope" size="14" placeholder="owner or owner/repo"> <button>set</button></form></td> |
|
| 25 | 27 | <td>{% if r.key.is_empty() %}<span class="mute" title="Bound on its first signed receipt">not yet</span>{% else %}<code title="{{ r.key }} · {{ r.key_name }}">{{ r.key|short }}</code>{% endif %}</td> |
|
| 26 | 28 | <td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}</td> |
|
| 27 | 29 | <td class="small">{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}</td> |
|
| … | 11 unchanged lines |
||
| 39 | 41 | <section class="panel"> |
|
| 40 | 42 | <h2>Add a runner</h2> |
|
| 41 | 43 | <p class="small">A runner registers with a one-time token: it sends its signing identity, architecture and labels, and gets its own credential, kept in <code>runner.toml</code> beside its <code>flow.toml</code>.</p> |
|
| 42 | - | <form method="post" action="/admin/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="primary-btn">Create a registration token</button></form> |
|
| 44 | + | <p class="small">The runner serves the whole site, one owner's repositories, or one repository; it never takes another scope's jobs.</p> |
|
| 45 | + | <form method="post" action="/admin/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 46 | + | <label for="scope">Serves (empty for the whole site, an owner, or owner/repository)</label> |
|
| 47 | + | <input type="text" id="scope" name="scope" placeholder="andymccall or andymccall/oxygen"> |
|
| 48 | + | <button type="submit" class="primary-btn">Create a registration token</button></form> |
|
| 43 | 49 | {% if !pending.is_empty() %}<p class="small mute">Unused tokens: {% for (by, made, expires) in pending %}by {{ by }} {{ made|ago_i }}, expires {{ expires|when_i }}{% if !loop.last %}; {% endif %}{% endfor %}</p>{% endif %} |
|
| 44 | 50 | </section> |
|
| 45 | 51 | {% endblock %} |