OXYGENHUB

A runner's own labels and time limit, from its admin page 7f5f6c056d8f

The runner's page under Admin > Runners has a Settings form: labels
an administrator gives it beside those it reports (flow_runners
admin_labels; a job asking for a label matches either set), and a
job time limit in minutes below the site's (timeout_minutes), applied
when the runner claims. Audited as flow.runner.limits. Spec §13.2.

closes #98
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 15:07 UTC
Signature
✔ signed by Andy McCall
Hash
7f5f6c056d8f8d6e1a66a185452f10303c36f5758c19fe6790af59137752708e
Depends on
42fa95540468 5dead1f4f26d 74be97962ed8 b0612e7dce30 b66e70b7b6c7 b6b7e4cf6b24 c4c87f481d05 d23464c0ac60
Depended on by
5d90e0e045e2 501d8b268dd4 92a797b20912 4d2e44d79b03
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.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
15 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/hub/migrations/0034_runner_limits.sql +5 −0
1 +
-- A runner's own settings from the admin page (oxygenhub#98): labels an
2 +
-- administrator gives it beside those it reports, and a time limit for
3 +
-- the jobs it runs, below the site's.
4 +
ALTER TABLE flow_runners ADD COLUMN admin_labels TEXT NOT NULL DEFAULT '';
5 +
ALTER TABLE flow_runners ADD COLUMN timeout_minutes INTEGER;
modified crates/hub/src/db/runners.rs +32 −0
…
26 unchanged lines
27 27
    pub scope_owner: String,
28 28
    /// The one repository it serves, when it serves one.
29 29
    pub scope_repo_id: Option<i64>,
30 +
    /// Labels an administrator gave it, beside those it reports (#98).
31 +
    pub admin_labels: String,
32 +
    /// A time limit for its jobs in minutes, below the site's, when set.
33 +
    pub timeout_minutes: Option<i64>,
30 34
}
31 35
32 36
/// Which repositories' jobs a runner may take (oxygenhub#41).
…
29 unchanged lines
62 66
        }
63 67
    }
64 68
69 +
    /// Every label it carries: what it reports, and what an administrator
70 +
    /// gave it.
65 71
    pub fn label_list(&self) -> Vec<&str> {
72 +
        let mut out: Vec<&str> = self.labels.split(',').filter(|l| !l.is_empty()).collect();
73 +
        for l in self.admin_label_list() {
74 +
            if !out.contains(&l) {
75 +
                out.push(l);
76 +
            }
77 +
        }
78 +
        out
79 +
    }
80 +
81 +
    pub fn admin_label_list(&self) -> Vec<&str> {
82 +
        self.admin_labels.split(',').filter(|l| !l.is_empty()).collect()
83 +
    }
84 +
85 +
    /// The labels it reported itself, as the admin page distinguishes them.
86 +
    pub fn reported_label_list(&self) -> Vec<&str> {
66 87
        self.labels.split(',').filter(|l| !l.is_empty()).collect()
67 88
    }
68 89
…
27 unchanged lines
96 117
97 118
/// What a claim says about the runner, and that it was seen now. A
98 119
/// configured runner takes the name it claims with.
120 +
/// The administrator's settings for a runner (#98).
121 +
pub async fn set_limits(pool: &Pool, id: i64, admin_labels: &str, timeout_minutes: Option<i64>) -> sqlx::Result<()> {
122 +
    sqlx::query("UPDATE flow_runners SET admin_labels = ?, timeout_minutes = ? WHERE id = ?")
123 +
        .bind(admin_labels)
124 +
        .bind(timeout_minutes)
125 +
        .bind(id)
126 +
        .execute(pool)
127 +
        .await?;
128 +
    Ok(())
129 +
}
130 +
99 131
pub async fn seen(pool: &Pool, id: i64, name: Option<&str>, info: Option<&flow_core::api::RunnerInfo>) -> sqlx::Result<()> {
100 132
    let now = now_ms();
101 133
    match info {
…
208 unchanged lines
modified crates/hub/src/flow.rs +14 −4
…
370 unchanged lines
371 371
        cpus: req.free_cpus,
372 372
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
373 373
    };
374 -
    // The labels it reports now, or those it registered with.
375 -
    let labels: Vec<String> = match &req.info {
374 +
    // The labels it reports now, or those it registered with, and those
375 +
    // an administrator gave it (#98).
376 +
    let mut labels: Vec<String> = match &req.info {
376 377
        Some(i) => i.labels.clone(),
377 -
        None => me.label_list().into_iter().map(String::from).collect(),
378 +
        None => me.reported_label_list().into_iter().map(String::from).collect(),
378 379
    };
380 +
    for l in me.admin_label_list() {
381 +
        if !labels.iter().any(|x| x == l) {
382 +
            labels.push(l.to_string());
383 +
        }
384 +
    }
379 385
    let Some(job) =
380 386
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
381 387
    else {
…
10 unchanged lines
392 398
    let limits = limits(&app).await;
393 399
    let spec: flow_core::pipeline::Job = serde_json::from_str(&job.spec).map_err(bad)?;
394 400
    let sandbox: flow_core::pipeline::Sandbox = serde_json::from_str(&job.sandbox).map_err(bad)?;
395 -
    let timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
401 +
    let mut timeout_s = spec.timeout_s.or(sandbox.timeout_s).unwrap_or(limits.default_s).min(limits.max_s).max(60);
402 +
    // ...and never past the runner's own limit, when it has one (#98).
403 +
    if let Some(m) = me.timeout_minutes.filter(|m| *m > 0) {
404 +
        timeout_s = timeout_s.min(m as u64 * 60).max(60);
405 +
    }
396 406
    fdb::set_job_timeout(&app.db, job.id, timeout_s).await?;
397 407
    let claim = Claim {
398 408
        job_id: job.id,
…
493 unchanged lines
modified crates/hub/src/web/runners.rs +51 −0
…
162 unchanged lines
163 163
}
164 164
165 165
#[derive(Deserialize)]
166 +
pub struct LimitsForm {
167 +
    #[serde(rename = "_csrf")]
168 +
    csrf_token: String,
169 +
    /// Comma-separated.
170 +
    #[serde(default)]
171 +
    labels: String,
172 +
    /// Minutes; empty for the site's limit.
173 +
    #[serde(default)]
174 +
    timeout_minutes: String,
175 +
}
176 +
177 +
/// `POST /admin/runners/{id}/limits`: the labels an administrator gives a
178 +
/// runner and its own job time limit (#98).
179 +
pub async fn set_limits(
180 +
    State(app): State<Shared>,
181 +
    viewer: Viewer,
182 +
    Path(id): Path<i64>,
183 +
    Form(f): Form<LimitsForm>,
184 +
) -> Result<Response, Error> {
185 +
    let shell = Shell::new(&app, &format!("/admin/runners/{id}"), &viewer);
186 +
    let me = require_admin(&viewer, &shell)?;
187 +
    csrf(&viewer, &f.csrf_token, &shell)?;
188 +
    let r =
189 +
        runners::get(&app.db, id).await.map_err(|e| Error::from_db(shell.clone(), e))?.ok_or_else(|| Error::not_found(shell.clone()))?;
190 +
    let mut labels: Vec<String> = Vec::new();
191 +
    for l in f.labels.split([',', ' ']).map(str::trim).filter(|l| !l.is_empty()) {
192 +
        if !l.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || l.len() > 40 {
193 +
            return Err(Error::bad(shell, format!("label {l:?}: letters, digits, - _ and . only, up to 40 characters")));
194 +
        }
195 +
        if !labels.iter().any(|x| x == l) {
196 +
            labels.push(l.to_string());
197 +
        }
198 +
    }
199 +
    let timeout = match f.timeout_minutes.trim() {
200 +
        "" => None,
201 +
        t => match t.parse::<i64>() {
202 +
            Ok(m) if (1..=24 * 60).contains(&m) => Some(m),
203 +
            _ => return Err(Error::bad(shell, "a time limit is a whole number of minutes from 1 to 1440")),
204 +
        },
205 +
    };
206 +
    runners::set_limits(&app.db, id, &labels.join(","), timeout).await.map_err(|e| Error::from_db(shell.clone(), e))?;
207 +
    let words = format!(
208 +
        "labels {}; time limit {}",
209 +
        if labels.is_empty() { "none".to_string() } else { labels.join(", ") },
210 +
        timeout.map(|m| format!("{m} min")).unwrap_or_else(|| "the site's".into())
211 +
    );
212 +
    accounts::audit(&app.db, Some(&me), "flow.runner.limits", &r.name, &words).await.ok();
213 +
    Ok(Redirect::to(&format!("/admin/runners/{id}")).into_response())
214 +
}
215 +
216 +
#[derive(Deserialize)]
166 217
pub struct StateForm {
167 218
    #[serde(rename = "_csrf")]
168 219
    csrf_token: String,
…
187 unchanged lines
modified crates/hub/tests/flow.rs +22 −0
…
1140 unchanged lines
1141 1141
    let listed: serde_json::Value = serde_json::from_str(&body).unwrap();
1142 1142
    let plex_row = listed.as_array().unwrap().iter().find(|r| r["name"] == "plex").unwrap();
1143 1143
    assert_eq!((plex_row["running"].as_i64(), plex_row["arch"].as_str()), (Some(1), Some("x86_64")));
1144 +
    // A runner's own labels and time limit from its page (#98).
1145 +
    let (_, page) = web("GET", format!("/admin/runners/{}", plex.id), None).await;
1146 +
    let (st, _) = web(
1147 +
        "POST",
1148 +
        format!("/admin/runners/{}/limits", plex.id),
1149 +
        Some(format!("_csrf={}&labels=gpu%2C+plex&timeout_minutes=5", csrf_of(&page))),
1150 +
    )
1151 +
    .await;
1152 +
    assert_eq!(st, StatusCode::SEE_OTHER);
1153 +
    let (_, page) = web("GET", format!("/admin/runners/{}", plex.id), None).await;
1154 +
    assert!(page.contains(">gpu<") && page.contains("5 min for its jobs"), "{page}");
1155 +
    let (_, body) = call(&h.app, "GET", "/api/v1/runners", Some(&admin_token), None).await;
1156 +
    let listed: serde_json::Value = serde_json::from_str(&body).unwrap();
1157 +
    let plex_row = listed.as_array().unwrap().iter().find(|r| r["name"] == "plex").unwrap();
1158 +
    assert!(plex_row["labels"].as_array().unwrap().iter().any(|l| l == "gpu"), "{plex_row}");
1159 +
    let (st, _) = web(
1160 +
        "POST",
1161 +
        format!("/admin/runners/{}/limits", plex.id),
1162 +
        Some(format!("_csrf={}&labels=bad%20label%21&timeout_minutes=", csrf_of(&page))),
1163 +
    )
1164 +
    .await;
1165 +
    assert_eq!(st, StatusCode::BAD_REQUEST, "a label with a space or punctuation is refused");
1144 1166
    let (_, body) = call(&h.app, "GET", "/api/v1/runners/queue", Some(&admin_token), None).await;
1145 1167
    let queue: serde_json::Value = serde_json::from_str(&body).unwrap();
1146 1168
    assert!(queue.as_array().unwrap().iter().any(|q| q["status"] == "running" && q["runner"] == "plex"), "{queue}");
…
382 unchanged lines
modified templates/admin_runner.html +13 −1
…
6 unchanged lines
7 7
  <dl class="facts">
8 8
    <dt>Signing key</dt><dd>{% if r.key.is_empty() %}<span class="mute">bound on its first signed receipt</span>{% else %}<code class="full">{{ r.key }}</code> {{ r.key_name }}{% endif %}</dd>
9 9
    <dt>Machine</dt><dd>{% if r.arch.is_empty() %}<span class="mute">not reported yet</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}</dd>
10 -
    <dt>Labels</dt><dd>{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}{% if r.label_list().is_empty() %}<span class="mute">none</span>{% endif %}</dd>
10 +
    <dt>Labels</dt><dd>{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}{% if r.label_list().is_empty() %}<span class="mute">none</span>{% endif %}{% if !r.admin_label_list().is_empty() %} <span class="mute small">({{ r.admin_label_list().len() }} set here)</span>{% endif %}</dd>
11 +
    <dt>Time limit</dt><dd>{% if let Some(m) = r.timeout_minutes %}{{ m }} min for its jobs, below the site's{% else %}<span class="mute">the site's</span>{% endif %}</dd>
11 12
    <dt>Capacity</dt><dd>{{ r.max_jobs|plural("job", "jobs") }} at once{% if let Some(m) = r.memory_mib %}, {{ m }} MiB{% endif %}{% if let Some(c) = r.cpus %}, {{ c }} vCPU{% endif %}</dd>
12 13
    <dt>Running now</dt><dd>{{ running|plural("job", "jobs") }}</dd>
13 14
    <dt>Ran</dt><dd>{{ jobs|plural("job", "jobs") }}, {{ passed }} passed{% if jobs > 0 %} ({{ passed * 100 / jobs }}%){% endif %}, busy {{ self.busy() }}</dd>
…
2 unchanged lines
16 17
  </dl>
17 18
</section>
18 19
<section class="panel">
20 +
  <h2>Settings</h2>
21 +
  <form class="card" method="post" action="/admin/runners/{{ r.id }}/limits">
22 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
23 +
    <label>Labels given here, beside those it reports{% if !r.reported_label_list().is_empty() %} ({{ r.reported_label_list().join(", ") }}){% endif %}</label>
24 +
    <input type="text" name="labels" value="{{ r.admin_labels }}" placeholder="gpu, plex">
25 +
    <label>Job time limit in minutes (empty: the site's)</label>
26 +
    <input type="number" name="timeout_minutes" min="1" max="1440" value="{% if let Some(m) = r.timeout_minutes %}{{ m }}{% endif %}">
27 +
    <button type="submit">SAVE</button>
28 +
  </form>
29 +
</section>
30 +
<section class="panel">
19 31
  <h2>Jobs</h2>
20 32
  {% if history.is_empty() %}<p class="mute">No jobs yet.</p>{% else %}
21 33
  <div class="table"><table>
…
15 unchanged lines