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