OXYGENHUB

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 %}