Runners: registration, Admin -> Runners, history, drain/disable/remove b6b7e4cf6b24
Runners live in the database. An administrator creates a one-time registration token on Admin -> Runners; flow runner register exchanges it for the runner's own credential and binds its signing key (POST /api/v1/flow/runners/register). Runners with hub.toml tokens keep working and are tracked from their first request; their bound keys move over. Claims record which runner took a job and its description (architecture, OS, version, labels, capacity). The Runners page shows each runner's status, key, machine, labels, capacity, running jobs and last seen, with drain, disable, resume and remove; a runner's page shows its totals and job history. The oxygenflow pin moves to registration. closes #45 refs #41
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 11:34 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b6b7e4cf6b24ea46b05b4e7672182b94d7aa0fd9c1785068bc36f7ae5c594662- Depends on
- 19bb31ff1f5c 380996db84e5 42fa95540468 8fb35df7d66e 93be9ce4653e a3c8288b0141 c5f3bde466d1 cc19697f8871 d23464c0ac60 df940d75cd3d
- Depended on by
- 1ec29691b67a 5d66f27cebe2 8501fd5815b9 03bfb945f5c1 737de0fac722 e97500b70f9a a7aa76ac52d7 2c31915f5c89 76f93eaefe45 107eaba52c9d 55673c650986 e9b9619cd857 c8684d44d549 3b1bc27fe805 d1bf2d698777 5d90e0e045e2 0024ccb1b050 4a4400c5812d 6246d1e51f4f 757195ad7a5d 750579ecc721 6ca0b971ff07 a46d15e72101 501d8b268dd4 2fb70d019851 92a797b20912 4d2e44d79b03 e8e7158bbc73 efd29a0033e8 4ac01a732552 2a83f5303458 e71846aa07b9 3556d08e17f9 7f5f6c056d8f d94449e42718 5dead1f4f26d eef677c48cd1 c4c87f481d05 7974adb0be66 b66e70b7b6c7 74be97962ed8 5533e91a2aa4 e9bfea8e26db 38833ed78707
- 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.1.0 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
- 32 atoms, 5 tree operations, 16 files
Browse the tree at this change
added crates/hub/migrations/0023_flow_runners.sql +39 −0
| 1 | + | -- Runners (hub spec §13.2): registered from the UI with a one-time token, |
|
| 2 | + | -- or configured with a hub.toml token (tracked here from their first |
|
| 3 | + | -- claim). A runner signs receipts with one key: bound at registration, or |
|
| 4 | + | -- on the first signed receipt of a configured runner. |
|
| 5 | + | CREATE TABLE flow_runners ( |
|
| 6 | + | id INTEGER PRIMARY KEY, |
|
| 7 | + | name TEXT NOT NULL, |
|
| 8 | + | token_hash TEXT NOT NULL UNIQUE, |
|
| 9 | + | -- 'registered' or 'config' |
|
| 10 | + | source TEXT NOT NULL, |
|
| 11 | + | key TEXT NOT NULL DEFAULT '', |
|
| 12 | + | key_name TEXT NOT NULL DEFAULT '', |
|
| 13 | + | arch TEXT NOT NULL DEFAULT '', |
|
| 14 | + | os TEXT NOT NULL DEFAULT '', |
|
| 15 | + | version TEXT NOT NULL DEFAULT '', |
|
| 16 | + | -- comma-separated |
|
| 17 | + | labels TEXT NOT NULL DEFAULT '', |
|
| 18 | + | max_jobs INTEGER NOT NULL DEFAULT 1, |
|
| 19 | + | memory_mib INTEGER, |
|
| 20 | + | cpus INTEGER, |
|
| 21 | + | -- 'active', 'draining' (finishes its jobs, claims no more) or 'disabled' |
|
| 22 | + | state TEXT NOT NULL DEFAULT 'active', |
|
| 23 | + | created_ms INTEGER NOT NULL, |
|
| 24 | + | created_by TEXT NOT NULL DEFAULT '', |
|
| 25 | + | last_seen_ms INTEGER |
|
| 26 | + | ); |
|
| 27 | + | -- Keys already bound to configured runners move over. |
|
| 28 | + | INSERT INTO flow_runners (name, token_hash, source, key, key_name, created_ms) |
|
| 29 | + | SELECT name, token_hash, 'config', key, name, first_ms FROM flow_runner_keys; |
|
| 30 | + | CREATE TABLE flow_runner_registrations ( |
|
| 31 | + | token_hash TEXT PRIMARY KEY, |
|
| 32 | + | created_by TEXT NOT NULL, |
|
| 33 | + | created_ms INTEGER NOT NULL, |
|
| 34 | + | expires_ms INTEGER NOT NULL, |
|
| 35 | + | used_ms INTEGER, |
|
| 36 | + | runner_id INTEGER |
|
| 37 | + | ); |
|
| 38 | + | ALTER TABLE flow_jobs ADD COLUMN runner_id INTEGER; |
|
| 39 | + | CREATE INDEX flow_jobs_runner ON flow_jobs(runner_id); |
added crates/hub/src/db/runners.rs +246 −0
| 1 | + | //! OxygenFlow runners (hub spec §13.2): who may claim jobs, what they are, |
|
| 2 | + | //! whether they are taking work, and what they have run. |
|
| 3 | + | ||
| 4 | + | use super::{now_ms, Pool}; |
|
| 5 | + | use sqlx::FromRow; |
|
| 6 | + | ||
| 7 | + | #[derive(Clone, Debug, FromRow)] |
|
| 8 | + | pub struct Runner { |
|
| 9 | + | pub id: i64, |
|
| 10 | + | pub name: String, |
|
| 11 | + | pub token_hash: String, |
|
| 12 | + | pub source: String, |
|
| 13 | + | pub key: String, |
|
| 14 | + | pub key_name: String, |
|
| 15 | + | pub arch: String, |
|
| 16 | + | pub os: String, |
|
| 17 | + | pub version: String, |
|
| 18 | + | pub labels: String, |
|
| 19 | + | pub max_jobs: i64, |
|
| 20 | + | pub memory_mib: Option<i64>, |
|
| 21 | + | pub cpus: Option<i64>, |
|
| 22 | + | pub state: String, |
|
| 23 | + | pub created_ms: i64, |
|
| 24 | + | pub created_by: String, |
|
| 25 | + | pub last_seen_ms: Option<i64>, |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | /// A runner that has not polled for this long is shown offline. |
|
| 29 | + | pub const ONLINE_MS: i64 = 90_000; |
|
| 30 | + | ||
| 31 | + | impl Runner { |
|
| 32 | + | pub fn online(&self) -> bool { |
|
| 33 | + | self.last_seen_ms.is_some_and(|t| now_ms() - t < ONLINE_MS) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | /// `online`, `offline`, `draining` or `disabled`. |
|
| 37 | + | pub fn status(&self) -> &'static str { |
|
| 38 | + | match self.state.as_str() { |
|
| 39 | + | "disabled" => "disabled", |
|
| 40 | + | "draining" => "draining", |
|
| 41 | + | _ if self.online() => "online", |
|
| 42 | + | _ => "offline", |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | pub fn label_list(&self) -> Vec<&str> { |
|
| 47 | + | self.labels.split(',').filter(|l| !l.is_empty()).collect() |
|
| 48 | + | } |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | pub async fn by_token(pool: &Pool, token_hash: &str) -> sqlx::Result<Option<Runner>> { |
|
| 52 | + | sqlx::query_as("SELECT * FROM flow_runners WHERE token_hash = ?").bind(token_hash).fetch_optional(pool).await |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Runner>> { |
|
| 56 | + | sqlx::query_as("SELECT * FROM flow_runners WHERE id = ?").bind(id).fetch_optional(pool).await |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | pub async fn list(pool: &Pool) -> sqlx::Result<Vec<Runner>> { |
|
| 60 | + | sqlx::query_as("SELECT * FROM flow_runners ORDER BY name, id").fetch_all(pool).await |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /// The row of a runner configured with a hub.toml token, made on first sight. |
|
| 64 | + | pub async fn configured(pool: &Pool, token_hash: &str, name: &str) -> sqlx::Result<Runner> { |
|
| 65 | + | sqlx::query("INSERT OR IGNORE INTO flow_runners (name, token_hash, source, created_ms) VALUES (?, ?, 'config', ?)") |
|
| 66 | + | .bind(name) |
|
| 67 | + | .bind(token_hash) |
|
| 68 | + | .bind(now_ms()) |
|
| 69 | + | .execute(pool) |
|
| 70 | + | .await?; |
|
| 71 | + | Ok(by_token(pool, token_hash).await?.expect("just made")) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | /// What a claim says about the runner, and that it was seen now. A |
|
| 75 | + | /// configured runner takes the name it claims with. |
|
| 76 | + | pub async fn seen(pool: &Pool, id: i64, name: Option<&str>, info: Option<&flow_core::api::RunnerInfo>) -> sqlx::Result<()> { |
|
| 77 | + | let now = now_ms(); |
|
| 78 | + | match info { |
|
| 79 | + | Some(i) => { |
|
| 80 | + | sqlx::query( |
|
| 81 | + | "UPDATE flow_runners SET arch = ?, os = ?, version = ?, labels = ?, max_jobs = ?, memory_mib = ?, cpus = ?, last_seen_ms = ? WHERE id = ?", |
|
| 82 | + | ) |
|
| 83 | + | .bind(&i.arch) |
|
| 84 | + | .bind(&i.os) |
|
| 85 | + | .bind(&i.version) |
|
| 86 | + | .bind(i.labels.join(",")) |
|
| 87 | + | .bind(i.max_jobs as i64) |
|
| 88 | + | .bind(i.memory_mib.map(i64::from)) |
|
| 89 | + | .bind(i.cpus.map(i64::from)) |
|
| 90 | + | .bind(now) |
|
| 91 | + | .bind(id) |
|
| 92 | + | .execute(pool) |
|
| 93 | + | .await?; |
|
| 94 | + | } |
|
| 95 | + | None => { |
|
| 96 | + | sqlx::query("UPDATE flow_runners SET last_seen_ms = ? WHERE id = ?").bind(now).bind(id).execute(pool).await?; |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | if let Some(n) = name { |
|
| 100 | + | sqlx::query("UPDATE flow_runners SET name = ? WHERE id = ? AND source = 'config'").bind(n).bind(id).execute(pool).await?; |
|
| 101 | + | } |
|
| 102 | + | Ok(()) |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | pub async fn set_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> { |
|
| 106 | + | sqlx::query("UPDATE flow_runners SET state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?; |
|
| 107 | + | Ok(()) |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// Forget a runner: its credential stops working. Its jobs keep its name. |
|
| 111 | + | pub async fn remove(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 112 | + | sqlx::query("DELETE FROM flow_runners WHERE id = ?").bind(id).execute(pool).await?; |
|
| 113 | + | Ok(()) |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | /// The key a runner signs receipts with: the one bound, or `key` (named |
|
| 117 | + | /// `name`) when none is yet. |
|
| 118 | + | pub async fn bind_key(pool: &Pool, id: i64, key: &str, name: &str) -> sqlx::Result<String> { |
|
| 119 | + | sqlx::query("UPDATE flow_runners SET key = ?, key_name = ? WHERE id = ? AND key = ''") |
|
| 120 | + | .bind(key) |
|
| 121 | + | .bind(name) |
|
| 122 | + | .bind(id) |
|
| 123 | + | .execute(pool) |
|
| 124 | + | .await?; |
|
| 125 | + | sqlx::query_scalar("SELECT key FROM flow_runners WHERE id = ?").bind(id).fetch_one(pool).await |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | /// Keys that runners of this hub sign with. |
|
| 129 | + | pub async fn keys(pool: &Pool) -> sqlx::Result<Vec<String>> { |
|
| 130 | + | sqlx::query_scalar("SELECT key FROM flow_runners WHERE key != ''").fetch_all(pool).await |
|
| 131 | + | } |
|
| 132 | + | ||
| 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<()> { |
|
| 135 | + | 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) |
|
| 141 | + | .execute(pool) |
|
| 142 | + | .await?; |
|
| 143 | + | Ok(()) |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | /// Registration tokens not used yet and not expired: (created by, created, expires). |
|
| 147 | + | pub async fn pending_registrations(pool: &Pool) -> sqlx::Result<Vec<(String, i64, i64)>> { |
|
| 148 | + | sqlx::query_as("SELECT created_by, created_ms, expires_ms FROM flow_runner_registrations WHERE used_ms IS NULL AND expires_ms > ? ORDER BY created_ms DESC") |
|
| 149 | + | .bind(now_ms()) |
|
| 150 | + | .fetch_all(pool) |
|
| 151 | + | .await |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | /// Use registration token `token_hash` to register a runner: `None` when |
|
| 155 | + | /// the token is unknown, used or expired. |
|
| 156 | + | #[allow(clippy::too_many_arguments)] |
|
| 157 | + | pub async fn register( |
|
| 158 | + | pool: &Pool, |
|
| 159 | + | registration: &str, |
|
| 160 | + | name: &str, |
|
| 161 | + | token_hash: &str, |
|
| 162 | + | key: &str, |
|
| 163 | + | key_name: &str, |
|
| 164 | + | by_default: &str, |
|
| 165 | + | ) -> sqlx::Result<Option<Runner>> { |
|
| 166 | + | let mut tx = super::begin_write(pool).await?; |
|
| 167 | + | 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) }; |
|
| 175 | + | 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", |
|
| 177 | + | ) |
|
| 178 | + | .bind(name) |
|
| 179 | + | .bind(token_hash) |
|
| 180 | + | .bind(key) |
|
| 181 | + | .bind(key_name) |
|
| 182 | + | .bind(now) |
|
| 183 | + | .bind(if who.is_empty() { by_default } else { &who }) |
|
| 184 | + | .fetch_one(&mut *tx) |
|
| 185 | + | .await?; |
|
| 186 | + | sqlx::query("UPDATE flow_runner_registrations SET used_ms = ?, runner_id = ? WHERE token_hash = ?") |
|
| 187 | + | .bind(now) |
|
| 188 | + | .bind(id) |
|
| 189 | + | .bind(registration) |
|
| 190 | + | .execute(&mut *tx) |
|
| 191 | + | .await?; |
|
| 192 | + | tx.commit().await?; |
|
| 193 | + | get(pool, id).await |
|
| 194 | + | } |
|
| 195 | + | ||
| 196 | + | /// A job a runner ran, for its history. |
|
| 197 | + | #[derive(Clone, Debug, FromRow)] |
|
| 198 | + | pub struct RunnerJob { |
|
| 199 | + | pub owner: String, |
|
| 200 | + | pub repo: String, |
|
| 201 | + | pub run_number: i64, |
|
| 202 | + | pub job: String, |
|
| 203 | + | pub status: String, |
|
| 204 | + | pub started_ms: Option<i64>, |
|
| 205 | + | pub finished_ms: Option<i64>, |
|
| 206 | + | pub receipt_attest: String, |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | /// Jobs runner `id` ran or is running, newest first. |
|
| 210 | + | pub async fn history(pool: &Pool, id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<RunnerJob>> { |
|
| 211 | + | sqlx::query_as( |
|
| 212 | + | "SELECT p.owner AS owner, p.name AS repo, r.number AS run_number, j.name AS job, j.status AS status, j.started_ms AS started_ms, \ |
|
| 213 | + | j.finished_ms AS finished_ms, j.receipt_attest AS receipt_attest \ |
|
| 214 | + | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \ |
|
| 215 | + | WHERE j.runner_id = ? AND (? IS NULL OR j.started_ms < ?) ORDER BY j.started_ms DESC LIMIT ?", |
|
| 216 | + | ) |
|
| 217 | + | .bind(id) |
|
| 218 | + | .bind(before) |
|
| 219 | + | .bind(before) |
|
| 220 | + | .bind(limit) |
|
| 221 | + | .fetch_all(pool) |
|
| 222 | + | .await |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | /// (jobs run, passed, busy milliseconds) of runner `id`. |
|
| 226 | + | pub async fn totals(pool: &Pool, id: i64) -> sqlx::Result<(i64, i64, i64)> { |
|
| 227 | + | sqlx::query_as( |
|
| 228 | + | "SELECT COUNT(*), COALESCE(SUM(status = 'passed'), 0), COALESCE(SUM(finished_ms - started_ms), 0) \ |
|
| 229 | + | FROM flow_jobs WHERE runner_id = ? AND finished_ms IS NOT NULL", |
|
| 230 | + | ) |
|
| 231 | + | .bind(id) |
|
| 232 | + | .fetch_one(pool) |
|
| 233 | + | .await |
|
| 234 | + | } |
|
| 235 | + | ||
| 236 | + | /// Jobs running on each runner now, by runner id. |
|
| 237 | + | pub async fn running(pool: &Pool) -> sqlx::Result<Vec<(i64, i64)>> { |
|
| 238 | + | sqlx::query_as("SELECT runner_id, COUNT(*) FROM flow_jobs WHERE status = 'running' AND runner_id IS NOT NULL GROUP BY runner_id") |
|
| 239 | + | .fetch_all(pool) |
|
| 240 | + | .await |
|
| 241 | + | } |
|
| 242 | + | ||
| 243 | + | pub async fn set_job_runner(pool: &Pool, job_id: i64, runner_id: i64) -> sqlx::Result<()> { |
|
| 244 | + | sqlx::query("UPDATE flow_jobs SET runner_id = ? WHERE id = ?").bind(runner_id).bind(job_id).execute(pool).await?; |
|
| 245 | + | Ok(()) |
|
| 246 | + | } |
added crates/hub/src/web/runners.rs +197 −0
| 1 | + | //! Admin → Runners (hub spec §13.2): the OxygenFlow runners of this hub, |
|
| 2 | + | //! what they are and are running, their history, registration tokens, and |
|
| 3 | + | //! draining, disabling and removing them. |
|
| 4 | + | ||
| 5 | + | use super::settings::csrf; |
|
| 6 | + | use super::{filters, Error, Page, Shell}; |
|
| 7 | + | use crate::auth::Viewer; |
|
| 8 | + | use crate::db::{self, accounts, runners}; |
|
| 9 | + | use crate::Shared; |
|
| 10 | + | use askama::Template; |
|
| 11 | + | use axum::extract::{Form, Path, Query, State}; |
|
| 12 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 13 | + | use serde::Deserialize; |
|
| 14 | + | use std::collections::HashMap; |
|
| 15 | + | ||
| 16 | + | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| 17 | + | match viewer.account() { |
|
| 18 | + | Some(a) if a.is_admin => Ok(a.clone()), |
|
| 19 | + | _ => Err(Error::not_found(shell.clone())), |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | pub struct RunnerRow { |
|
| 24 | + | pub r: runners::Runner, |
|
| 25 | + | /// Jobs it is running now. |
|
| 26 | + | pub running: i64, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | #[derive(Template)] |
|
| 30 | + | #[template(path = "admin_runners.html")] |
|
| 31 | + | pub struct RunnersPage { |
|
| 32 | + | shell: Shell, |
|
| 33 | + | runners: Vec<RunnerRow>, |
|
| 34 | + | /// Registration tokens waiting to be used: (created by, created, expires). |
|
| 35 | + | pending: Vec<(String, i64, i64)>, |
|
| 36 | + | /// A token just made, shown once, with the command that uses it. |
|
| 37 | + | new_token: Option<String>, |
|
| 38 | + | hub: String, |
|
| 39 | + | notice: Option<String>, |
|
| 40 | + | error: Option<String>, |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | async fn page( |
|
| 44 | + | app: &Shared, |
|
| 45 | + | shell: Shell, |
|
| 46 | + | new_token: Option<String>, |
|
| 47 | + | notice: Option<String>, |
|
| 48 | + | error: Option<String>, |
|
| 49 | + | ) -> Result<Page<RunnersPage>, Error> { |
|
| 50 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 51 | + | let list = runners::list(&app.db).await.map_err(e)?; |
|
| 52 | + | let running: HashMap<i64, i64> = runners::running(&app.db).await.map_err(e)?.into_iter().collect(); |
|
| 53 | + | 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(); |
|
| 55 | + | Ok(Page(RunnersPage { hub: app.config.base_url().to_string(), shell, runners: rows, pending, new_token, notice, error })) |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// `GET /admin/runners`. |
|
| 59 | + | pub async fn list(State(app): State<Shared>, viewer: Viewer) -> Result<Page<RunnersPage>, Error> { |
|
| 60 | + | let shell = Shell::new(&app, "/admin/runners", &viewer); |
|
| 61 | + | require_admin(&viewer, &shell)?; |
|
| 62 | + | page(&app, shell, None, None, None).await |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | #[derive(Deserialize)] |
|
| 66 | + | pub struct CsrfOnly { |
|
| 67 | + | #[serde(rename = "_csrf")] |
|
| 68 | + | csrf_token: String, |
|
| 69 | + | } |
|
| 70 | + | ||
| 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> { |
|
| 73 | + | let shell = Shell::new(&app, "/admin/runners", &viewer); |
|
| 74 | + | let me = require_admin(&viewer, &shell)?; |
|
| 75 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 76 | + | let token = format!("flowreg_{}", crate::auth::random_hex(24)); |
|
| 77 | + | runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username) |
|
| 78 | + | .await |
|
| 79 | + | .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 |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | #[derive(Deserialize)] |
|
| 85 | + | pub struct StateForm { |
|
| 86 | + | #[serde(rename = "_csrf")] |
|
| 87 | + | csrf_token: String, |
|
| 88 | + | /// `active`, `draining` or `disabled`. |
|
| 89 | + | state: String, |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | /// `POST /admin/runners/{id}/state`: drain, disable or resume a runner. |
|
| 93 | + | pub async fn set_state( |
|
| 94 | + | State(app): State<Shared>, |
|
| 95 | + | viewer: Viewer, |
|
| 96 | + | Path(id): Path<i64>, |
|
| 97 | + | Form(f): Form<StateForm>, |
|
| 98 | + | ) -> Result<Response, Error> { |
|
| 99 | + | let shell = Shell::new(&app, "/admin/runners", &viewer); |
|
| 100 | + | let me = require_admin(&viewer, &shell)?; |
|
| 101 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 102 | + | if !matches!(f.state.as_str(), "active" | "draining" | "disabled") { |
|
| 103 | + | return Err(Error::bad(shell, "a runner is active, draining or disabled")); |
|
| 104 | + | } |
|
| 105 | + | let r = |
|
| 106 | + | runners::get(&app.db, id).await.map_err(|e| Error::from_db(shell.clone(), e))?.ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 107 | + | runners::set_state(&app.db, id, &f.state).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 108 | + | accounts::audit(&app.db, Some(&me), "flow.runner.state", &r.name, &f.state).await.ok(); |
|
| 109 | + | Ok(Redirect::to("/admin/runners").into_response()) |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | /// `POST /admin/runners/{id}/remove`: forget a runner that runs nothing. |
|
| 113 | + | pub async fn remove( |
|
| 114 | + | State(app): State<Shared>, |
|
| 115 | + | viewer: Viewer, |
|
| 116 | + | Path(id): Path<i64>, |
|
| 117 | + | Form(f): Form<CsrfOnly>, |
|
| 118 | + | ) -> Result<Page<RunnersPage>, Error> { |
|
| 119 | + | let shell = Shell::new(&app, "/admin/runners", &viewer); |
|
| 120 | + | let me = require_admin(&viewer, &shell)?; |
|
| 121 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 122 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 123 | + | let r = runners::get(&app.db, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 124 | + | let busy = runners::running(&app.db).await.map_err(e)?.into_iter().any(|(rid, n)| rid == id && n > 0); |
|
| 125 | + | if busy { |
|
| 126 | + | return page( |
|
| 127 | + | &app, |
|
| 128 | + | shell, |
|
| 129 | + | None, |
|
| 130 | + | None, |
|
| 131 | + | Some(format!("{} is running jobs: drain it first, then remove it when they finish.", r.name)), |
|
| 132 | + | ) |
|
| 133 | + | .await; |
|
| 134 | + | } |
|
| 135 | + | runners::remove(&app.db, id).await.map_err(e)?; |
|
| 136 | + | accounts::audit(&app.db, Some(&me), "flow.runner.remove", &r.name, "").await.ok(); |
|
| 137 | + | let also = if r.source == "config" { |
|
| 138 | + | " It is configured in hub.toml; remove its token there too, or it comes back on its next claim." |
|
| 139 | + | } else { |
|
| 140 | + | "" |
|
| 141 | + | }; |
|
| 142 | + | page(&app, shell, None, Some(format!("Removed {}.{also}", r.name)), None).await |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | #[derive(Template)] |
|
| 146 | + | #[template(path = "admin_runner.html")] |
|
| 147 | + | pub struct RunnerPage { |
|
| 148 | + | shell: Shell, |
|
| 149 | + | r: runners::Runner, |
|
| 150 | + | running: i64, |
|
| 151 | + | history: Vec<runners::RunnerJob>, |
|
| 152 | + | older: Option<i64>, |
|
| 153 | + | jobs: i64, |
|
| 154 | + | passed: i64, |
|
| 155 | + | busy_ms: i64, |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | impl RunnerPage { |
|
| 159 | + | /// Time spent running jobs, as pages show durations. |
|
| 160 | + | pub fn busy(&self) -> String { |
|
| 161 | + | crate::flow::human_ms(self.busy_ms) |
|
| 162 | + | } |
|
| 163 | + | } |
|
| 164 | + | ||
| 165 | + | #[derive(Deserialize, Default)] |
|
| 166 | + | pub struct HistoryQuery { |
|
| 167 | + | before: Option<i64>, |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | const PER_PAGE: i64 = 50; |
|
| 171 | + | ||
| 172 | + | /// `GET /admin/runners/{id}`: a runner and the jobs it ran. |
|
| 173 | + | pub async fn show( |
|
| 174 | + | State(app): State<Shared>, |
|
| 175 | + | viewer: Viewer, |
|
| 176 | + | Path(id): Path<i64>, |
|
| 177 | + | Query(q): Query<HistoryQuery>, |
|
| 178 | + | ) -> Result<Page<RunnerPage>, Error> { |
|
| 179 | + | let shell = Shell::new(&app, &format!("/admin/runners/{id}"), &viewer); |
|
| 180 | + | require_admin(&viewer, &shell)?; |
|
| 181 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 182 | + | let r = runners::get(&app.db, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 183 | + | let mut history = runners::history(&app.db, id, PER_PAGE + 1, q.before).await.map_err(e)?; |
|
| 184 | + | let older = (history.len() as i64 > PER_PAGE).then(|| history[PER_PAGE as usize - 1].started_ms).flatten(); |
|
| 185 | + | history.truncate(PER_PAGE as usize); |
|
| 186 | + | let (jobs, passed, busy_ms) = runners::totals(&app.db, id).await.map_err(e)?; |
|
| 187 | + | let running = runners::running(&app.db).await.map_err(e)?.into_iter().find(|(rid, _)| *rid == id).map(|(_, n)| n).unwrap_or(0); |
|
| 188 | + | Ok(Page(RunnerPage { shell, r, running, history, older, jobs, passed, busy_ms })) |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | pub fn duration(j: &runners::RunnerJob) -> String { |
|
| 192 | + | match (j.started_ms, j.finished_ms) { |
|
| 193 | + | (Some(a), Some(b)) => crate::flow::human_ms(b - a), |
|
| 194 | + | (Some(a), None) => crate::flow::human_ms(db::now_ms() - a), |
|
| 195 | + | _ => "–".into(), |
|
| 196 | + | } |
|
| 197 | + | } |
added templates/admin_runner.html +36 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}{{ r.name }} · Runners{% endblock %} |
|
| 3 | + | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | + | {% block content %} |
|
| 5 | + | <div class="head"><h1>{{ r.name }} <small><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span> · <a href="/admin/runners">Runners</a></small></h1></div> |
|
| 6 | + | <section class="panel"> |
|
| 7 | + | <dl class="facts"> |
|
| 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 | + | <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> |
|
| 11 | + | <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 | + | <dt>Running now</dt><dd>{{ running|plural("job", "jobs") }}</dd> |
|
| 13 | + | <dt>Ran</dt><dd>{{ jobs|plural("job", "jobs") }}, {{ passed }} passed{% if jobs > 0 %} ({{ passed * 100 / jobs }}%){% endif %}, busy {{ self.busy() }}</dd> |
|
| 14 | + | <dt>Registered</dt><dd>{% if r.source == "config" %}configured in hub.toml{% else %}by {{ r.created_by }}{% endif %}, <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></dd> |
|
| 15 | + | <dt>Last seen</dt><dd>{% if let Some(t) = r.last_seen_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% else %}never{% endif %}</dd> |
|
| 16 | + | </dl> |
|
| 17 | + | </section> |
|
| 18 | + | <section class="panel"> |
|
| 19 | + | <h2>Jobs</h2> |
|
| 20 | + | {% if history.is_empty() %}<p class="mute">No jobs yet.</p>{% else %} |
|
| 21 | + | <div class="table"><table> |
|
| 22 | + | <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Duration</th><th>Started</th><th>Receipt</th></tr> |
|
| 23 | + | {% for j in history %}<tr> |
|
| 24 | + | <td><a href="/{{ j.owner }}/{{ j.repo }}">{{ j.owner }}/{{ j.repo }}</a></td> |
|
| 25 | + | <td><a href="/{{ j.owner }}/{{ j.repo }}/flow/{{ j.run_number }}">#{{ j.run_number }}</a></td> |
|
| 26 | + | <td>{{ j.job }}</td> |
|
| 27 | + | <td><span class="flow-glyph st-{{ j.status }}">{{ crate::web::flow::glyph(j.status) }}</span> {{ j.status }}</td> |
|
| 28 | + | <td>{{ crate::web::runners::duration(j) }}</td> |
|
| 29 | + | <td class="small">{% if let Some(t) = j.started_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}</td> |
|
| 30 | + | <td class="small">{% if !j.receipt_attest.is_empty() %}<a href="/{{ j.owner }}/{{ j.repo }}/receipts/{{ j.receipt_attest }}">signed</a>{% endif %}</td> |
|
| 31 | + | </tr>{% endfor %} |
|
| 32 | + | </table></div> |
|
| 33 | + | {% if let Some(b) = older %}<p><a href="?before={{ b }}">Older jobs</a></p>{% endif %} |
|
| 34 | + | {% endif %} |
|
| 35 | + | </section> |
|
| 36 | + | {% endblock %} |
added templates/admin_runners.html +45 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Runners · Administration{% endblock %} |
|
| 3 | + | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | + | {% block content %} |
|
| 5 | + | <div class="head"><h1>Runners <small>{{ runners.len()|plural("runner", "runners") }} · <a href="/admin">Administration</a></small></h1></div> |
|
| 6 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 7 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 8 | + | {% if let Some(t) = new_token %} |
|
| 9 | + | <section class="panel strip ok"> |
|
| 10 | + | <h2>Registration token</h2> |
|
| 11 | + | <p>Use it once, within a day, on the machine that will run jobs. It is shown only now.</p> |
|
| 12 | + | <pre class="snippet">flow runner register {{ hub }} --token {{ t }} --name NAME [--ca hub-ca.pem] [--label LABEL] |
|
| 13 | + | flow runner</pre> |
|
| 14 | + | <p class="small mute">The runner sends its signing identity when it registers; this hub accepts receipts from it signed by that key only.</p> |
|
| 15 | + | </section> |
|
| 16 | + | {% endif %} |
|
| 17 | + | <section class="panel"> |
|
| 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 | + | <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> |
|
| 21 | + | {% for row in runners %}{% let r = row.r %} |
|
| 22 | + | <tr> |
|
| 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 | + | <td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td> |
|
| 25 | + | <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 | + | <td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}</td> |
|
| 27 | + | <td class="small">{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}</td> |
|
| 28 | + | <td class="small">{{ r.max_jobs|plural("job", "jobs") }}{% if let Some(m) = r.memory_mib %} · {{ m }} MiB{% endif %}{% if let Some(c) = r.cpus %} · {{ c }} vCPU{% endif %}</td> |
|
| 29 | + | <td>{{ row.running }}</td> |
|
| 30 | + | <td class="small">{% if let Some(t) = r.last_seen_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 31 | + | <td><form class="tiny" method="post" action="/admin/runners/{{ r.id }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 32 | + | {% if r.state == "active" %}<button name="state" value="draining" title="Finish current jobs, take no new ones">drain</button> <button name="state" value="disabled" title="Refuse its requests at once">disable</button>{% else %}<button name="state" value="active">resume</button>{% endif %}</form> |
|
| 33 | + | <form class="tiny" method="post" action="/admin/runners/{{ r.id }}/remove" onsubmit="return confirm('Remove {{ r.name }}? Its credential stops working; its jobs keep its name.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form></td> |
|
| 34 | + | </tr> |
|
| 35 | + | {% endfor %} |
|
| 36 | + | </table></div> |
|
| 37 | + | {% endif %} |
|
| 38 | + | </section> |
|
| 39 | + | <section class="panel"> |
|
| 40 | + | <h2>Add a runner</h2> |
|
| 41 | + | <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> |
|
| 43 | + | {% 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 | + | </section> |
|
| 45 | + | {% endblock %} |
modified crates/hub/src/api/mod.rs +1 −0
| … | 25 unchanged lines |
||
| 26 | 26 | Router::new() |
|
| 27 | 27 | .fallback(no_route) |
|
| 28 | 28 | .route("/flow/claim", post(crate::flow::claim)) |
|
| 29 | + | .route("/flow/runners/register", post(crate::flow::register)) |
|
| 29 | 30 | .route("/flow/jobs/{id}/log", post(crate::flow::log)) |
|
| 30 | 31 | .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) |
|
| 31 | 32 | .route( |
|
| … | 1637 unchanged lines |
modified crates/hub/src/db/flow.rs +0 −13
| … | 425 unchanged lines |
||
| 426 | 426 | .await |
|
| 427 | 427 | } |
|
| 428 | 428 | ||
| 429 | - | /// The key runner token `token_hash` signs with: the one already bound, or |
|
| 430 | - | /// `key` (named `name`) when this is the first. |
|
| 431 | - | pub async fn bind_runner_key(pool: &Pool, token_hash: &str, key: &str, name: &str) -> sqlx::Result<String> { |
|
| 432 | - | sqlx::query("INSERT OR IGNORE INTO flow_runner_keys (token_hash, key, name, first_ms) VALUES (?, ?, ?, ?)") |
|
| 433 | - | .bind(token_hash) |
|
| 434 | - | .bind(key) |
|
| 435 | - | .bind(name) |
|
| 436 | - | .bind(crate::db::now_ms()) |
|
| 437 | - | .execute(pool) |
|
| 438 | - | .await?; |
|
| 439 | - | sqlx::query_scalar("SELECT key FROM flow_runner_keys WHERE token_hash = ?").bind(token_hash).fetch_one(pool).await |
|
| 440 | - | } |
|
| 441 | - | ||
| 442 | 429 | /// Record what became of job `job_id`'s signed receipt. |
|
| 443 | 430 | pub async fn set_receipt(pool: &Pool, job_id: i64, attest: &str, signer: &str, note: &str) -> sqlx::Result<()> { |
|
| 444 | 431 | sqlx::query("UPDATE flow_jobs SET receipt_attest = ?, receipt_signer = ?, receipt_note = ? WHERE id = ?") |
|
| … | 67 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 10 unchanged lines |
||
| 11 | 11 | pub mod proposals; |
|
| 12 | 12 | pub mod releases; |
|
| 13 | 13 | pub mod repos; |
|
| 14 | + | pub mod runners; |
|
| 14 | 15 | ||
| 15 | 16 | use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; |
|
| 16 | 17 | use sqlx::SqlitePool; |
|
| … | 30 unchanged lines |
modified crates/hub/src/flow.rs +55 −14
| … | 180 unchanged lines |
||
| 181 | 181 | } |
|
| 182 | 182 | } |
|
| 183 | 183 | ||
| 184 | - | /// The runner's token, checked, as the BLAKE3 hex that names it. |
|
| 185 | - | fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<String, ApiError> { |
|
| 184 | + | /// The runner a bearer token belongs to: a registered runner, or one |
|
| 185 | + | /// configured with a hub.toml token (tracked from its first request). A |
|
| 186 | + | /// disabled runner is refused. |
|
| 187 | + | async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> { |
|
| 186 | 188 | let Some(t) = oxy_server::bearer_token(headers) else { |
|
| 187 | 189 | return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into())); |
|
| 190 | + | }; |
|
| 191 | + | let hash = blake3::hash(t.as_bytes()).to_hex().to_string(); |
|
| 192 | + | let runner = match db::runners::by_token(&app.db, &hash).await? { |
|
| 193 | + | Some(r) => r, |
|
| 194 | + | None if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) => { |
|
| 195 | + | db::runners::configured(&app.db, &hash, "runner").await? |
|
| 196 | + | } |
|
| 197 | + | None => return Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())), |
|
| 188 | 198 | }; |
|
| 189 | - | if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) { |
|
| 190 | - | Ok(blake3::hash(t.as_bytes()).to_hex().to_string()) |
|
| 191 | - | } else { |
|
| 192 | - | Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into())) |
|
| 199 | + | if runner.state == "disabled" { |
|
| 200 | + | return Err(ApiError(StatusCode::FORBIDDEN, format!("runner {} is disabled on this hub", runner.name))); |
|
| 193 | 201 | } |
|
| 202 | + | Ok(runner) |
|
| 194 | 203 | } |
|
| 195 | 204 | ||
| 205 | + | /// `POST /api/v1/flow/runners/register`: a one-time registration token |
|
| 206 | + | /// from Admin → Runners buys a runner credential, binding the runner's |
|
| 207 | + | /// signing identity (hub spec §13.2). |
|
| 208 | + | pub async fn register( |
|
| 209 | + | State(app): State<Shared>, |
|
| 210 | + | Json(req): Json<flow_core::api::Register>, |
|
| 211 | + | ) -> Result<Json<flow_core::api::Registered>, ApiError> { |
|
| 212 | + | let bytes = hex::decode(req.identity.trim()).map_err(|_| ApiError(StatusCode::BAD_REQUEST, "identity: not hex".into()))?; |
|
| 213 | + | let identity = oxy_core::identity::Identity::decode(&bytes).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("identity: {e}")))?; |
|
| 214 | + | let name: String = req.name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).take(64).collect(); |
|
| 215 | + | if name.is_empty() { |
|
| 216 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "give the runner a name of letters, digits, - _ or .".into())); |
|
| 217 | + | } |
|
| 218 | + | let credential = format!("flowrunner_{}", crate::auth::random_hex(32)); |
|
| 219 | + | let hash = blake3::hash(credential.as_bytes()).to_hex().to_string(); |
|
| 220 | + | let reg = blake3::hash(req.token.trim().as_bytes()).to_hex().to_string(); |
|
| 221 | + | let runner = |
|
| 222 | + | db::runners::register(&app.db, ®, &name, &hash, &identity.hash().to_hex(), &identity.name, "").await?.ok_or_else(|| { |
|
| 223 | + | ApiError(StatusCode::FORBIDDEN, "that registration token is unknown, used or expired; create one on Admin → Runners".into()) |
|
| 224 | + | })?; |
|
| 225 | + | db::runners::seen(&app.db, runner.id, None, Some(&req.info)).await?; |
|
| 226 | + | tracing::info!("flow: runner {} registered (key {})", runner.name, &runner.key[..12]); |
|
| 227 | + | Ok(Json(flow_core::api::Registered { id: runner.id, name: runner.name, credential })) |
|
| 228 | + | } |
|
| 229 | + | ||
| 196 | 230 | /// `POST /api/v1/flow/claim`. |
|
| 197 | 231 | pub async fn claim(State(app): State<Shared>, headers: HeaderMap, Json(req): Json<ClaimRequest>) -> Result<Response, ApiError> { |
|
| 198 | - | runner_auth(&app, &headers)?; |
|
| 199 | - | let runner: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect(); |
|
| 232 | + | let me = runner_auth(&app, &headers).await?; |
|
| 233 | + | let claimed: String = req.runner.chars().filter(|c| !c.is_control()).take(64).collect(); |
|
| 234 | + | db::runners::seen(&app.db, me.id, Some(&claimed), req.info.as_ref()).await?; |
|
| 235 | + | // A draining runner finishes what it has and takes nothing new. |
|
| 236 | + | if me.state == "draining" { |
|
| 237 | + | return Ok(StatusCode::NO_CONTENT.into_response()); |
|
| 238 | + | } |
|
| 239 | + | let runner = if me.source == "registered" { me.name.clone() } else { claimed }; |
|
| 200 | 240 | let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32)); |
|
| 201 | 241 | let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus }; |
|
| 202 | 242 | let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room).await? else { |
|
| 203 | 243 | return Ok(StatusCode::NO_CONTENT.into_response()); |
|
| 204 | 244 | }; |
|
| 245 | + | db::runners::set_job_runner(&app.db, job.id, me.id).await?; |
|
| 205 | 246 | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 206 | 247 | let row = |
|
| 207 | 248 | db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?; |
|
| … | 120 unchanged lines |
||
| 328 | 369 | Path(id): Path<i64>, |
|
| 329 | 370 | Json(mut batch): Json<LogBatch>, |
|
| 330 | 371 | ) -> Result<StatusCode, ApiError> { |
|
| 331 | - | runner_auth(&app, &headers)?; |
|
| 372 | + | runner_auth(&app, &headers).await?; |
|
| 332 | 373 | let job = running_job(&app, id).await?; |
|
| 333 | 374 | for r in &mut batch.records { |
|
| 334 | 375 | if r.text.len() > MAX_RECORD_TEXT { |
|
| … | 26 unchanged lines |
||
| 361 | 402 | Path((id, name)): Path<(i64, String)>, |
|
| 362 | 403 | body: axum::body::Bytes, |
|
| 363 | 404 | ) -> Result<Response, ApiError> { |
|
| 364 | - | runner_auth(&app, &headers)?; |
|
| 405 | + | runner_auth(&app, &headers).await?; |
|
| 365 | 406 | let job = running_job(&app, id).await?; |
|
| 366 | 407 | let spec: flow_core::Job = |
|
| 367 | 408 | serde_json::from_str(&job.spec).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}")))?; |
|
| … | 23 unchanged lines |
||
| 391 | 432 | Path(id): Path<i64>, |
|
| 392 | 433 | Json(f): Json<Finish>, |
|
| 393 | 434 | ) -> Result<StatusCode, ApiError> { |
|
| 394 | - | let token = runner_auth(&app, &headers)?; |
|
| 435 | + | let me = runner_auth(&app, &headers).await?; |
|
| 395 | 436 | let job = running_job(&app, id).await?; |
|
| 396 | 437 | let status = match (f.exit_code, &f.error) { |
|
| 397 | 438 | (Some(0), None) => "passed", |
|
| … | 22 unchanged lines |
||
| 420 | 461 | if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? { |
|
| 421 | 462 | if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? { |
|
| 422 | 463 | if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) { |
|
| 423 | - | let (attest, signer, note) = match keep_receipt(&app, &token, &row, &run, r, signed).await { |
|
| 464 | + | let (attest, signer, note) = match keep_receipt(&app, &me, &row, &run, r, signed).await { |
|
| 424 | 465 | Ok((attest, signer)) => (attest, signer, String::new()), |
|
| 425 | 466 | Err(e) => { |
|
| 426 | 467 | tracing::warn!("flow: job {} receipt not kept: {e}", job.id); |
|
| … | 23 unchanged lines |
||
| 450 | 491 | /// signed with. |
|
| 451 | 492 | pub async fn keep_receipt( |
|
| 452 | 493 | app: &Shared, |
|
| 453 | - | token_hash: &str, |
|
| 494 | + | runner: &db::runners::Runner, |
|
| 454 | 495 | row: &db::repos::RepoRow, |
|
| 455 | 496 | run: &fdb::Run, |
|
| 456 | 497 | receipt: &flow_core::Receipt, |
|
| … | 12 unchanged lines |
||
| 469 | 510 | return Err("it is for another state than the run's".into()); |
|
| 470 | 511 | } |
|
| 471 | 512 | let key = sr.runner.hash().to_hex(); |
|
| 472 | - | let bound = fdb::bind_runner_key(&app.db, token_hash, &key, &sr.runner.name).await.map_err(|e| e.to_string())?; |
|
| 513 | + | let bound = db::runners::bind_key(&app.db, runner.id, &key, &sr.runner.name).await.map_err(|e| e.to_string())?; |
|
| 473 | 514 | if bound != key { |
|
| 474 | 515 | return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())])); |
|
| 475 | 516 | } |
|
| … | 136 unchanged lines |
modified crates/hub/src/web/mod.rs +6 −0
| … | 23 unchanged lines |
||
| 24 | 24 | mod repo; |
|
| 25 | 25 | mod repo_settings; |
|
| 26 | 26 | mod resolve; |
|
| 27 | + | pub mod runners; |
|
| 27 | 28 | mod session; |
|
| 28 | 29 | mod settings; |
|
| 29 | 30 | mod statics; |
|
| … | 59 unchanged lines |
||
| 89 | 90 | .route("/admin/invite", post(admin::invite)) |
|
| 90 | 91 | .route("/admin/account", post(admin::account)) |
|
| 91 | 92 | .route("/admin/storage", get(admin::storage).post(admin::maintain)) |
|
| 93 | + | .route("/admin/runners", get(runners::list)) |
|
| 94 | + | .route("/admin/runners/token", post(runners::new_token)) |
|
| 95 | + | .route("/admin/runners/{id}", get(runners::show)) |
|
| 96 | + | .route("/admin/runners/{id}/state", post(runners::set_state)) |
|
| 97 | + | .route("/admin/runners/{id}/remove", post(runners::remove)) |
|
| 92 | 98 | .nest("/api/v1", crate::api::router()) |
|
| 93 | 99 | .route("/{owner}", get(home::owner)) |
|
| 94 | 100 | .route("/{owner}/{name}", get(repo::overview)) |
|
| … | 497 unchanged lines |
modified crates/hub/src/web/release_trust.rs +1 −2
| … | 279 unchanged lines |
||
| 280 | 280 | for a in &mut facts.authors { |
|
| 281 | 281 | a.who.account = links.get(&a.who.key).cloned(); |
|
| 282 | 282 | } |
|
| 283 | - | let known_runner_keys: Vec<String> = |
|
| 284 | - | sqlx::query_scalar("SELECT key FROM flow_runner_keys").fetch_all(&app.db).await.unwrap_or_default(); |
|
| 283 | + | let known_runner_keys: Vec<String> = db::runners::keys(&app.db).await.unwrap_or_default(); |
|
| 285 | 284 | ||
| 286 | 285 | let base = format!("{}/{}", app.config.base_url(), row.slug()); |
|
| 287 | 286 | let mut tag_heads: Vec<String> = facts.heads.iter().map(|h| h.to_hex()).collect(); |
|
| … | 308 unchanged lines |
modified crates/hub/tests/flow.rs +126 −0
| … | 984 unchanged lines |
||
| 985 | 985 | assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/demo/releases/trust/v9", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 986 | 986 | assert_eq!(call(&h.app, "GET", "/andy/demo/releases/trust/v9", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 987 | 987 | } |
|
| 988 | + | ||
| 989 | + | /// Runners (hub spec §13.2): an administrator makes a registration token, |
|
| 990 | + | /// a runner registers with it (binding its key), claims, shows on Admin → |
|
| 991 | + | /// Runners with its history, and is drained, disabled and removed. |
|
| 992 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 993 | + | async fn runners_register_show_their_history_and_are_drained_and_removed() { |
|
| 994 | + | use oxy_core::identity::SecretKey; |
|
| 995 | + | let h = hub().await; |
|
| 996 | + | create(&h.app, "andy", "demo").await; |
|
| 997 | + | // A signed-in site administrator. |
|
| 998 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 999 | + | .oneshot( |
|
| 1000 | + | Request::post("/register") |
|
| 1001 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 1002 | + | .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery")) |
|
| 1003 | + | .unwrap(), |
|
| 1004 | + | ) |
|
| 1005 | + | .await |
|
| 1006 | + | .unwrap(); |
|
| 1007 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 1008 | + | let admin = db::accounts::account_by_name(&h.app.db, "carol").await.unwrap().unwrap(); |
|
| 1009 | + | db::accounts::set_admin(&h.app.db, admin.id, true).await.unwrap(); |
|
| 1010 | + | let web = |method: &'static str, path: String, body: Option<String>| { |
|
| 1011 | + | let (app, cookie) = (h.app.clone(), cookie.clone()); |
|
| 1012 | + | async move { |
|
| 1013 | + | let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie); |
|
| 1014 | + | if body.is_some() { |
|
| 1015 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 1016 | + | } |
|
| 1017 | + | let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap(); |
|
| 1018 | + | let st = res.status(); |
|
| 1019 | + | (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
|
| 1020 | + | } |
|
| 1021 | + | }; |
|
| 1022 | + | let csrf_of = |page: &str| { |
|
| 1023 | + | let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 1024 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 1025 | + | }; |
|
| 1026 | + | let (st, page) = web("GET", "/admin/runners".into(), None).await; |
|
| 1027 | + | assert_eq!(st, StatusCode::OK); |
|
| 1028 | + | assert!(page.contains("No runners yet")); |
|
| 1029 | + | assert_eq!(call(&h.app, "GET", "/admin/runners", None, None).await.0, StatusCode::NOT_FOUND, "administrators only"); |
|
| 1030 | + | ||
| 1031 | + | // A one-time registration token, shown once with the command. |
|
| 1032 | + | let (_, page) = web("POST", "/admin/runners/token".into(), Some(format!("_csrf={}", csrf_of(&page)))).await; |
|
| 1033 | + | let i = page.find("--token ").unwrap() + "--token ".len(); |
|
| 1034 | + | let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect(); |
|
| 1035 | + | assert!(token.starts_with("flowreg_"), "{token}"); |
|
| 1036 | + | ||
| 1037 | + | // The runner registers, binding its key; the token then is spent. |
|
| 1038 | + | let sk = SecretKey::generate(); |
|
| 1039 | + | let identity = sk.identity("plex (OxygenFlow runner)", "", 1); |
|
| 1040 | + | let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0", "labels": ["big-disk"], "max_jobs": 2, "memory_mib": 16384, "cpus": 4 }); |
|
| 1041 | + | let reg = serde_json::json!({ "token": token, "name": "plex", "identity": hex::encode(identity.encode()), "info": info }); |
|
| 1042 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await; |
|
| 1043 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1044 | + | let credential = serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string(); |
|
| 1045 | + | assert!(credential.starts_with("flowrunner_")); |
|
| 1046 | + | assert_eq!( |
|
| 1047 | + | call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await.0, |
|
| 1048 | + | StatusCode::FORBIDDEN, |
|
| 1049 | + | "used once" |
|
| 1050 | + | ); |
|
| 1051 | + | ||
| 1052 | + | // It claims with its credential; the hub.toml runner shows too. |
|
| 1053 | + | let claim_as = |who: String| { |
|
| 1054 | + | let app = h.app.clone(); |
|
| 1055 | + | let info = info.clone(); |
|
| 1056 | + | async move { |
|
| 1057 | + | let body = serde_json::json!({ "runner": "ignored", "runtimes": ["microvm"], "info": info }).to_string(); |
|
| 1058 | + | call(&app, "POST", "/api/v1/flow/claim", Some(&who), Some(body)).await |
|
| 1059 | + | } |
|
| 1060 | + | }; |
|
| 1061 | + | assert_eq!(claim_as(credential.clone()).await.0, StatusCode::NO_CONTENT, "nothing queued"); |
|
| 1062 | + | assert_eq!(claim(&h.app).await, None); |
|
| 1063 | + | let (_, page) = web("GET", "/admin/runners".into(), None).await; |
|
| 1064 | + | assert!( |
|
| 1065 | + | page.contains(">plex</strong>") && page.contains("st-online") && page.contains("x86_64 · linux") && page.contains("big-disk"), |
|
| 1066 | + | "{page}" |
|
| 1067 | + | ); |
|
| 1068 | + | assert!(page.contains(">test-1</strong>") && page.contains("hub.toml"), "the configured runner, by its claimed name"); |
|
| 1069 | + | let plex = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "plex").unwrap(); |
|
| 1070 | + | assert_eq!(plex.key, identity.hash().to_hex(), "bound at registration"); |
|
| 1071 | + | ||
| 1072 | + | // A run: plex takes a job, and its history shows it. |
|
| 1073 | + | let url = format!("{}/andy/demo", h.base); |
|
| 1074 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1075 | + | let c = client(&url, work.path()); |
|
| 1076 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 1077 | + | c.snapshot("test").unwrap(); |
|
| 1078 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 1079 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1080 | + | runs_of(&h.app, "andy", "demo").await; |
|
| 1081 | + | let (st, body) = claim_as(credential.clone()).await; |
|
| 1082 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1083 | + | let job: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 1084 | + | let (_, page) = web("GET", format!("/admin/runners/{}", plex.id), None).await; |
|
| 1085 | + | assert!( |
|
| 1086 | + | page.contains("andy/demo") && page.contains(job["job"]["name"].as_str().unwrap()) && page.contains("Running now</dt><dd>1 job"), |
|
| 1087 | + | "{page}" |
|
| 1088 | + | ); |
|
| 1089 | + | ||
| 1090 | + | // Draining: no new work; removing while busy is refused. |
|
| 1091 | + | let (_, page) = web("GET", "/admin/runners".into(), None).await; |
|
| 1092 | + | let csrf = csrf_of(&page); |
|
| 1093 | + | web("POST", format!("/admin/runners/{}/state", plex.id), Some(format!("_csrf={csrf}&state=draining"))).await; |
|
| 1094 | + | assert_eq!(claim_as(credential.clone()).await.0, StatusCode::NO_CONTENT, "a draining runner takes nothing"); |
|
| 1095 | + | let (_, page) = web("POST", format!("/admin/runners/{}/remove", plex.id), Some(format!("_csrf={csrf}"))).await; |
|
| 1096 | + | assert!(page.contains("is running jobs: drain it first"), "{page}"); |
|
| 1097 | + | // Its job finishes; disabled, it is refused; removed, its credential is dead. |
|
| 1098 | + | let id = job["job_id"].as_i64().unwrap(); |
|
| 1099 | + | let done = call( |
|
| 1100 | + | &h.app, |
|
| 1101 | + | "POST", |
|
| 1102 | + | &format!("/api/v1/flow/jobs/{id}/finish"), |
|
| 1103 | + | Some(&credential), |
|
| 1104 | + | Some(r#"{"exit_code":0,"duration_ms":5}"#.into()), |
|
| 1105 | + | ) |
|
| 1106 | + | .await; |
|
| 1107 | + | assert_eq!(done.0, StatusCode::NO_CONTENT); |
|
| 1108 | + | web("POST", format!("/admin/runners/{}/state", plex.id), Some(format!("_csrf={csrf}&state=disabled"))).await; |
|
| 1109 | + | assert_eq!(claim_as(credential.clone()).await.0, StatusCode::FORBIDDEN); |
|
| 1110 | + | let (_, page) = web("POST", format!("/admin/runners/{}/remove", plex.id), Some(format!("_csrf={csrf}"))).await; |
|
| 1111 | + | assert!(page.contains("Removed plex."), "{page}"); |
|
| 1112 | + | assert_eq!(claim_as(credential).await.0, StatusCode::UNAUTHORIZED); |
|
| 1113 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +14 −0
| … | 441 unchanged lines |
||
| 442 | 442 | - **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state, numbered within its repository from 1 (the run's page is `/{owner}/{name}/flow/{number}`): the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs. A run waiting for a runner says where it stands: on the Flow tab its place in line, on its page also the jobs running now and those queued ahead of it (named when their repository is this one or public, otherwise counted), and in the API `queue_position`. Writers also start runs without moving a bookmark: **Run pipeline** on the Flow tab runs a bookmark's current state, and **Re-run** on a finished run's page runs its state again. The new run's checks replace the old ones for that state, and the run page says what started it (pushed by, started by, re-run of #N by). |
|
| 443 | 443 | - **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`. |
|
| 444 | 444 | - **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it. |
|
| 445 | + | - **Runners** (Admin → Runners, site administrators). |
|
| 446 | + | - **Registering:** |
|
| 447 | + | - An administrator creates a one-time registration token, valid for a day and shown once with the command that uses it. |
|
| 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 | + | - The runner's key is bound at registration: receipts from that runner signed by another key are not kept. |
|
| 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 | + | - **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 | + | - **Actions:** |
|
| 454 | + | - **Drain:** the runner finishes its jobs and claims return nothing. |
|
| 455 | + | - **Disable:** its requests are refused. |
|
| 456 | + | - **Resume.** |
|
| 457 | + | - **Remove:** refused while the runner has running jobs. Its credential stops working, and its jobs keep its name. |
|
| 458 | + | - **A runner's page** shows its totals (jobs, pass rate, time busy) and its job history, newest first, with links to runs and signed receipts. Jobs record the runner that claimed them (`flow_jobs.runner_id`). |
|
| 445 | 459 | - **Signed receipts** (Oxygen spec §14.6): `finish` may carry `signed_receipt`, the runner's signed receipt as hex. The hub keeps it in the repository when four things hold: |
|
| 446 | 460 | - it verifies; |
|
| 447 | 461 | - it signs the job's receipt; |
|
| … | 169 unchanged lines |
modified templates/admin.html +1 −1
| … | 1 unchanged lines |
||
| 2 | 2 | {% block title %}Administration{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | 4 | <div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div> |
|
| 5 | - | <p><a class="go small" href="/admin/storage">Storage and maintenance</a></p> |
|
| 5 | + | <p><a class="go small" href="/admin/storage">Storage and maintenance</a> <a class="go small" href="/admin/runners">Runners</a></p> |
|
| 6 | 6 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 7 | 7 | <div class="side-layout"> |
|
| 8 | 8 | <div> |
|
| … | 35 unchanged lines |
modified templates/style.css +6 −0
| … | 566 unchanged lines |
||
| 567 | 567 | svg.trust-graph .node.fail rect { stroke: var(--bad); } |
|
| 568 | 568 | svg.trust-graph text { font-size: 11px; fill: var(--ink); } |
|
| 569 | 569 | svg.trust-graph text.s { font-size: 10px; fill: var(--mute); } |
|
| 570 | + | /* Runners (hub spec §13.2) */ |
|
| 571 | + | .runner-status { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; padding: 1px 6px; border: 1px solid var(--line); } |
|
| 572 | + | .runner-status.st-online { color: var(--ok); border-color: var(--ok); } |
|
| 573 | + | .runner-status.st-offline { color: var(--mute); } |
|
| 574 | + | .runner-status.st-draining { color: var(--warn); border-color: var(--warn); } |
|
| 575 | + | .runner-status.st-disabled { color: var(--bad); border-color: var(--bad); } |