OXYGENHUB
◆ v0.4.1 oxygenhub / crates / hub / src / db / runners.rs
13.2 KiB · Rust · last changed Runners decline, and the queue says why a job waits 1 month ago source blame history raw
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, Default, 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
    /// 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>,
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>,
34
    /// The tiers it offered at its last claim, comma-separated.
35
    pub tiers: String,
36
    /// The images it held then (a JSON array); empty when it did not say.
37
    pub images: String,
38
    /// BLAKE3 of what it offers; its declines hold until this changes.
39
    pub profile: String,
40
}
41
42
/// Which repositories' jobs a runner may take (oxygenhub#41).
43
#[derive(Clone, Debug, Default, PartialEq, Eq)]
44
pub struct Scope {
45
    /// Empty: any owner.
46
    pub owner: String,
47
    /// `None`: any of the owner's repositories.
48
    pub repo_id: Option<i64>,
49
}
50
51
impl Scope {
52
    pub fn is_site(&self) -> bool {
53
        self.owner.is_empty() && self.repo_id.is_none()
54
    }
55
}
56
57
/// A runner that has not polled for this long is shown offline.
58
pub const ONLINE_MS: i64 = 90_000;
59
60
impl Runner {
61
    pub fn online(&self) -> bool {
62
        self.last_seen_ms.is_some_and(|t| now_ms() - t < ONLINE_MS)
63
    }
64
65
    /// `online`, `offline`, `draining` or `disabled`.
66
    pub fn status(&self) -> &'static str {
67
        match self.state.as_str() {
68
            "disabled" => "disabled",
69
            "draining" => "draining",
70
            _ if self.online() => "online",
71
            _ => "offline",
72
        }
73
    }
74
75
    /// Every label it carries: what it reports, and what an administrator
76
    /// gave it.
77
    pub fn label_list(&self) -> Vec<&str> {
78
        let mut out: Vec<&str> = self.labels.split(',').filter(|l| !l.is_empty()).collect();
79
        for l in self.admin_label_list() {
80
            if !out.contains(&l) {
81
                out.push(l);
82
            }
83
        }
84
        out
85
    }
86
87
    pub fn admin_label_list(&self) -> Vec<&str> {
88
        self.admin_labels.split(',').filter(|l| !l.is_empty()).collect()
89
    }
90
91
    /// The labels it reported itself, as the admin page distinguishes them.
92
    pub fn reported_label_list(&self) -> Vec<&str> {
93
        self.labels.split(',').filter(|l| !l.is_empty()).collect()
94
    }
95
96
    pub fn scope(&self) -> Scope {
97
        Scope { owner: self.scope_owner.clone(), repo_id: self.scope_repo_id }
98
    }
99
}
100
101
pub async fn by_token(pool: &Pool, token_hash: &str) -> sqlx::Result<Option<Runner>> {
102
    sqlx::query_as("SELECT * FROM flow_runners WHERE token_hash = ?").bind(token_hash).fetch_optional(pool).await
103
}
104
105
pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Runner>> {
106
    sqlx::query_as("SELECT * FROM flow_runners WHERE id = ?").bind(id).fetch_optional(pool).await
107
}
108
109
pub async fn list(pool: &Pool) -> sqlx::Result<Vec<Runner>> {
110
    sqlx::query_as("SELECT * FROM flow_runners ORDER BY name, id").fetch_all(pool).await
111
}
112
113
/// The row of a runner configured with a hub.toml token, made on first sight.
114
pub async fn configured(pool: &Pool, token_hash: &str, name: &str) -> sqlx::Result<Runner> {
115
    sqlx::query("INSERT OR IGNORE INTO flow_runners (name, token_hash, source, created_ms) VALUES (?, ?, 'config', ?)")
116
        .bind(name)
117
        .bind(token_hash)
118
        .bind(now_ms())
119
        .execute(pool)
120
        .await?;
121
    Ok(by_token(pool, token_hash).await?.expect("just made"))
122
}
123
124
/// What a claim says about the runner, and that it was seen now. A
125
/// configured runner takes the name it claims with.
126
/// The administrator's settings for a runner (#98).
127
pub async fn set_limits(pool: &Pool, id: i64, admin_labels: &str, timeout_minutes: Option<i64>) -> sqlx::Result<()> {
128
    sqlx::query("UPDATE flow_runners SET admin_labels = ?, timeout_minutes = ? WHERE id = ?")
129
        .bind(admin_labels)
130
        .bind(timeout_minutes)
131
        .bind(id)
132
        .execute(pool)
133
        .await?;
134
    Ok(())
135
}
136
137
pub async fn seen(pool: &Pool, id: i64, name: Option<&str>, info: Option<&flow_core::api::RunnerInfo>) -> sqlx::Result<()> {
138
    let now = now_ms();
139
    match info {
140
        Some(i) => {
141
            sqlx::query(
142
                "UPDATE flow_runners SET arch = ?, os = ?, version = ?, labels = ?, max_jobs = ?, memory_mib = ?, cpus = ?, last_seen_ms = ? WHERE id = ?",
143
            )
144
            .bind(&i.arch)
145
            .bind(&i.os)
146
            .bind(&i.version)
147
            .bind(i.labels.join(","))
148
            .bind(i.max_jobs as i64)
149
            .bind(i.memory_mib.map(i64::from))
150
            .bind(i.cpus.map(i64::from))
151
            .bind(now)
152
            .bind(id)
153
            .execute(pool)
154
            .await?;
155
        }
156
        None => {
157
            sqlx::query("UPDATE flow_runners SET last_seen_ms = ? WHERE id = ?").bind(now).bind(id).execute(pool).await?;
158
        }
159
    }
160
    if let Some(n) = name {
161
        sqlx::query("UPDATE flow_runners SET name = ? WHERE id = ? AND source = 'config'").bind(n).bind(id).execute(pool).await?;
162
    }
163
    Ok(())
164
}
165
166
/// What a runner offers at a claim (OxygenFlow spec §11.1): its tiers and
167
/// images, and the profile hash over those, its architecture, labels and
168
/// capacity.
169
pub async fn set_offer(pool: &Pool, id: i64, tiers: &[String], images: Option<&[String]>, profile: &str) -> sqlx::Result<()> {
170
    let images = images.map(|i| serde_json::to_string(i).unwrap_or_default()).unwrap_or_default();
171
    sqlx::query("UPDATE flow_runners SET tiers = ?, images = ?, profile = ? WHERE id = ?")
172
        .bind(tiers.join(","))
173
        .bind(images)
174
        .bind(profile)
175
        .bind(id)
176
        .execute(pool)
177
        .await?;
178
    Ok(())
179
}
180
181
impl Runner {
182
    pub fn tier_list(&self) -> Vec<&str> {
183
        self.tiers.split(',').filter(|t| !t.is_empty()).collect()
184
    }
185
186
    /// The images it holds, `None` when it does not say.
187
    pub fn image_list(&self) -> Option<Vec<String>> {
188
        if self.images.is_empty() {
189
            None
190
        } else {
191
            serde_json::from_str(&self.images).ok()
192
        }
193
    }
194
}
195
196
pub async fn set_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
197
    sqlx::query("UPDATE flow_runners SET state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?;
198
    Ok(())
199
}
200
201
/// Forget a runner: its credential stops working. Its jobs keep its name.
202
pub async fn remove(pool: &Pool, id: i64) -> sqlx::Result<()> {
203
    sqlx::query("DELETE FROM flow_runners WHERE id = ?").bind(id).execute(pool).await?;
204
    Ok(())
205
}
206
207
/// The key a runner signs receipts with: the one bound, or `key` (named
208
/// `name`) when none is yet.
209
pub async fn bind_key(pool: &Pool, id: i64, key: &str, name: &str) -> sqlx::Result<String> {
210
    sqlx::query("UPDATE flow_runners SET key = ?, key_name = ? WHERE id = ? AND key = ''")
211
        .bind(key)
212
        .bind(name)
213
        .bind(id)
214
        .execute(pool)
215
        .await?;
216
    sqlx::query_scalar("SELECT key FROM flow_runners WHERE id = ?").bind(id).fetch_one(pool).await
217
}
218
219
/// Keys that runners of this hub sign with.
220
pub async fn keys(pool: &Pool) -> sqlx::Result<Vec<String>> {
221
    sqlx::query_scalar("SELECT key FROM flow_runners WHERE key != ''").fetch_all(pool).await
222
}
223
224
/// A one-time registration token, valid for a day, for a runner of `scope`.
225
pub async fn add_registration(pool: &Pool, token_hash: &str, by: &str, scope: &Scope) -> sqlx::Result<()> {
226
    let now = now_ms();
227
    sqlx::query(
228
        "INSERT INTO flow_runner_registrations (token_hash, created_by, created_ms, expires_ms, scope_owner, scope_repo_id) VALUES (?, ?, ?, ?, ?, ?)",
229
    )
230
    .bind(token_hash)
231
    .bind(by)
232
    .bind(now)
233
    .bind(now + 24 * 3_600_000)
234
    .bind(&scope.owner)
235
    .bind(scope.repo_id)
236
    .execute(pool)
237
    .await?;
238
    Ok(())
239
}
240
241
/// The runners that may take a repository's jobs: the site's, its owner's
242
/// and its own (oxygenhub#41).
243
pub async fn serving(pool: &Pool, owner: &str, repo_id: i64) -> sqlx::Result<Vec<Runner>> {
244
    sqlx::query_as(
245
        "SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) \
246
         OR (scope_owner = ? AND scope_repo_id IS NULL) OR scope_repo_id = ? ORDER BY name",
247
    )
248
    .bind(owner)
249
    .bind(repo_id)
250
    .fetch_all(pool)
251
    .await
252
}
253
254
/// The runners that serve an owner: the site's, and those scoped to the
255
/// owner or one of its repositories.
256
pub async fn serving_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Runner>> {
257
    sqlx::query_as("SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) OR scope_owner = ? ORDER BY name")
258
        .bind(owner)
259
        .fetch_all(pool)
260
        .await
261
}
262
263
/// Change the repositories a runner serves.
264
pub async fn set_scope(pool: &Pool, id: i64, scope: &Scope) -> sqlx::Result<()> {
265
    sqlx::query("UPDATE flow_runners SET scope_owner = ?, scope_repo_id = ? WHERE id = ?")
266
        .bind(&scope.owner)
267
        .bind(scope.repo_id)
268
        .bind(id)
269
        .execute(pool)
270
        .await?;
271
    Ok(())
272
}
273
274
/// Registration tokens not used yet and not expired: (created by, created, expires).
275
pub async fn pending_registrations(pool: &Pool) -> sqlx::Result<Vec<(String, i64, i64)>> {
276
    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")
277
        .bind(now_ms())
278
        .fetch_all(pool)
279
        .await
280
}
281
282
/// Use registration token `token_hash` to register a runner: `None` when
283
/// the token is unknown, used or expired.
284
#[allow(clippy::too_many_arguments)]
285
pub async fn register(
286
    pool: &Pool,
287
    registration: &str,
288
    name: &str,
289
    token_hash: &str,
290
    key: &str,
291
    key_name: &str,
292
    by_default: &str,
293
) -> sqlx::Result<Option<Runner>> {
294
    let mut tx = super::begin_write(pool).await?;
295
    let now = now_ms();
296
    let found: Option<(String, String, Option<i64>)> = sqlx::query_as(
297
        "SELECT created_by, scope_owner, scope_repo_id FROM flow_runner_registrations WHERE token_hash = ? AND used_ms IS NULL AND expires_ms > ?",
298
    )
299
    .bind(registration)
300
    .bind(now)
301
    .fetch_optional(&mut *tx)
302
    .await?;
303
    let Some((who, scope_owner, scope_repo_id)) = found else { return Ok(None) };
304
    let id: i64 = sqlx::query_scalar(
305
        "INSERT INTO flow_runners (name, token_hash, source, key, key_name, created_ms, created_by, scope_owner, scope_repo_id) VALUES (?, ?, 'registered', ?, ?, ?, ?, ?, ?) RETURNING id",
306
    )
307
    .bind(name)
308
    .bind(token_hash)
309
    .bind(key)
310
    .bind(key_name)
311
    .bind(now)
312
    .bind(if who.is_empty() { by_default } else { &who })
313
    .bind(scope_owner)
314
    .bind(scope_repo_id)
315
    .fetch_one(&mut *tx)
316
    .await?;
317
    sqlx::query("UPDATE flow_runner_registrations SET used_ms = ?, runner_id = ? WHERE token_hash = ?")
318
        .bind(now)
319
        .bind(id)
320
        .bind(registration)
321
        .execute(&mut *tx)
322
        .await?;
323
    tx.commit().await?;
324
    get(pool, id).await
325
}
326
327
/// A job a runner ran, for its history.
328
#[derive(Clone, Debug, FromRow)]
329
pub struct RunnerJob {
330
    pub owner: String,
331
    pub repo: String,
332
    pub run_number: i64,
333
    pub job: String,
334
    pub status: String,
335
    pub started_ms: Option<i64>,
336
    pub finished_ms: Option<i64>,
337
    pub receipt_attest: String,
338
}
339
340
/// Jobs runner `id` ran or is running, newest first.
341
pub async fn history(pool: &Pool, id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<RunnerJob>> {
342
    sqlx::query_as(
343
        "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, \
344
         j.finished_ms AS finished_ms, j.receipt_attest AS receipt_attest \
345
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \
346
         WHERE j.runner_id = ? AND (? IS NULL OR j.started_ms < ?) ORDER BY j.started_ms DESC LIMIT ?",
347
    )
348
    .bind(id)
349
    .bind(before)
350
    .bind(before)
351
    .bind(limit)
352
    .fetch_all(pool)
353
    .await
354
}
355
356
/// (jobs run, passed, busy milliseconds) of runner `id`.
357
pub async fn totals(pool: &Pool, id: i64) -> sqlx::Result<(i64, i64, i64)> {
358
    sqlx::query_as(
359
        "SELECT COUNT(*), COALESCE(SUM(status = 'passed'), 0), COALESCE(SUM(finished_ms - started_ms), 0) \
360
         FROM flow_jobs WHERE runner_id = ? AND finished_ms IS NOT NULL",
361
    )
362
    .bind(id)
363
    .fetch_one(pool)
364
    .await
365
}
366
367
/// Jobs running on each runner now, by runner id.
368
pub async fn running(pool: &Pool) -> sqlx::Result<Vec<(i64, i64)>> {
369
    sqlx::query_as("SELECT runner_id, COUNT(*) FROM flow_jobs WHERE status = 'running' AND runner_id IS NOT NULL GROUP BY runner_id")
370
        .fetch_all(pool)
371
        .await
372
}
373
374
pub async fn set_job_runner(pool: &Pool, job_id: i64, runner_id: i64) -> sqlx::Result<()> {
375
    sqlx::query("UPDATE flow_jobs SET runner_id = ? WHERE id = ?").bind(runner_id).bind(job_id).execute(pool).await?;
376
    Ok(())
377
}